@myazahq/kyc-sdk-react-native 2.5.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 (167) hide show
  1. package/README.md +151 -5
  2. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +6 -4
  3. package/app.plugin.js +89 -8
  4. package/ios/HybridMyazaFaceDetector.swift +8 -3
  5. package/package.json +14 -2
  6. package/src/MyazaBiometricAuth.tsx +119 -0
  7. package/src/components/CountryField.tsx +8 -0
  8. package/src/components/CountryRegionPicker.tsx +184 -0
  9. package/src/components/DialCodePicker.tsx +47 -68
  10. package/src/components/DialCodeRow.tsx +102 -0
  11. package/src/components/FramedMapPicker.tsx +122 -0
  12. package/src/components/GeoBadge.tsx +34 -0
  13. package/src/components/Icon.tsx +2 -185
  14. package/src/components/KycFlow.tsx +60 -32
  15. package/src/components/LineSkeleton.tsx +100 -0
  16. package/src/components/MapChrome.tsx +67 -0
  17. package/src/components/MapPinMarker.tsx +54 -0
  18. package/src/components/MapPinPicker.tsx +179 -0
  19. package/src/components/MilestoneTrack.tsx +155 -0
  20. package/src/components/MyazaInput.tsx +17 -1
  21. package/src/components/OptionRow.tsx +22 -3
  22. package/src/components/PhoneNumberInput.tsx +8 -0
  23. package/src/components/PoweredBy.tsx +5 -5
  24. package/src/components/PresenceBlocks.tsx +153 -0
  25. package/src/components/StepView.tsx +17 -0
  26. package/src/components/StickyActions.tsx +56 -0
  27. package/src/components/dialCodeRows.ts +64 -0
  28. package/src/components/icon-map.ts +176 -0
  29. package/src/components/stepHeaderMeta.tsx +42 -4
  30. package/src/config/addressCollection.ts +125 -0
  31. package/src/config/biometricOptions.ts +100 -0
  32. package/src/config/business.ts +20 -1
  33. package/src/config/businessSteps.ts +8 -2
  34. package/src/config/consentStep.ts +19 -0
  35. package/src/config/proofOfAddress.ts +54 -11
  36. package/src/config/regions.ts +24 -0
  37. package/src/config/stepOrder.ts +79 -5
  38. package/src/config/uploadLimits.ts +35 -0
  39. package/src/config/workflowMerge.ts +11 -5
  40. package/src/index.ts +54 -1
  41. package/src/lib/address-current-location.ts +171 -0
  42. package/src/lib/address-field-modes.ts +119 -0
  43. package/src/lib/address-flow.ts +172 -0
  44. package/src/lib/address-helpers.ts +48 -0
  45. package/src/lib/address-line.ts +76 -0
  46. package/src/lib/address-pin-move.ts +97 -0
  47. package/src/lib/address-step-recovery.ts +63 -0
  48. package/src/lib/authed-image.ts +78 -0
  49. package/src/lib/biometric-auth.ts +52 -0
  50. package/src/lib/biometric-copy.ts +51 -0
  51. package/src/lib/captureRing.ts +83 -0
  52. package/src/lib/country-adoption.ts +89 -0
  53. package/src/lib/inferred-country.ts +65 -0
  54. package/src/lib/livenessLayout.ts +49 -0
  55. package/src/lib/map-frame.ts +159 -0
  56. package/src/lib/map-tiles.ts +143 -0
  57. package/src/lib/poa-country-gate.ts +27 -0
  58. package/src/lib/result-copy.ts +116 -0
  59. package/src/lib/result-wait.ts +53 -0
  60. package/src/lib/review-map-surface.ts +26 -0
  61. package/src/lib/scope.ts +31 -0
  62. package/src/lib/selfie-upload-wait.ts +79 -0
  63. package/src/lib/street-view-fov.ts +42 -0
  64. package/src/lib/webview-available.ts +47 -0
  65. package/src/liveness/useLiveness.ts +6 -1
  66. package/src/presence/background-math.ts +100 -0
  67. package/src/presence/background-store.ts +82 -0
  68. package/src/presence/background.ts +165 -0
  69. package/src/presence/foreground-service.ts +193 -0
  70. package/src/presence/fs.ts +51 -0
  71. package/src/presence/geofence.ts +41 -0
  72. package/src/presence/math.ts +44 -0
  73. package/src/presence/post.ts +40 -0
  74. package/src/presence/report.ts +87 -0
  75. package/src/presence/sampler.ts +110 -0
  76. package/src/presence/status.ts +92 -0
  77. package/src/presence/store.ts +94 -0
  78. package/src/presence/tier.ts +36 -0
  79. package/src/presence/watch-wait.ts +81 -0
  80. package/src/screens/AddressCountryControl.tsx +115 -0
  81. package/src/screens/BusinessDetailsFields.tsx +3 -0
  82. package/src/screens/BusinessDetailsStep.tsx +2 -0
  83. package/src/screens/BusinessDocumentSlot.tsx +3 -2
  84. package/src/screens/BusinessDocumentsStep.tsx +4 -55
  85. package/src/screens/CompanyInfoFields.tsx +6 -1
  86. package/src/screens/ConsentStep.tsx +6 -17
  87. package/src/screens/ContactActions.tsx +54 -0
  88. package/src/screens/ContactDestinationField.tsx +4 -0
  89. package/src/screens/ContactEntryPanel.tsx +75 -0
  90. package/src/screens/ContactVerificationStep.tsx +26 -56
  91. package/src/screens/CountrySelectStep.tsx +13 -147
  92. package/src/screens/LivenessAvatar.tsx +15 -3
  93. package/src/screens/LivenessStep.tsx +154 -52
  94. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  95. package/src/screens/ProofOfAddressParts.tsx +151 -0
  96. package/src/screens/ProofOfAddressStep.tsx +58 -99
  97. package/src/screens/SubmittedBadge.tsx +25 -0
  98. package/src/screens/SubmittedError.tsx +64 -0
  99. package/src/screens/SubmittedResult.tsx +117 -0
  100. package/src/screens/SubmittedStep.tsx +71 -164
  101. package/src/screens/SubmittedSuccess.tsx +127 -0
  102. package/src/screens/SubmittedWaiting.tsx +45 -0
  103. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  104. package/src/screens/address/AddressIntroGate.tsx +148 -0
  105. package/src/screens/address/AddressMapStub.tsx +50 -0
  106. package/src/screens/address/AddressPinStep.tsx +178 -0
  107. package/src/screens/address/AddressReviewStep.tsx +162 -0
  108. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  109. package/src/screens/address/AddressSearchField.tsx +115 -0
  110. package/src/screens/address/AddressSearchStep.tsx +94 -0
  111. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  112. package/src/screens/address/DetailsSheet.tsx +175 -0
  113. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  114. package/src/screens/address/EntranceDropzone.tsx +178 -0
  115. package/src/screens/address/EntranceFraming.tsx +66 -0
  116. package/src/screens/address/EntrancePills.tsx +60 -0
  117. package/src/screens/address/FramedStreetView.tsx +155 -0
  118. package/src/screens/address/IntroDisclosures.tsx +210 -0
  119. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  120. package/src/screens/address/PinSummaryRow.tsx +105 -0
  121. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  122. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  123. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  124. package/src/screens/address/SearchResults.tsx +105 -0
  125. package/src/screens/address/SearchScreen.tsx +176 -0
  126. package/src/screens/address/SkipForNow.tsx +43 -0
  127. package/src/screens/address/StreetViewChrome.tsx +81 -0
  128. package/src/screens/address/detail-values.ts +22 -0
  129. package/src/screens/address/fix-source.ts +27 -0
  130. package/src/screens/address/index.ts +8 -0
  131. package/src/screens/address/meta.ts +44 -0
  132. package/src/screens/address/use-address-flow.ts +200 -0
  133. package/src/screens/address/use-label-pin.ts +80 -0
  134. package/src/screens/address/use-pin-actions.ts +192 -0
  135. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  136. package/src/screens/consent/model.ts +79 -10
  137. package/src/screens/liveness/CaptureRing.tsx +91 -0
  138. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  139. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  140. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  141. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  142. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  143. package/src/screens/liveness/index.ts +4 -0
  144. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  145. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  146. package/src/screens/useAddressPhotoAttach.ts +77 -0
  147. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  148. package/src/screens/usePoaAttach.ts +12 -15
  149. package/src/services/api-biometric.ts +37 -0
  150. package/src/services/api-types-biometric.ts +41 -0
  151. package/src/services/api-types.ts +93 -1
  152. package/src/services/api-verify-types.ts +37 -0
  153. package/src/services/api.ts +133 -3
  154. package/src/services/deviceMetadata.ts +1 -1
  155. package/src/services/location.ts +234 -0
  156. package/src/services/uploadErrors.ts +30 -0
  157. package/src/services/workflowGate.ts +7 -0
  158. package/src/store/address.ts +97 -0
  159. package/src/store/derive.ts +29 -4
  160. package/src/store/kycStore.ts +112 -3
  161. package/src/store/serverConfig.ts +18 -0
  162. package/src/store/session.ts +23 -4
  163. package/src/store/state.ts +134 -0
  164. package/src/store/submit.ts +18 -1
  165. package/src/types/config.ts +48 -1
  166. package/src/types/verification.ts +12 -0
  167. package/src/types/workflow.ts +97 -1
@@ -87,8 +87,14 @@ export type BusinessSectionStep =
87
87
  export function businessSectionSteps(
88
88
  business: WorkflowBusinessConfig | undefined,
89
89
  withQuestionnaire = false,
90
- ): (BusinessSectionStep | 'questionnaire')[] {
91
- const steps: (BusinessSectionStep | 'questionnaire')[] = ['business-details'];
90
+ withAddressCollection = false,
91
+ ): (BusinessSectionStep | 'questionnaire' | 'address-collection')[] {
92
+ const steps: (BusinessSectionStep | 'questionnaire' | 'address-collection')[] = [
93
+ 'business-details',
94
+ ];
95
+ // The premises pin follows the company details it is about — before the
96
+ // paperwork, and long before the application hands over to other people.
97
+ if (withAddressCollection) steps.push('address-collection');
92
98
  // Documents BEFORE key people: they are about the company the applicant has
93
99
  // just identified, so they follow that thread, and the register's officer
94
100
  // list - which the key-people step is a confirmation of - is what should
@@ -0,0 +1,19 @@
1
+ import type { KYCStep } from '../types/config';
2
+
3
+ // ─── The consent screen switch (`consentStep`) ──────────────────────────────
4
+ //
5
+ // A workflow can switch the opening consent (welcome) screen off, for a host
6
+ // app that has already asked: the flow then opens on its first real step.
7
+ // Mirrors the server's `WorkflowConfigSchema.consentStep` and the web /
8
+ // Flutter helpers of the same name; keep the default in lockstep.
9
+
10
+ /** Whether the flow opens on the consent screen. Absent = yes. */
11
+ export function hasConsentStep(config: { consentStep?: boolean | null }): boolean {
12
+ return config.consentStep !== false;
13
+ }
14
+
15
+ /** The step a built order opens on; 'consent' for an empty order, which no
16
+ * flow produces. */
17
+ export function openingStepOf(order: readonly KYCStep[]): KYCStep {
18
+ return order[0] ?? 'consent';
19
+ }
@@ -8,12 +8,13 @@
8
8
  // server is going to make anyway.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- import type { PoaDocumentType, ProofOfAddressConfig } from '../types/workflow';
11
+ import type { PoaDocumentType, PoaNameRule, ProofOfAddressConfig } from '../types/workflow';
12
12
 
13
13
  export const POA_TYPE_LABELS: Record<PoaDocumentType, string> = {
14
14
  utility_bill: 'Utility bill',
15
15
  bank_statement: 'Bank statement',
16
16
  tenancy_agreement: 'Tenancy agreement',
17
+ government_document: 'Government-issued document',
17
18
  other: 'Other document',
18
19
  };
19
20
 
@@ -21,9 +22,15 @@ const ALL_POA_TYPES: PoaDocumentType[] = [
21
22
  'utility_bill',
22
23
  'bank_statement',
23
24
  'tenancy_agreement',
25
+ 'government_document',
24
26
  'other',
25
27
  ];
26
28
 
29
+ /** A kind THIS build can label and draw. A newer kind the dashboard offers
30
+ * before the SDK ships is hidden rather than rendered as a blank row. */
31
+ const knownKinds = (kinds: readonly string[] | undefined): PoaDocumentType[] =>
32
+ (kinds ?? []).filter((k): k is PoaDocumentType => (ALL_POA_TYPES as string[]).includes(k));
33
+
27
34
  /** Default recency window the server checks the document date against. */
28
35
  export const DEFAULT_POA_MAX_AGE_DAYS = 90;
29
36
 
@@ -32,10 +39,52 @@ export function hasProofOfAddressStep(poa: ProofOfAddressConfig | undefined | nu
32
39
  return poa?.enabled === true;
33
40
  }
34
41
 
35
- /** The document kinds on offer. An absent or empty list means all of them. */
36
- export function poaDocumentTypes(poa: ProofOfAddressConfig | undefined): PoaDocumentType[] {
37
- const configured = poa?.documentTypes;
38
- return configured && configured.length > 0 ? configured : ALL_POA_TYPES;
42
+ /**
43
+ * The document kinds on offer for `country` (mirror of the web SDK's
44
+ * `poaOfferedKinds` — keep the three in lockstep): that country's override
45
+ * when the workflow declares one, else the global list, else all four.
46
+ */
47
+ export function poaDocumentTypes(
48
+ poa: ProofOfAddressConfig | undefined,
49
+ country?: string | null,
50
+ ): PoaDocumentType[] {
51
+ const override = knownKinds(country ? poa?.countryDocuments?.[country.toUpperCase()] : undefined);
52
+ if (override.length > 0) return override;
53
+ const configured = knownKinds(poa?.documentTypes);
54
+ return configured.length > 0 ? configured : ALL_POA_TYPES;
55
+ }
56
+
57
+ /**
58
+ * The name rule the server judges THIS document under — the country's per-kind
59
+ * exception, else the workflow default, else `required`. Mirror of the web
60
+ * SDK's `poaNamePolicy` and the server's `resolvePoaNamePolicy` — keep in
61
+ * lockstep. Read only to word the step: under `off` the header stops asking
62
+ * for the applicant's name.
63
+ */
64
+ export function poaNamePolicy(
65
+ poa: ProofOfAddressConfig | undefined | null,
66
+ country: string | undefined | null,
67
+ kind: PoaDocumentType | undefined | null,
68
+ ): PoaNameRule {
69
+ const exception = country && kind ? poa?.countryNameMatch?.[country.toUpperCase()]?.[kind] : undefined;
70
+ if (exception === 'required' || exception === 'optional' || exception === 'off') return exception;
71
+ const def = poa?.nameMatch;
72
+ return def === 'optional' || def === 'off' ? def : 'required';
73
+ }
74
+
75
+ /**
76
+ * Whether the org's accepted-country list admits `country`. An empty list
77
+ * accepts everyone; an unknown country is not refused here (the server is the
78
+ * gate, and it is soft on full flows).
79
+ */
80
+ export function poaCountryAccepted(
81
+ poa: ProofOfAddressConfig | undefined | null,
82
+ country: string | undefined | null,
83
+ ): boolean {
84
+ const accepted = poa?.countries;
85
+ if (!accepted?.length || !country) return true;
86
+ const code = country.toUpperCase();
87
+ return accepted.some((c) => c.toUpperCase() === code);
39
88
  }
40
89
 
41
90
  /**
@@ -63,12 +112,6 @@ export const POA_ACCEPTED_MIME_TYPES = [
63
112
  'application/pdf',
64
113
  ] as const;
65
114
 
66
- /**
67
- * Local size cap, matching the web SDK. The server allows more, but rejecting
68
- * here gives an immediate, specific message instead of a slow 413.
69
- */
70
- export const POA_MAX_BYTES = 20 * 1024 * 1024;
71
-
72
115
  export function isAcceptedPoaMimeType(mimeType: string | undefined): boolean {
73
116
  const base = (mimeType?.split(';')[0] ?? '').trim().toLowerCase();
74
117
  return (POA_ACCEPTED_MIME_TYPES as readonly string[]).includes(base);
@@ -116,3 +116,27 @@ export function groupCountriesByRegion(codes: string[]): RegionGroup[] {
116
116
  countries: buckets.get(region)!.sort((a, b) => a.name.localeCompare(b.name)),
117
117
  }));
118
118
  }
119
+
120
+ /**
121
+ * Lift the visitor's IP country out of its alphabetical or regional place to
122
+ * the top of a picker, so the one country most likely to be theirs is the
123
+ * first thing they see rather than something to scroll for.
124
+ *
125
+ * Takes the ALREADY-FILTERED list: the pin stays subject to the search, so
126
+ * typing narrows to what was asked for rather than keeping a row that does
127
+ * not match it. Generic over the row shape because the country-select step
128
+ * pins bare ISO codes and the dial-code sheet pins option objects; one rule,
129
+ * two callers. Mirrors the web SDK's CountryRegionPicker + PhoneNumberInput
130
+ * and Flutter's `pinGeoRow`.
131
+ */
132
+ export function pinGeoRow<T>(
133
+ visible: readonly T[],
134
+ geoCountry: string | null | undefined,
135
+ codeOf: (item: T) => string,
136
+ ): { pinned: T | null; rest: T[] } {
137
+ const geo = geoCountry?.trim().toUpperCase();
138
+ if (!geo) return { pinned: null, rest: [...visible] };
139
+ const hit = visible.find((item) => codeOf(item).toUpperCase() === geo);
140
+ if (hit === undefined) return { pinned: null, rest: [...visible] };
141
+ return { pinned: hit, rest: visible.filter((item) => item !== hit) };
142
+ }
@@ -21,19 +21,37 @@
21
21
  import type { WorkflowBusinessConfig } from '../types/business';
22
22
  import type { KYCStep } from '../types/config';
23
23
  import { applyResubmitSteps, type ResubmitConfig } from '../lib/resubmit';
24
+ import { addressFlowSteps, type AddressFlowOptions } from '../lib/address-flow';
24
25
  import { businessSectionSteps, hasApplicantVerification } from './businessSteps';
25
26
 
26
27
  export interface StepOrderOptions {
27
28
  isBusiness: boolean;
29
+ /** Scoped flows: no identity section — each scope's headline step is the
30
+ * flow (see lib/scope.ts). */
31
+ scope?: import('../lib/scope').WorkflowScope | null;
28
32
  /** Business (KYB) configuration — drives the application-section steps. */
29
33
  business?: WorkflowBusinessConfig;
30
34
  hasDocCapture: boolean;
31
35
  hasNfc: boolean;
32
36
  hasLiveness: boolean;
33
37
  hasCountrySelect: boolean;
38
+ /**
39
+ * Whether the flow opens on the consent screen. Absent = yes. `false` is the
40
+ * workflow's `consentStep: false`: the host app has already asked, so the
41
+ * flow opens on its first real step instead (config/consentStep.ts).
42
+ */
43
+ hasConsent?: boolean;
34
44
  hasEmailVerification: boolean;
35
45
  hasPhoneVerification: boolean;
36
46
  hasPoa: boolean;
47
+ hasAddressCollection: boolean;
48
+ /**
49
+ * Which address screens the flow has, from the ONE `addressFlowFor` call.
50
+ * The list is built by `addressFlowSteps`, the same builder the address
51
+ * steps navigate by, so the progress bar and the flow cannot disagree.
52
+ * Absent falls back to the default shape (no search, entrance offered).
53
+ */
54
+ addressFlow?: AddressFlowOptions;
37
55
  hasQuestionnaire: boolean;
38
56
  /**
39
57
  * A reviewer sent this back to redo specific steps.
@@ -68,6 +86,11 @@ export function buildStepOrder(o: StepOrderOptions): KYCStep[] {
68
86
  return applyResubmitSteps(fullStepOrder(o), o.resubmit);
69
87
  }
70
88
 
89
+ /** The flow's opening screen: consent, unless the workflow switched it off. */
90
+ function openingSteps(o: StepOrderOptions): KYCStep[] {
91
+ return o.hasConsent === false ? [] : ['consent'];
92
+ }
93
+
71
94
  /** The flow as configured, before any reviewer narrowing. */
72
95
  function fullStepOrder(o: StepOrderOptions): KYCStep[] {
73
96
  // Business (KYB) flow — the application section, then (when the workflow
@@ -77,9 +100,9 @@ function fullStepOrder(o: StepOrderOptions): KYCStep[] {
77
100
  // its questions are about the company, so it stays with the company form
78
101
  // rather than trailing the applicant's own capture leg.
79
102
  const steps: KYCStep[] = [
80
- 'consent',
103
+ ...openingSteps(o),
81
104
  ...contactSteps(o),
82
- ...businessSectionSteps(o.business, o.hasQuestionnaire),
105
+ ...businessSectionSteps(o.business, o.hasQuestionnaire, o.hasAddressCollection),
83
106
  ];
84
107
  if (hasApplicantVerification(o.business)) {
85
108
  // The applicant may hold an ID issued anywhere the org can verify —
@@ -93,11 +116,52 @@ function fullStepOrder(o: StepOrderOptions): KYCStep[] {
93
116
  return steps;
94
117
  }
95
118
 
119
+ // Scoped flows: the scope's headline section IS the flow.
120
+ if (o.scope === 'address') {
121
+ const steps: KYCStep[] = [...openingSteps(o), ...contactSteps(o)];
122
+ if (o.hasPoa) steps.push('proof-of-address');
123
+ steps.push(
124
+ ...addressFlowSteps(
125
+ o.addressFlow ?? { searchAvailable: false, photoMode: 'optional', streetViewOffered: false },
126
+ ),
127
+ );
128
+ if (o.hasQuestionnaire) steps.push('questionnaire');
129
+ steps.push('submitted');
130
+ return steps;
131
+ }
132
+ if (o.scope === 'biometric-authentication' || o.scope === 'biometric-enrollment') {
133
+ const steps: KYCStep[] = [...openingSteps(o), ...contactSteps(o), 'liveness'];
134
+ if (o.hasQuestionnaire) steps.push('questionnaire');
135
+ steps.push('submitted');
136
+ return steps;
137
+ }
138
+ if (o.scope === 'questionnaire') {
139
+ return [...openingSteps(o), ...contactSteps(o), 'questionnaire', 'submitted'];
140
+ }
141
+ if (o.scope === 'contact') {
142
+ return [...openingSteps(o), ...contactSteps(o), 'submitted'];
143
+ }
144
+
96
145
  const middle: KYCStep[] = [...captureLeg(o)];
97
146
  if (o.hasPoa) middle.push('proof-of-address');
147
+ // The address flow is FOUR real steps on an individual flow (find it, confirm
148
+ // it, show it, commit it), so the progress bar advances through them and back
149
+ // is ordinary step navigation. KYB keeps the single premises step, inserted
150
+ // by businessSectionSteps above.
151
+ if (o.hasAddressCollection) {
152
+ middle.push(
153
+ ...addressFlowSteps(
154
+ o.addressFlow ?? {
155
+ searchAvailable: false,
156
+ photoMode: 'optional',
157
+ streetViewOffered: false,
158
+ },
159
+ ),
160
+ );
161
+ }
98
162
  if (o.hasQuestionnaire) middle.push('questionnaire');
99
163
  return [
100
- 'consent',
164
+ ...openingSteps(o),
101
165
  ...contactSteps(o),
102
166
  ...(o.hasCountrySelect ? (['country-select'] as KYCStep[]) : []),
103
167
  'id-type',
@@ -129,10 +193,20 @@ export function nextStepInOrder(step: KYCStep, o: StepOrderOptions): KYCStep {
129
193
  return order[index + 1] ?? order[order.length - 1]!;
130
194
  }
131
195
 
132
- /** The step before `step` — what the back button goes to. */
196
+ /**
197
+ * The step before `step` — what the back button goes to.
198
+ *
199
+ * A step not in the order stands still, for the same reason `nextStepInOrder`
200
+ * refuses to guess at one: it has no predecessor in a flow that does not
201
+ * contain it. This used to fall through to `order[0]`, so an applicant resumed
202
+ * onto a step the flow had since dropped was thrown all the way back to consent
203
+ * by a single back-press, discarding everything they had done. Standing still
204
+ * is recoverable; that was not.
205
+ */
133
206
  export function previousStepInOrder(step: KYCStep, o: StepOrderOptions): KYCStep {
134
207
  const order = buildStepOrder(o);
135
208
  const index = order.indexOf(step);
136
- if (index <= 0) return order[0]!;
209
+ if (index < 0) return step;
210
+ if (index === 0) return order[0]!;
137
211
  return order[index - 1]!;
138
212
  }
@@ -0,0 +1,35 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Upload size limits.
3
+ //
4
+ // One rule for every document the applicant attaches (proof of address, the
5
+ // KYB company documents, the entrance photo): images up to 5 MB, PDFs up to
6
+ // 15 MB (user decision 2026-09-06). The server's own cap is higher, so refusing
7
+ // here gives an immediate, specific message instead of a slow 413. A THREE-WAY
8
+ // MIRROR with the web SDK's `lib/upload-limits.ts` and Flutter's
9
+ // `config/upload_limits.dart`, pinned to
10
+ // `kyc-sdk-flutter/test/upload_limits_vectors.json`.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export const IMAGE_MAX_BYTES = 5 * 1024 * 1024;
14
+ export const PDF_MAX_BYTES = 15 * 1024 * 1024;
15
+
16
+ /** The line under every drop zone: what is accepted, and how big. */
17
+ export const UPLOAD_HINT = 'PDF, JPG, PNG · PDF max 15 MB, images max 5 MB';
18
+
19
+ export function isPdfMime(mime: string | null | undefined): boolean {
20
+ return (mime?.split(';')[0] ?? '').trim().toLowerCase() === 'application/pdf';
21
+ }
22
+
23
+ /**
24
+ * The refusal for a file over its cap, or null when it fits. An unknown size
25
+ * also passes: the server still judges the bytes at its own cap, and a picker
26
+ * that reports no size must not block a good file.
27
+ */
28
+ export function uploadSizeError(
29
+ mime: string | null | undefined,
30
+ size: number | null | undefined,
31
+ ): string | null {
32
+ if (typeof size !== 'number' || !Number.isFinite(size)) return null;
33
+ if (isPdfMime(mime)) return size > PDF_MAX_BYTES ? 'PDF is too large (max 15 MB).' : null;
34
+ return size > IMAGE_MAX_BYTES ? 'Image is too large (max 5 MB).' : null;
35
+ }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export const WORKFLOW_KEYS = [
18
18
  'subjectType',
19
+ 'scope',
19
20
  'business',
20
21
  'country',
21
22
  'countries',
@@ -27,8 +28,10 @@ export const WORKFLOW_KEYS = [
27
28
  'enableLiveness',
28
29
  'livenessMode',
29
30
  'flashSequenceLength',
31
+ 'biometric',
30
32
  'deviceIntelligence',
31
33
  'keyPeopleLinkRecovery',
34
+ 'consentStep',
32
35
  'requireMobileDevice',
33
36
  'voiceGuidance',
34
37
  'showThemeToggle',
@@ -41,6 +44,7 @@ export const WORKFLOW_KEYS = [
41
44
  'phoneVerification',
42
45
  'questionnaire',
43
46
  'proofOfAddress',
47
+ 'addressCollection',
44
48
  'nfc',
45
49
  // Set only on a session a reviewer sent back, never on a published flow.
46
50
  'resubmit',
@@ -53,8 +57,10 @@ export type WorkflowKey = (typeof WORKFLOW_KEYS)[number];
53
57
  * key it DEFINES; an absent key leaves the prop alone (which is how a flow that
54
58
  * only sets, say, `livenessMode` doesn't wipe a prop-supplied `userId`).
55
59
  *
56
- * `appearance` merges shallowly with flow keys winning per-field, so a flow
57
- * that only sets `primaryColor` doesn't wipe a prop-supplied `logo`.
60
+ * `appearance` and `biometric` merge shallowly with flow keys winning
61
+ * per-field, so a flow that only sets `primaryColor` doesn't wipe a
62
+ * prop-supplied `logo`, and a flow that only switches the selfie review on
63
+ * doesn't wipe a host app's `doneButton: false`.
58
64
  *
59
65
  * Pure and side-effect free.
60
66
  */
@@ -67,10 +73,10 @@ export function mergeWorkflowConfig<P extends Record<string, unknown>>(
67
73
  for (const key of WORKFLOW_KEYS) {
68
74
  const value = flowConfig[key];
69
75
  if (value === undefined) continue;
70
- if (key === 'appearance') {
71
- const propAppearance = props['appearance'];
76
+ if (key === 'appearance' || key === 'biometric') {
77
+ const propBlock = props[key];
72
78
  merged[key] = {
73
- ...(typeof propAppearance === 'object' && propAppearance !== null ? propAppearance : {}),
79
+ ...(typeof propBlock === 'object' && propBlock !== null ? propBlock : {}),
74
80
  ...(value as Record<string, unknown>),
75
81
  };
76
82
  } else {
package/src/index.ts CHANGED
@@ -11,6 +11,16 @@
11
11
  export { MyazaKYC, useMyazaKYC } from './MyazaKYC';
12
12
  export type { MyazaKYCProps, UseMyazaKYCReturn } from './MyazaKYC';
13
13
 
14
+ // Returning-user face re-authentication (the web SDK's MyazaBiometricAuth).
15
+ export { MyazaBiometricAuth } from './MyazaBiometricAuth';
16
+ export type { MyazaBiometricAuthProps } from './MyazaBiometricAuth';
17
+ export type {
18
+ BiometricAuthRequest,
19
+ BiometricAuthResponse,
20
+ BiometricStatusResponse,
21
+ BiometricLivenessClaim,
22
+ } from './services/api-types-biometric';
23
+
14
24
  // Public config + callback types
15
25
  export type {
16
26
  MyazaKYCConfig,
@@ -32,6 +42,7 @@ export type {
32
42
  PhoneVerificationConfig,
33
43
  ProofOfAddressConfig,
34
44
  PoaDocumentType,
45
+ AddressCollectionConfig,
35
46
  QuestionnaireConfig,
36
47
  QuestionnaireField,
37
48
  QuestionnaireFieldOption,
@@ -56,7 +67,8 @@ export type {
56
67
  WorkflowBusinessDocumentTypeConfig,
57
68
  WorkflowBusinessApplicantConfig,
58
69
  } from './types/business';
59
- export type { KYCSubmission, KYCErrorCode, KYCErrorDetails } from './types/verification';
70
+ export type { KYCSubmission, KYCResult, KYCErrorCode, KYCErrorDetails } from './types/verification';
71
+ export type { BiometricFlowConfig, BiometricCopy, BiometricCopyText } from './config/biometricOptions';
60
72
  export { KYCError } from './types/verification';
61
73
 
62
74
  // ID-type matrix + helpers
@@ -107,6 +119,47 @@ export {
107
119
  export { hasActiveQuestionnaire, questionnaireAnswerKeys } from './config/questionnaire';
108
120
  export { hasEmailVerificationStep, hasPhoneVerificationStep } from './config/contact';
109
121
  export { hasProofOfAddressStep, poaDocumentTypes, poaTypeLabel } from './config/proofOfAddress';
122
+ export { hasAddressCollectionStep } from './config/addressCollection';
123
+ // Presence verification (Address Intelligence Phase 2): the foreground
124
+ // reporter the HOST APP calls on app open, plus the on-device pin store.
125
+ export {
126
+ reportAddressPresence,
127
+ type ReportPresenceOptions,
128
+ type ReportPresenceResult,
129
+ } from './presence/report';
130
+ // The background (OS geofence) tier — the always-on model. register* must run
131
+ // at app-root module scope; enable* asks for the background permission.
132
+ export {
133
+ PRESENCE_GEOFENCE_TASK,
134
+ disableBackgroundPresence,
135
+ enableBackgroundPresence,
136
+ registerBackgroundPresence,
137
+ type EnableBackgroundResult,
138
+ } from './presence/background';
139
+ // The Android foreground-service tier (the OkHi reliability move): a
140
+ // persistent notification keeps the process alive on phones whose battery
141
+ // managers drop geofence transitions. Opt-in; the host words the notification.
142
+ export {
143
+ PRESENCE_LOCATION_TASK,
144
+ disableForegroundService,
145
+ enableForegroundService,
146
+ foregroundServiceRunning,
147
+ type EnableForegroundServiceResult,
148
+ type PresenceNotification,
149
+ } from './presence/foreground-service';
150
+ export {
151
+ openLocationSettings,
152
+ presenceStatus,
153
+ type PresenceStatus,
154
+ type PresenceTier,
155
+ } from './presence/status';
156
+ export { resolvePresenceTier, type PermissionState, type TierInputs } from './presence/tier';
157
+ // savePresencePin is the org-side handoff for STANDALONE address verification:
158
+ // when capture happened on a hosted web link (or the org's own backend already
159
+ // holds the address), the host app hands the SDK the pin so the foreground and
160
+ // background presence tiers can run — the pin otherwise only ever exists where
161
+ // OUR capture step stored it.
162
+ export { clearPresencePin, savePresencePin } from './presence/store';
110
163
 
111
164
  // Country grouping for multi-region flows
112
165
  export { groupCountriesByRegion, regionCountryName, type Region } from './config/regions';
@@ -0,0 +1,171 @@
1
+ import type { AddressParts } from '../services/api';
2
+ import type { LocationFailure } from '../services/location';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // The device's current location, fetched ONCE per verification and shared by
6
+ // every address step.
7
+ //
8
+ // Module-level on purpose: the steps mount and unmount as the person walks the
9
+ // flow, and re-prompting or re-fixing on every screen is exactly the hesitation
10
+ // this exists to remove. Every address step starts the prefetch on mount, so
11
+ // the GPS warms up while the person is still reading the SEARCH screen; by the
12
+ // pin step the fix — and its reverse-geocoded line — are usually already in
13
+ // hand, and the map lands right first time instead of showing a default view
14
+ // and then jumping.
15
+ //
16
+ // A MIRROR of the web SDK's steps/address/current-location.ts, with one
17
+ // deliberate difference: web scopes the cache to a PAGE session, which ends at
18
+ // the next reload. An app session spans many opens of the SDK, so a fix cached
19
+ // an hour ago would drop the pin at yesterday's address. Store CREATION clears
20
+ // it (one store per modal launch), making "this verification" the mobile
21
+ // equivalent of that scope. Clearing it only from the store's reset() is not
22
+ // enough: reset() is a consumer-facing restart, not part of the open path.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export interface CurrentFix {
26
+ lat: number;
27
+ lng: number;
28
+ accuracy: number | null;
29
+ /** The reverse-geocoded line ("11 Bassey Street, Calabar"), when known. */
30
+ label: string | null;
31
+ parts: AddressParts | null;
32
+ }
33
+
34
+ /** A GPS read: the fix, or WHY there is none (services/location.ts). */
35
+ export type FixOutcome =
36
+ | { fix: { lat: number; lng: number; accuracy: number | null } }
37
+ | { failure: LocationFailure };
38
+
39
+ /**
40
+ * What the cache needs to produce a fix.
41
+ *
42
+ * The GPS reader is INJECTED rather than imported: the store's reset clears
43
+ * this cache, and importing expo-location here would drag a native module into
44
+ * the store's graph for the sake of resetting three variables.
45
+ */
46
+ export interface FixSource {
47
+ takeFix(): Promise<FixOutcome>;
48
+ addressReverse(
49
+ lat: number,
50
+ lng: number,
51
+ ): Promise<{ line: string | null; parts?: AddressParts | null }>;
52
+ }
53
+
54
+ let resolved: CurrentFix | null = null;
55
+ let inflight: Promise<CurrentFix | null> | null = null;
56
+ let failed = false;
57
+ let lastFailure: LocationFailure | null = null;
58
+
59
+ /** Why the last attempt failed, so the message can say something true. */
60
+ export function currentFixFailure(): LocationFailure | null {
61
+ return lastFailure;
62
+ }
63
+
64
+ /**
65
+ * The message for a failed read. A refused permission, a phone that cannot
66
+ * place itself, and a fix that took too long are three different problems
67
+ * with three different remedies, and one line telling everybody to "allow
68
+ * location access" sent people to a permission that was already granted.
69
+ * Placing the pin by hand always works, so every line says so. Mirrors the
70
+ * web SDK's locationFailureMessage and Flutter's.
71
+ */
72
+ export function locationFailureMessage(reason: LocationFailure | null): string {
73
+ switch (reason) {
74
+ case 'denied':
75
+ return "Location access is blocked for this app. Allow it in your phone's Settings, then try again, or place the pin yourself.";
76
+ case 'unavailable':
77
+ return 'Your phone could not work out where it is right now. Check that location is switched on, then try again, or place the pin yourself.';
78
+ case 'timeout':
79
+ return 'Finding your location took too long. Try again, or place the pin yourself.';
80
+ default:
81
+ return 'This device cannot share its location. Place the pin yourself.';
82
+ }
83
+ }
84
+
85
+ /** The fix, when one has already resolved for this verification. */
86
+ export function currentFix(): CurrentFix | null {
87
+ return resolved;
88
+ }
89
+
90
+ /** Is a fix attempt still running? */
91
+ export function locating(): boolean {
92
+ return inflight !== null && resolved === null;
93
+ }
94
+
95
+ /**
96
+ * Start (or JOIN) the one location attempt. Safe to call from every address
97
+ * step's mount: the OS permission prompt fires at most once, and a second
98
+ * caller waits on the first attempt rather than starting its own.
99
+ *
100
+ * Resolves null on a denied or failed read — callers fall back to the manual
101
+ * pin, which is what every address failure degrades to.
102
+ */
103
+ export function prefetchCurrentFix(
104
+ source: FixSource,
105
+ opts?: { retry?: boolean },
106
+ ): Promise<CurrentFix | null> {
107
+ if (resolved) return Promise.resolve(resolved);
108
+ // A failed attempt (denied prompt, no fix) is never retried AUTOMATICALLY.
109
+ // Every step mount calls this, so re-arming on failure left the location row
110
+ // spinning forever and re-fired the OS prompt on each screen. An explicit
111
+ // TAP passes `retry` and gets a fresh attempt, since the person may have
112
+ // granted permission in the meantime.
113
+ if (failed && !inflight && !opts?.retry) return Promise.resolve(null);
114
+ if (!inflight) {
115
+ inflight = (async () => {
116
+ // No builder-preview branch: the preview is a web-only surface, and a
117
+ // canned fix here would be a code path no mobile caller can reach.
118
+ const outcome = await source.takeFix().catch((): FixOutcome => ({ failure: 'unsupported' }));
119
+ if (!('fix' in outcome)) {
120
+ inflight = null;
121
+ failed = true;
122
+ lastFailure = outcome.failure;
123
+ return null;
124
+ }
125
+ const fix = outcome.fix;
126
+ lastFailure = null;
127
+ let label: string | null = null;
128
+ let parts: AddressParts | null = null;
129
+ try {
130
+ const r = await source.addressReverse(fix.lat, fix.lng);
131
+ label = r.line ?? null;
132
+ parts = r.parts ?? null;
133
+ } catch {
134
+ /* the coordinates alone are still a fix */
135
+ }
136
+ resolved = { lat: fix.lat, lng: fix.lng, accuracy: fix.accuracy, label, parts };
137
+ failed = false;
138
+ return resolved;
139
+ })();
140
+ }
141
+ return inflight;
142
+ }
143
+
144
+ /** Forget the fix so the next verification takes its own. Called from the
145
+ * store's reset, beside the step log. */
146
+ export function resetCurrentFix(): void {
147
+ resolved = null;
148
+ inflight = null;
149
+ failed = false;
150
+ lastFailure = null;
151
+ }
152
+
153
+ // ── The pin step's automatic first locate ───────────────────────────────────
154
+ //
155
+ // Most people are verifying from home, so the map should land on them rather
156
+ // than a country-centre default. It fires ONCE: a dismissed or denied prompt
157
+ // must not re-fire every time the person passes back through the pin step, and
158
+ // the explicit control is there for retries.
159
+
160
+ let autoLocateAttempted = false;
161
+
162
+ /** True the FIRST time it is called for a verification, false after. */
163
+ export function claimAutoLocate(): boolean {
164
+ if (autoLocateAttempted) return false;
165
+ autoLocateAttempted = true;
166
+ return true;
167
+ }
168
+
169
+ export function resetAutoLocate(): void {
170
+ autoLocateAttempted = false;
171
+ }