@myazahq/kyc-sdk-react-native 2.3.0 → 2.5.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.
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +75 -0
- package/ios/HybridMyazaFaceDetector.swift +8 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
- package/package.json +1 -2
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/BrandBar.tsx +137 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +25 -24
- package/src/components/DocumentReviewSide.tsx +34 -14
- package/src/components/Icon.tsx +14 -0
- package/src/components/KycSheet.tsx +39 -140
- package/src/components/MediaSourceSheet.tsx +7 -37
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaDateField.tsx +6 -20
- package/src/components/MyazaInput.tsx +23 -4
- package/src/components/MyazaSelect.tsx +17 -40
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +37 -4
- package/src/components/SandboxBanner.tsx +92 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepHeader.tsx +15 -2
- package/src/components/StepView.tsx +26 -0
- package/src/components/glass/ChromeGlass.tsx +65 -0
- package/src/components/glass/FloatingSheet.tsx +242 -0
- package/src/components/glass/GlassSheet.tsx +50 -0
- package/src/components/glass/GlassSurface.tsx +31 -3
- package/src/components/stepHeaderMeta.tsx +3 -1
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
- package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
- package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
- package/src/config/business.ts +6 -6
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +14 -3
- package/src/config/keyPeople.ts +111 -7
- package/src/config/keyPeopleCorporate.ts +19 -0
- package/src/config/keyPeopleOwnerTypes.ts +12 -0
- package/src/config/keyPeopleOwners.ts +26 -0
- package/src/config/keyPeoplePrefill.ts +138 -0
- package/src/config/keyPeopleSectionDefs.ts +65 -0
- package/src/config/keyPeopleSections.ts +167 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +24 -4
- package/src/config/theme.ts +8 -2
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +5 -0
- package/src/emrtd/activeAuth.ts +100 -0
- package/src/emrtd/extras.ts +76 -0
- package/src/emrtd/files.ts +5 -0
- package/src/emrtd/index.ts +1 -0
- package/src/emrtd/read.ts +8 -1
- package/src/emrtd/session.ts +41 -0
- package/src/emrtd/stages.ts +11 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +109 -0
- package/src/screens/BusinessDetailsStep.tsx +131 -145
- package/src/screens/BusinessDocumentSlot.tsx +2 -3
- package/src/screens/BusinessDocumentsStep.tsx +1 -1
- package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
- package/src/screens/BusinessPickedCard.tsx +86 -0
- package/src/screens/BusinessPickedSection.tsx +51 -0
- package/src/screens/BusinessRegistryPickers.tsx +67 -0
- package/src/screens/BusinessSandboxToggle.tsx +114 -0
- package/src/screens/BusinessSearch.tsx +199 -0
- package/src/screens/BusinessSearchResults.tsx +115 -0
- package/src/screens/CompanyInfoFields.tsx +86 -51
- package/src/screens/ConsentStep.tsx +31 -0
- package/src/screens/ContactDestinationField.tsx +1 -0
- package/src/screens/ContactVerificationStep.tsx +34 -3
- package/src/screens/IdTypeStep.tsx +12 -5
- package/src/screens/KeepLinksSheet.tsx +88 -0
- package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
- package/src/screens/KeyPeopleAwaitList.tsx +126 -0
- package/src/screens/KeyPeoplePending.tsx +154 -0
- package/src/screens/KeyPeopleSection.tsx +162 -0
- package/src/screens/KeyPeopleSectionsList.tsx +103 -0
- package/src/screens/KeyPeopleUboExemption.tsx +72 -0
- package/src/screens/KeyPersonCard.tsx +36 -4
- package/src/screens/KeyPersonForm.tsx +195 -36
- package/src/screens/KeyPersonKindToggle.tsx +68 -0
- package/src/screens/KeyPersonOwners.tsx +109 -0
- package/src/screens/KeyPersonRoleChips.tsx +68 -0
- package/src/screens/KeyPersonSheet.tsx +92 -108
- package/src/screens/LivenessStep.tsx +18 -0
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/ProofOfAddressStep.tsx +6 -3
- package/src/screens/SubmittedStep.tsx +66 -10
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/services/api-types.ts +158 -2
- package/src/services/api-verify-types.ts +53 -1
- package/src/services/api.ts +104 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/workflowGate.ts +1 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +15 -0
- package/src/store/kycStore.ts +241 -14
- package/src/store/serverConfig.ts +2 -0
- package/src/store/session.ts +208 -0
- package/src/store/state.ts +155 -2
- package/src/store/submit.ts +63 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +29 -3
- package/src/types/verification.ts +1 -1
- package/src/types/workflow.ts +15 -0
- package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
package/src/emrtd/session.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { fromBase64, toBase64 } from './bytes';
|
|
2
2
|
import { buildBacChallenge, completeBac } from './bac';
|
|
3
3
|
import { primitivesFromNative, type EmrtdPrimitives, type MrzKeyFields } from './crypto';
|
|
4
|
+
import { readExtraGroups, type ExtraGroupReads } from './extras';
|
|
5
|
+
import { readActiveAuth, type AaChallenge, type ActiveAuthRead } from './activeAuth';
|
|
4
6
|
import { EF, readFile, type Transceive } from './files';
|
|
5
7
|
import { readOptionalFile } from './optionalRead';
|
|
6
8
|
import { PaceError, PREFER_PACE, tryPace, type PaceOutcome } from './open';
|
|
@@ -45,6 +47,22 @@ export interface EmrtdReadResult {
|
|
|
45
47
|
sod?: string;
|
|
46
48
|
/** DG2 — the portrait. Best-effort; the largest file and the likeliest to drop. */
|
|
47
49
|
dg2?: string;
|
|
50
|
+
/** DG7 — the displayed signature image. Best-effort, COM-gated (see extras.ts). */
|
|
51
|
+
dg7?: string;
|
|
52
|
+
/** DG11 — additional personal details. Best-effort; many issuers omit it. */
|
|
53
|
+
dg11?: string;
|
|
54
|
+
/** DG12 — additional document details. Best-effort; many issuers omit it. */
|
|
55
|
+
dg12?: string;
|
|
56
|
+
/** DG15 — the chip's Active-Authentication public key. Absent on the many
|
|
57
|
+
* chips that support no AA at all. */
|
|
58
|
+
dg15?: string;
|
|
59
|
+
/** The chip's signature over the SERVER's challenge — the anti-clone proof.
|
|
60
|
+
* Verified server-side only; a client that checked its own chip could be
|
|
61
|
+
* patched to say yes. */
|
|
62
|
+
aaSignature?: string;
|
|
63
|
+
/** Which server-issued challenge that signature answers — echoed back so the
|
|
64
|
+
* server can spend it. Not read off the chip; carried through with it. */
|
|
65
|
+
aaChallengeId?: string;
|
|
48
66
|
/** How the chip was unlocked. Reported to the server on the submission. */
|
|
49
67
|
chipAuth: 'bac' | 'pace';
|
|
50
68
|
/**
|
|
@@ -141,6 +159,12 @@ export async function readChip(
|
|
|
141
159
|
* enough that silence reads as failure and prompts them to lift the document.
|
|
142
160
|
*/
|
|
143
161
|
onStage?: (stage: NfcReadStage) => void,
|
|
162
|
+
/**
|
|
163
|
+
* The Active-Authentication challenge the SERVER issued, if it could. Ours to
|
|
164
|
+
* carry, never ours to choose: a client-chosen nonce makes a captured
|
|
165
|
+
* signature replayable, which is exactly the clone AA exists to catch.
|
|
166
|
+
*/
|
|
167
|
+
aaChallenge?: AaChallenge,
|
|
144
168
|
): Promise<EmrtdReadResult> {
|
|
145
169
|
const p = primitivesFromNative(native);
|
|
146
170
|
|
|
@@ -178,9 +202,20 @@ export async function readChip(
|
|
|
178
202
|
const sod = await readOptionalFile(sm, transceive, EF.SOD, 'EF.SOD');
|
|
179
203
|
|
|
180
204
|
let dg2: Uint8Array | null = null;
|
|
205
|
+
let extras: ExtraGroupReads = { dg7: null, dg11: null, dg12: null };
|
|
206
|
+
let activeAuth: ActiveAuthRead = {};
|
|
181
207
|
if (sod) {
|
|
182
208
|
onStage?.('readingPhoto');
|
|
183
209
|
dg2 = await readOptionalFile(sm, transceive, EF.DG2, 'DG2');
|
|
210
|
+
// The optional detail groups come LAST: by now everything that matters is
|
|
211
|
+
// banked, so a drop here costs only nice-to-have context. Gated on the SOD
|
|
212
|
+
// like DG2 — the server authenticates each group against it.
|
|
213
|
+
onStage?.('readingDetails');
|
|
214
|
+
extras = await readExtraGroups(sm, transceive);
|
|
215
|
+
// The anti-clone challenge LAST: it is the only step that asks the chip to
|
|
216
|
+
// compute rather than read, so it is the slowest per byte and the one worth
|
|
217
|
+
// losing if the document leaves contact.
|
|
218
|
+
activeAuth = await readActiveAuth(sm, transceive, aaChallenge);
|
|
184
219
|
}
|
|
185
220
|
onStage?.('done');
|
|
186
221
|
|
|
@@ -188,6 +223,12 @@ export async function readChip(
|
|
|
188
223
|
dg1: toBase64(dg1),
|
|
189
224
|
...(sod ? { sod: toBase64(sod) } : {}),
|
|
190
225
|
...(dg2 ? { dg2: toBase64(dg2) } : {}),
|
|
226
|
+
...(extras.dg7 ? { dg7: toBase64(extras.dg7) } : {}),
|
|
227
|
+
...(extras.dg11 ? { dg11: toBase64(extras.dg11) } : {}),
|
|
228
|
+
...(extras.dg12 ? { dg12: toBase64(extras.dg12) } : {}),
|
|
229
|
+
...(activeAuth.dg15 ? { dg15: activeAuth.dg15 } : {}),
|
|
230
|
+
...(activeAuth.signature ? { aaSignature: activeAuth.signature } : {}),
|
|
231
|
+
...(activeAuth.signature && aaChallenge ? { aaChallengeId: aaChallenge.id } : {}),
|
|
191
232
|
chipAuth: access.chipAuth,
|
|
192
233
|
paceOutcome: access.outcome,
|
|
193
234
|
...(access.detail ? { paceDetail: access.detail } : {}),
|
package/src/emrtd/stages.ts
CHANGED
|
@@ -23,6 +23,8 @@ export type NfcReadStage =
|
|
|
23
23
|
| 'readingSecurity'
|
|
24
24
|
/** Reading DG2 — the chip's portrait. */
|
|
25
25
|
| 'readingPhoto'
|
|
26
|
+
/** Reading the optional detail groups (DG7/DG11/DG12) — small and quick. */
|
|
27
|
+
| 'readingDetails'
|
|
26
28
|
/** Everything that could be read has been. */
|
|
27
29
|
| 'done';
|
|
28
30
|
|
|
@@ -33,6 +35,7 @@ export const NFC_STAGE_ORDER: NfcReadStage[] = [
|
|
|
33
35
|
'readingData',
|
|
34
36
|
'readingSecurity',
|
|
35
37
|
'readingPhoto',
|
|
38
|
+
'readingDetails',
|
|
36
39
|
'done',
|
|
37
40
|
];
|
|
38
41
|
|
|
@@ -49,6 +52,8 @@ export function nfcStageLabel(stage: NfcReadStage): string {
|
|
|
49
52
|
return 'Reading security data';
|
|
50
53
|
case 'readingPhoto':
|
|
51
54
|
return 'Reading photo';
|
|
55
|
+
case 'readingDetails':
|
|
56
|
+
return 'Reading extra details';
|
|
52
57
|
case 'done':
|
|
53
58
|
return 'Chip read complete';
|
|
54
59
|
}
|
|
@@ -73,6 +78,8 @@ export function nfcStageDetail(stage: NfcReadStage): string {
|
|
|
73
78
|
return 'Downloading the chip’s digital signature. This is the largest part and takes the longest — keep holding.';
|
|
74
79
|
case 'readingPhoto':
|
|
75
80
|
return 'Copying the photo stored on the chip.';
|
|
81
|
+
case 'readingDetails':
|
|
82
|
+
return 'Copying the optional details stored on the chip. These are small and quick.';
|
|
76
83
|
case 'done':
|
|
77
84
|
return 'Everything was read successfully.';
|
|
78
85
|
}
|
|
@@ -98,6 +105,8 @@ export function nfcSheetMessage(stage: NfcReadStage): string {
|
|
|
98
105
|
return 'Verifying security data — keep holding…';
|
|
99
106
|
case 'readingPhoto':
|
|
100
107
|
return 'Reading your photo — keep holding…';
|
|
108
|
+
case 'readingDetails':
|
|
109
|
+
return 'Almost done — keep holding…';
|
|
101
110
|
case 'done':
|
|
102
111
|
return 'Chip read complete';
|
|
103
112
|
}
|
|
@@ -116,6 +125,8 @@ export function nfcStageProgress(stage: NfcReadStage): number {
|
|
|
116
125
|
return 0.6;
|
|
117
126
|
case 'readingPhoto':
|
|
118
127
|
return 0.85;
|
|
128
|
+
case 'readingDetails':
|
|
129
|
+
return 0.95;
|
|
119
130
|
case 'done':
|
|
120
131
|
return 1;
|
|
121
132
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Recovery from a submit refused over contact proofs. Mirrors the web SDK's
|
|
2
|
+
// steps/contact-recovery.ts — keep the two in lockstep.
|
|
3
|
+
//
|
|
4
|
+
// Contact proof tokens are SINGLE-USE and expire ~30 minutes after the OTP is
|
|
5
|
+
// checked, but they ride session progress and are restored on resume. A
|
|
6
|
+
// resumed attempt therefore carries a proof the server will (rightly)
|
|
7
|
+
// validate-and-drop at submit, while the contact step still shows "verified"
|
|
8
|
+
// — and a plain retry resubmits the same dead token forever.
|
|
9
|
+
//
|
|
10
|
+
// The 422 is recoverable in-flow: clear the stale proofs, walk the person
|
|
11
|
+
// back to the contact step (their data is untouched), and once re-verified
|
|
12
|
+
// the step routes straight back to 'submitted', which auto-submits with the
|
|
13
|
+
// fresh token.
|
|
14
|
+
import { KYCApiError } from '../services/api';
|
|
15
|
+
import type { KYCStep } from '../types/config';
|
|
16
|
+
|
|
17
|
+
export type ContactChannel = 'email' | 'phone';
|
|
18
|
+
|
|
19
|
+
/** The channels a 422 `contact_verification_required` names as missing. */
|
|
20
|
+
export function expiredContactChannels(err: unknown): ContactChannel[] {
|
|
21
|
+
if (!(err instanceof KYCApiError) || err.code !== 'contact_verification_required') return [];
|
|
22
|
+
const missing = Array.isArray(err.body?.missing) ? (err.body.missing as unknown[]) : [];
|
|
23
|
+
return missing.filter((c): c is ContactChannel => c === 'email' || c === 'phone');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function contactStepFor(channel: ContactChannel): KYCStep {
|
|
27
|
+
return channel === 'email' ? 'email-verification' : 'phone-verification';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Where the step routes once this channel is verified. Normally the ordinary
|
|
32
|
+
* forward step; in recovery it returns to 'submitted' (which auto-submits
|
|
33
|
+
* with the fresh proof) via any OTHER still-refused channel first — the
|
|
34
|
+
* person never re-walks steps they already completed.
|
|
35
|
+
*/
|
|
36
|
+
export function stepAfterContactVerified(opts: {
|
|
37
|
+
recovery: boolean;
|
|
38
|
+
expired: ContactChannel[];
|
|
39
|
+
channel: ContactChannel;
|
|
40
|
+
}): KYCStep | null {
|
|
41
|
+
if (!opts.recovery) return null;
|
|
42
|
+
const remaining = opts.expired.find((c) => c !== opts.channel);
|
|
43
|
+
return remaining ? contactStepFor(remaining) : 'submitted';
|
|
44
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Multi-ID flows on React Native. A PORT of the web SDK's lib/multi-id.ts,
|
|
2
|
+
// kept byte-for-byte in its logic: the server validates the pick sequence the
|
|
3
|
+
// client produced, so a client that computes options differently produces
|
|
4
|
+
// submissions the server rejects. If you change a rule here, change it in
|
|
5
|
+
// kyc-sdk-react/src/lib/multi-id.ts, kyc-core/src/lib/multi-id.ts and the
|
|
6
|
+
// dashboard's required-ids-model.ts in the same commit.
|
|
7
|
+
//
|
|
8
|
+
// Multi-ID flows: a workflow's `multiId` block asks for SEVERAL ID checks in
|
|
9
|
+
// one run — the applicant picks each slot's ID from what the admin allowed for
|
|
10
|
+
// THEIR country (a picked ID disappears from later slots), ONE selfie covers
|
|
11
|
+
// the whole run, and everything submits as ONE verification the server judges
|
|
12
|
+
// by the pass policy.
|
|
13
|
+
//
|
|
14
|
+
// Multi-region works: the country-select step comes first as usual, and the
|
|
15
|
+
// run then walks THAT country's slots (`countries[].multiIdSlots`). The pure
|
|
16
|
+
// helpers here MIRROR the server's lib/multi-id.ts exactly — the safe-options
|
|
17
|
+
// rule is what keeps an applicant from ever being offered a pick that would
|
|
18
|
+
// strand a later slot, and the server validates the same sequence.
|
|
19
|
+
|
|
20
|
+
export interface MultiIdConfig {
|
|
21
|
+
count: number;
|
|
22
|
+
minPassed: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MultiIdCountry {
|
|
26
|
+
country: string;
|
|
27
|
+
idTypes?: string[];
|
|
28
|
+
/** This country's per-verification ID allowlists. */
|
|
29
|
+
multiIdSlots?: Array<{ idTypes?: string[] }>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface MultiIdSourceConfig {
|
|
33
|
+
multiId?: MultiIdConfig;
|
|
34
|
+
subjectType?: string;
|
|
35
|
+
/** The EFFECTIVE country (post country-select) — whose slots the run walks. */
|
|
36
|
+
country?: string;
|
|
37
|
+
countries?: MultiIdCountry[];
|
|
38
|
+
idTypes?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The workflow's multiId POLICY — null for ordinary flows and every KYB flow. */
|
|
42
|
+
export function multiIdConfigFrom(config: MultiIdSourceConfig): MultiIdConfig | null {
|
|
43
|
+
if (config.subjectType === 'business') return null;
|
|
44
|
+
const block = config.multiId;
|
|
45
|
+
if (!block || typeof block.count !== 'number') return null;
|
|
46
|
+
const count = Math.trunc(block.count);
|
|
47
|
+
// REJECT an out-of-range count rather than clamping it. The server returns
|
|
48
|
+
// null here, so clamping made the client walk 3 checks for a config the
|
|
49
|
+
// server does not consider multi-ID at all — and clamping `minPassed`
|
|
50
|
+
// against the RAW count could ask for 9 of 3 to pass, which nothing can
|
|
51
|
+
// satisfy. Same rule, both sides, or the client builds submissions the
|
|
52
|
+
// server rejects.
|
|
53
|
+
if (!Number.isInteger(count) || count < 2 || count > 3) return null;
|
|
54
|
+
return {
|
|
55
|
+
count,
|
|
56
|
+
minPassed:
|
|
57
|
+
typeof block.minPassed === 'number' && Number.isInteger(block.minPassed)
|
|
58
|
+
? Math.min(Math.max(block.minPassed, 1), count)
|
|
59
|
+
: count,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The country entry the run walks: the EFFECTIVE country's, else the legacy
|
|
64
|
+
* single-country shape. */
|
|
65
|
+
export function multiIdCountryEntry(config: MultiIdSourceConfig): MultiIdCountry | null {
|
|
66
|
+
const country = config.country;
|
|
67
|
+
if (config.countries && config.countries.length > 0) {
|
|
68
|
+
return config.countries.find((c) => c.country === country) ?? null;
|
|
69
|
+
}
|
|
70
|
+
return country ? { country, idTypes: config.idTypes } : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The ID types the picked country offers: its pinned list, else everything the
|
|
75
|
+
* server granted there.
|
|
76
|
+
*/
|
|
77
|
+
export function multiIdOfferedTypes(
|
|
78
|
+
config: MultiIdSourceConfig,
|
|
79
|
+
serverIdTypes: Array<{ country: string; idType: string }>,
|
|
80
|
+
): string[] {
|
|
81
|
+
const entry = multiIdCountryEntry(config);
|
|
82
|
+
if (!entry) return [];
|
|
83
|
+
if (entry.idTypes && entry.idTypes.length > 0) return entry.idTypes;
|
|
84
|
+
return serverIdTypes.filter((row) => row.country === entry.country).map((row) => row.idType);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Per-slot option lists (a pinned slot keeps its list; others offer everything). */
|
|
88
|
+
export function multiIdSlotOptions(
|
|
89
|
+
count: number,
|
|
90
|
+
slots: Array<{ idTypes?: string[] }> | undefined,
|
|
91
|
+
offered: string[],
|
|
92
|
+
): string[][] {
|
|
93
|
+
const offeredSet = new Set(offered);
|
|
94
|
+
return Array.from({ length: count }, (_, i) => {
|
|
95
|
+
const pinned = slots?.[i]?.idTypes;
|
|
96
|
+
const base = pinned && pinned.length > 0 ? pinned : offered;
|
|
97
|
+
return base.filter((t) => offeredSet.has(t));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The first reachable dead-end across pick orders, or null. */
|
|
102
|
+
export function multiIdFirstDeadEnd(
|
|
103
|
+
slotOptions: string[][],
|
|
104
|
+
): { picks: string[]; slotIndex: number } | null {
|
|
105
|
+
const walk = (index: number, picked: string[]): { picks: string[]; slotIndex: number } | null => {
|
|
106
|
+
if (index >= slotOptions.length) return null;
|
|
107
|
+
const available = slotOptions[index]!.filter((t) => !picked.includes(t));
|
|
108
|
+
if (available.length === 0) return { picks: picked, slotIndex: index };
|
|
109
|
+
for (const pick of available) {
|
|
110
|
+
const deadEnd = walk(index + 1, [...picked, pick]);
|
|
111
|
+
if (deadEnd) return deadEnd;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
return walk(0, []);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The picks a slot may SAFELY offer: unused AND non-stranding. */
|
|
119
|
+
export function multiIdSafeOptions(
|
|
120
|
+
slotOptions: string[][],
|
|
121
|
+
slotIndex: number,
|
|
122
|
+
picked: string[],
|
|
123
|
+
): string[] {
|
|
124
|
+
const remaining = slotOptions.slice(slotIndex + 1);
|
|
125
|
+
return (slotOptions[slotIndex] ?? [])
|
|
126
|
+
.filter((t) => !picked.includes(t))
|
|
127
|
+
.filter(
|
|
128
|
+
(t) =>
|
|
129
|
+
multiIdFirstDeadEnd(
|
|
130
|
+
remaining.map((opts) => opts.filter((o) => o !== t && !picked.includes(o))),
|
|
131
|
+
) === null,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface MultiIdPlan {
|
|
136
|
+
count: number;
|
|
137
|
+
minPassed: number;
|
|
138
|
+
/** Which slot is being walked (clamped; equals count once every slot committed). */
|
|
139
|
+
index: number;
|
|
140
|
+
/** The current slot is the final one. */
|
|
141
|
+
last: boolean;
|
|
142
|
+
/** ID types committed so far, in order. */
|
|
143
|
+
picked: string[];
|
|
144
|
+
/** What the CURRENT slot's picker may offer. */
|
|
145
|
+
safeOptions: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The active plan, or null for ordinary flows. */
|
|
149
|
+
export function multiIdPlan(
|
|
150
|
+
config: MultiIdSourceConfig,
|
|
151
|
+
state: { multiIdSlotIndex: number; multiIdSlots: Array<{ idType: string }> },
|
|
152
|
+
serverIdTypes: Array<{ country: string; idType: string }>,
|
|
153
|
+
): MultiIdPlan | null {
|
|
154
|
+
const cfg = multiIdConfigFrom(config);
|
|
155
|
+
if (!cfg) return null;
|
|
156
|
+
const offered = multiIdOfferedTypes(config, serverIdTypes);
|
|
157
|
+
const options = multiIdSlotOptions(cfg.count, multiIdCountryEntry(config)?.multiIdSlots, offered);
|
|
158
|
+
const picked = state.multiIdSlots.map((s) => s.idType);
|
|
159
|
+
const index = Math.min(Math.max(state.multiIdSlotIndex, 0), cfg.count);
|
|
160
|
+
return {
|
|
161
|
+
count: cfg.count,
|
|
162
|
+
minPassed: cfg.minPassed,
|
|
163
|
+
index,
|
|
164
|
+
last: index >= cfg.count - 1,
|
|
165
|
+
picked,
|
|
166
|
+
safeOptions: index < cfg.count ? multiIdSafeOptions(options, index, picked) : [],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Which evidence step one slot's ID opens on. */
|
|
171
|
+
export function multiIdEvidenceStep(
|
|
172
|
+
def: { requiresDocumentCapture: boolean } | null | undefined,
|
|
173
|
+
): 'id-input' | 'document-capture' {
|
|
174
|
+
return def && def.requiresDocumentCapture === false ? 'id-input' : 'document-capture';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The committed slots as the WIRE sees them — an explicit whitelist, because
|
|
179
|
+
* the slots also carry local preview images for the back journey and those must
|
|
180
|
+
* never reach the submission or the session-progress blob. Whitelist, not
|
|
181
|
+
* spread: a field added to the slot later cannot leak by default.
|
|
182
|
+
*/
|
|
183
|
+
export function multiIdWireSlots(
|
|
184
|
+
slots: Array<{
|
|
185
|
+
idType: string;
|
|
186
|
+
idNumber?: string;
|
|
187
|
+
documentFront?: string;
|
|
188
|
+
documentBack?: string;
|
|
189
|
+
documentFrontVideo?: string;
|
|
190
|
+
documentBackVideo?: string;
|
|
191
|
+
}>,
|
|
192
|
+
): Array<{
|
|
193
|
+
idType: string;
|
|
194
|
+
idNumber?: string;
|
|
195
|
+
documentFront?: string;
|
|
196
|
+
documentBack?: string;
|
|
197
|
+
documentFrontVideo?: string;
|
|
198
|
+
documentBackVideo?: string;
|
|
199
|
+
}> {
|
|
200
|
+
// The chip is attached by the caller after this whitelist (it is a payload,
|
|
201
|
+
// not a mediaId), so it is absent here and not stripped.
|
|
202
|
+
return slots.map((s) => ({
|
|
203
|
+
idType: s.idType,
|
|
204
|
+
...(s.idNumber ? { idNumber: s.idNumber } : {}),
|
|
205
|
+
...(s.documentFront ? { documentFront: s.documentFront } : {}),
|
|
206
|
+
...(s.documentBack ? { documentBack: s.documentBack } : {}),
|
|
207
|
+
...(s.documentFrontVideo ? { documentFrontVideo: s.documentFrontVideo } : {}),
|
|
208
|
+
...(s.documentBackVideo ? { documentBackVideo: s.documentBackVideo } : {}),
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Narrowing a flow to the steps a reviewer asked the applicant to redo.
|
|
2
|
+
//
|
|
3
|
+
// A reviewer looked at a finished verification and sent it back — usually
|
|
4
|
+
// because one thing was unreadable, not because everything was wrong. Making
|
|
5
|
+
// somebody retake a passport photo is reasonable; making them redo consent, the
|
|
6
|
+
// ID picker, liveness and a questionnaire to fix that photo is how you lose them.
|
|
7
|
+
//
|
|
8
|
+
// The instruction rides the session's config snapshot (`config.resubmit`), which
|
|
9
|
+
// is how every other per-session flow instruction travels. An SDK that predates
|
|
10
|
+
// this simply does not read the key and runs the whole flow, which is the safe
|
|
11
|
+
// degradation: asking for too much is recoverable, silently skipping a step the
|
|
12
|
+
// reviewer wanted is not.
|
|
13
|
+
import type { KYCStep } from '../types/config';
|
|
14
|
+
|
|
15
|
+
export interface ResubmitConfig {
|
|
16
|
+
/** Steps to redo, in flow order. Never empty — the server omits the key instead. */
|
|
17
|
+
steps: string[];
|
|
18
|
+
/** Reviewer's note to the applicant. */
|
|
19
|
+
message?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Steps that are always kept, whatever the reviewer ticked.
|
|
24
|
+
*
|
|
25
|
+
* `consent` because a flow with no first screen is disorienting, and it is where
|
|
26
|
+
* the redo is explained. `submitted` because a flow has to end somewhere.
|
|
27
|
+
* Neither is something the applicant is being asked to redo — they are the
|
|
28
|
+
* frame around what is.
|
|
29
|
+
*/
|
|
30
|
+
const ALWAYS: KYCStep[] = ['consent', 'submitted'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One ID's evidence — a FAMILY, not alternatives a reviewer picks between.
|
|
34
|
+
*
|
|
35
|
+
* Which member a flow contains depends on the ID type: a number-only ID has
|
|
36
|
+
* `id-input`, a document ID has `document-capture` (and maybe `nfc`). At the
|
|
37
|
+
* moment the order is built nobody has chosen one yet, so the flow is shaped by
|
|
38
|
+
* a default. A plan naming `id-input` against an order still shaped for
|
|
39
|
+
* `document-capture` therefore matched NOTHING, fell through to the safety net
|
|
40
|
+
* below, and silently ran the entire flow — which is exactly the bug a reviewer
|
|
41
|
+
* sees as "I asked for the ID and it made them do everything again".
|
|
42
|
+
*
|
|
43
|
+
* Asking for one member asks for whichever this flow turns out to have.
|
|
44
|
+
*/
|
|
45
|
+
const EVIDENCE: KYCStep[] = ['id-input', 'document-capture', 'nfc'];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Steps a narrowed flow keeps regardless, because without them it cannot
|
|
49
|
+
* produce a submission at all.
|
|
50
|
+
*
|
|
51
|
+
* A resubmission is a NEW verification on a FRESH session: nothing is carried
|
|
52
|
+
* forward from the one being redone, so the applicant must still say which ID
|
|
53
|
+
* this is and supply it. `POST /verify` requires an `idType`, and a number-only
|
|
54
|
+
* ID requires the number with it.
|
|
55
|
+
*
|
|
56
|
+
* So narrowing removes the things arranged AROUND the identity — liveness,
|
|
57
|
+
* proof of address, the questionnaire, contact checks — and never the identity
|
|
58
|
+
* itself. The alternative is a two-screen flow that collects a photo and then
|
|
59
|
+
* fails to submit, which is worse for the applicant than being asked for one
|
|
60
|
+
* extra screen.
|
|
61
|
+
*/
|
|
62
|
+
const INDIVIDUAL_REQUIRED: KYCStep[] = ['id-type', ...EVIDENCE];
|
|
63
|
+
const BUSINESS_REQUIRED: KYCStep[] = ['business-details'];
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Narrow a full step order to the redo, preserving flow order.
|
|
67
|
+
*
|
|
68
|
+
* Order comes from `order`, never from the reviewer's list: they ticked
|
|
69
|
+
* checkboxes, and walking somebody through liveness before document capture
|
|
70
|
+
* because that is the order the boxes were ticked in would be nonsense.
|
|
71
|
+
*
|
|
72
|
+
* Returns the ORIGINAL order untouched when the instruction is absent, empty, or
|
|
73
|
+
* matches nothing we know. That last case matters: a server that learns a new
|
|
74
|
+
* step name before this SDK does must not produce a two-screen flow that
|
|
75
|
+
* collects nothing.
|
|
76
|
+
*/
|
|
77
|
+
export function applyResubmitSteps(
|
|
78
|
+
order: KYCStep[],
|
|
79
|
+
resubmit: ResubmitConfig | undefined | null,
|
|
80
|
+
): KYCStep[] {
|
|
81
|
+
const asked = resubmit?.steps;
|
|
82
|
+
if (!asked?.length) return order;
|
|
83
|
+
|
|
84
|
+
const wantsEvidence = asked.some((step) => EVIDENCE.includes(step as KYCStep));
|
|
85
|
+
|
|
86
|
+
// Does the instruction name anything this flow actually has? If not, it came
|
|
87
|
+
// from a server that knows a step name this SDK does not — and narrowing on it
|
|
88
|
+
// would quietly drop whatever was really asked for. Checked BEFORE the
|
|
89
|
+
// required steps are added, or those alone would make every unknown plan look
|
|
90
|
+
// recognised and turn the safety net below into dead code.
|
|
91
|
+
const recognised = wantsEvidence || asked.some((step) => order.includes(step as KYCStep));
|
|
92
|
+
if (!recognised) return order;
|
|
93
|
+
|
|
94
|
+
const wanted = new Set<string>(asked);
|
|
95
|
+
if (wantsEvidence) for (const step of EVIDENCE) wanted.add(step);
|
|
96
|
+
for (const step of order.includes('business-details') ? BUSINESS_REQUIRED : INDIVIDUAL_REQUIRED) {
|
|
97
|
+
wanted.add(step);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const narrowed = order.filter((step) => wanted.has(step) || ALWAYS.includes(step));
|
|
101
|
+
|
|
102
|
+
// Nothing but the frame survived, so the instruction named steps this flow
|
|
103
|
+
// does not contain. Run everything rather than nothing.
|
|
104
|
+
const collects = narrowed.some((step) => !ALWAYS.includes(step));
|
|
105
|
+
return collects ? narrowed : order;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether this mount is a targeted redo, for the wording on screen. */
|
|
109
|
+
export function isResubmission(resubmit: ResubmitConfig | undefined | null): boolean {
|
|
110
|
+
return Boolean(resubmit?.steps?.length);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The reviewer's note to the applicant, when this mount is a targeted redo.
|
|
115
|
+
*
|
|
116
|
+
* The dashboard's send-back dialog asks a reviewer to explain the problem — its
|
|
117
|
+
* placeholder is literally "Your document photo was too dark to read, please
|
|
118
|
+
* retake it in good light." The server stamps that note onto the session and
|
|
119
|
+
* every SDK parses it into config, and until now NONE of them displayed it. So
|
|
120
|
+
* a send-back reached the applicant as a flow that had silently lost most of
|
|
121
|
+
* its steps, with nothing saying why they were back or what to do differently.
|
|
122
|
+
*
|
|
123
|
+
* Returns null when there is nothing to show, so a caller can render this
|
|
124
|
+
* unconditionally.
|
|
125
|
+
*/
|
|
126
|
+
export function resubmitNote(resubmit: ResubmitConfig | undefined | null): string | null {
|
|
127
|
+
if (!isResubmission(resubmit)) return null;
|
|
128
|
+
const note = resubmit?.message?.trim();
|
|
129
|
+
return note && note.length > 0 ? note : null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Dimensions, Platform } from 'react-native';
|
|
2
|
+
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
3
|
+
|
|
4
|
+
// Apple's grammar for nested corners is CONCENTRIC: an inset card's radius is
|
|
5
|
+
// the DISPLAY's corner radius minus the gap between card and screen edge —
|
|
6
|
+
// that is why the system share sheet's curve runs parallel to the phone's.
|
|
7
|
+
// No public API exposes the display radius, so iOS resolves it from the
|
|
8
|
+
// model's logical screen size (the per-device values Apple ships, from
|
|
9
|
+
// UIScreen's private _displayCornerRadius, in points). Unknown hardware falls
|
|
10
|
+
// back on whether the display is rounded at all: home-indicator devices are,
|
|
11
|
+
// square-corner devices are not.
|
|
12
|
+
|
|
13
|
+
/** `short x long` logical points → display corner radius in points. */
|
|
14
|
+
const IOS_RADII: Record<string, number> = {
|
|
15
|
+
// X / XS / 11 Pro are 39 on the same canvas as the 12–13 mini's 44; the
|
|
16
|
+
// minis are the ones still in pockets, so the shared key takes their value.
|
|
17
|
+
'375x812': 44,
|
|
18
|
+
'414x896': 41.5, // XR / 11 / XS Max / 11 Pro Max
|
|
19
|
+
'390x844': 47.33, // 12 / 12 Pro / 13 / 13 Pro / 14
|
|
20
|
+
'428x926': 53.33, // 12–13 Pro Max / 14 Plus
|
|
21
|
+
'393x852': 55, // 14 Pro / 15 / 15 Pro / 16 / 16e
|
|
22
|
+
'430x932': 55, // 14 Pro Max / 15 Plus / 15 Pro Max / 16 Plus
|
|
23
|
+
'402x874': 62, // 16 Pro
|
|
24
|
+
'440x956': 62, // 16 Pro Max
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The display's own corner radius, in points/dp. 0 means a square-cornered
|
|
29
|
+
* screen (or one we cannot judge), and the caller picks its flat-world look.
|
|
30
|
+
*/
|
|
31
|
+
export function displayCornerRadius(): number {
|
|
32
|
+
const { width, height } = Dimensions.get('screen');
|
|
33
|
+
const key = `${Math.round(Math.min(width, height))}x${Math.round(Math.max(width, height))}`;
|
|
34
|
+
const bottomInset = initialWindowMetrics?.insets?.bottom ?? 0;
|
|
35
|
+
|
|
36
|
+
if (Platform.OS === 'ios') {
|
|
37
|
+
const known = IOS_RADII[key];
|
|
38
|
+
if (known != null) return known;
|
|
39
|
+
// Unlisted model: a home indicator means a rounded display; 47 is the
|
|
40
|
+
// middle of the modern range.
|
|
41
|
+
return bottomInset > 0 ? 47 : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Android has no cheap signal (the RoundedCorner API needs native code).
|
|
45
|
+
// A small bottom inset is gesture navigation — overwhelmingly the modern,
|
|
46
|
+
// rounded-display phones — while ~48dp is the opaque 3-button bar, which
|
|
47
|
+
// says nothing. Modest on purpose: an under-estimate still looks composed,
|
|
48
|
+
// an over-estimate looks like a mistake.
|
|
49
|
+
return bottomInset > 0 && bottomInset <= 32 ? 32 : 0;
|
|
50
|
+
}
|
package/src/lib/step-log.ts
CHANGED
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
export interface StepLogEntry {
|
|
12
12
|
step: string;
|
|
13
13
|
at: string;
|
|
14
|
+
/**
|
|
15
|
+
* Which multi-ID check the applicant was on (1-based). Absent on ordinary
|
|
16
|
+
* runs and on the first check.
|
|
17
|
+
*
|
|
18
|
+
* The SERVER cannot tell a slot advance from a back-press: a multi-ID run
|
|
19
|
+
* legitimately returns to the ID picker for its next ID, and by step name
|
|
20
|
+
* that is identical to pressing Back. Only the client knows a slot was
|
|
21
|
+
* committed, so only the client can say.
|
|
22
|
+
*/
|
|
23
|
+
slot?: number;
|
|
24
|
+
/** The ID type selected AT THAT MOMENT — a slot's final pick is not what the
|
|
25
|
+
* applicant was doing earlier in it (pick one ID, go back, settle on
|
|
26
|
+
* another, and the earlier step would be named for an ID not yet chosen). */
|
|
27
|
+
idType?: string;
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
export interface StepLog {
|
|
@@ -29,10 +43,17 @@ export function resetStepLog(): void {
|
|
|
29
43
|
|
|
30
44
|
/** Records a step visit. Consecutive duplicates are collapsed; back-and-forth
|
|
31
45
|
* navigation is kept — repeat visits are honest journey data. */
|
|
32
|
-
export function recordStep(step: string): void {
|
|
46
|
+
export function recordStep(step: string, slot?: number, idType?: string): void {
|
|
33
47
|
if (entries.length >= MAX_ENTRIES) return;
|
|
34
|
-
|
|
35
|
-
|
|
48
|
+
const last = entries[entries.length - 1];
|
|
49
|
+
// A revisit on a NEW check, or on a DIFFERENT ID, is a different visit.
|
|
50
|
+
if (last?.step === step && last.slot === slot && last.idType === idType) return;
|
|
51
|
+
entries.push({
|
|
52
|
+
step,
|
|
53
|
+
at: new Date().toISOString(),
|
|
54
|
+
...(slot ? { slot } : {}),
|
|
55
|
+
...(idType ? { idType } : {}),
|
|
56
|
+
});
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
/** Snapshot attached to the verify submission. Null when nothing was recorded
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { multiIdPlan, type MultiIdPlan } from './multi-id';
|
|
4
|
+
import { useEffectiveCountry, useKyc, useKycConfig } from '../components/runtime';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The active multi-ID plan for the current state, or null on an ordinary run.
|
|
8
|
+
*
|
|
9
|
+
* One hook so the picker and the position strip cannot disagree about which
|
|
10
|
+
* check the applicant is on or what it may offer — the server validates the
|
|
11
|
+
* pick sequence, so two answers here means submissions it rejects.
|
|
12
|
+
*/
|
|
13
|
+
export function useMultiIdPlan(): MultiIdPlan | null {
|
|
14
|
+
const config = useKycConfig();
|
|
15
|
+
const country = useEffectiveCountry();
|
|
16
|
+
const multiIdSlotIndex = useKyc((s) => s.multiIdSlotIndex);
|
|
17
|
+
const multiIdSlots = useKyc((s) => s.multiIdSlots);
|
|
18
|
+
const serverConfig = useKyc((s) => s.serverConfig);
|
|
19
|
+
|
|
20
|
+
return useMemo(
|
|
21
|
+
() =>
|
|
22
|
+
multiIdPlan(
|
|
23
|
+
{ ...config, country },
|
|
24
|
+
{ multiIdSlotIndex, multiIdSlots },
|
|
25
|
+
serverConfig.status === 'ready' ? serverConfig.idTypes : [],
|
|
26
|
+
),
|
|
27
|
+
[config, country, multiIdSlotIndex, multiIdSlots, serverConfig],
|
|
28
|
+
);
|
|
29
|
+
}
|