@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.
- package/README.md +151 -5
- package/android/build.gradle +35 -3
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
- package/app.plugin.js +89 -8
- package/ios/HybridMyazaFaceDetector.swift +16 -3
- 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 +14 -2
- package/src/MyazaBiometricAuth.tsx +119 -0
- package/src/MyazaKYC.tsx +12 -0
- package/src/components/CountryField.tsx +8 -0
- package/src/components/CountryRegionPicker.tsx +184 -0
- package/src/components/DashedBorder.tsx +55 -0
- package/src/components/DialCodePicker.tsx +53 -67
- package/src/components/DialCodeRow.tsx +102 -0
- package/src/components/FramedMapPicker.tsx +122 -0
- package/src/components/GeoBadge.tsx +34 -0
- package/src/components/Icon.tsx +2 -176
- package/src/components/KycFlow.tsx +60 -32
- package/src/components/LineSkeleton.tsx +100 -0
- package/src/components/MapChrome.tsx +67 -0
- package/src/components/MapPinMarker.tsx +54 -0
- package/src/components/MapPinPicker.tsx +179 -0
- package/src/components/MilestoneTrack.tsx +155 -0
- package/src/components/MultiIdProgress.tsx +107 -0
- package/src/components/MyazaInput.tsx +39 -4
- package/src/components/MyazaSelect.tsx +2 -1
- package/src/components/OptionRow.tsx +22 -3
- package/src/components/OwnershipSlider.tsx +102 -0
- package/src/components/PhoneNumberInput.tsx +45 -4
- package/src/components/PoweredBy.tsx +5 -5
- package/src/components/PresenceBlocks.tsx +153 -0
- package/src/components/StaggerIn.tsx +63 -0
- package/src/components/StepView.tsx +43 -0
- package/src/components/StickyActions.tsx +56 -0
- package/src/components/dialCodeRows.ts +64 -0
- package/src/components/glass/FloatingSheet.tsx +73 -21
- package/src/components/glass/GlassSheet.tsx +14 -2
- package/src/components/icon-map.ts +176 -0
- package/src/components/stepHeaderMeta.tsx +45 -5
- package/src/config/addressCollection.ts +125 -0
- package/src/config/biometricOptions.ts +100 -0
- package/src/config/business.ts +26 -7
- package/src/config/businessDetailsValidity.ts +52 -0
- package/src/config/businessPrefill.ts +72 -0
- package/src/config/businessSteps.ts +20 -3
- package/src/config/consentStep.ts +19 -0
- 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/proofOfAddress.ts +54 -11
- package/src/config/regions.ts +24 -0
- package/src/config/registrationHint.ts +1 -1
- package/src/config/stepOrder.ts +100 -6
- package/src/config/theme.ts +8 -2
- package/src/config/uploadLimits.ts +35 -0
- package/src/config/website.ts +36 -0
- package/src/config/workflowMerge.ts +16 -5
- 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/index.ts +54 -1
- package/src/lib/address-current-location.ts +171 -0
- package/src/lib/address-field-modes.ts +119 -0
- package/src/lib/address-flow.ts +172 -0
- package/src/lib/address-helpers.ts +48 -0
- package/src/lib/address-line.ts +76 -0
- package/src/lib/address-pin-move.ts +97 -0
- package/src/lib/address-step-recovery.ts +63 -0
- package/src/lib/authed-image.ts +78 -0
- package/src/lib/biometric-auth.ts +52 -0
- package/src/lib/biometric-copy.ts +51 -0
- package/src/lib/captureRing.ts +83 -0
- package/src/lib/contact-recovery.ts +44 -0
- package/src/lib/country-adoption.ts +89 -0
- package/src/lib/inferred-country.ts +65 -0
- package/src/lib/livenessLayout.ts +49 -0
- package/src/lib/map-frame.ts +159 -0
- package/src/lib/map-tiles.ts +143 -0
- package/src/lib/multi-id.ts +210 -0
- package/src/lib/poa-country-gate.ts +27 -0
- package/src/lib/resubmit.ts +130 -0
- package/src/lib/result-copy.ts +116 -0
- package/src/lib/result-wait.ts +53 -0
- package/src/lib/review-map-surface.ts +26 -0
- package/src/lib/scope.ts +31 -0
- package/src/lib/screen-corners.ts +50 -0
- package/src/lib/selfie-upload-wait.ts +79 -0
- package/src/lib/step-log.ts +24 -3
- package/src/lib/street-view-fov.ts +42 -0
- package/src/lib/use-multi-id-plan.ts +29 -0
- package/src/lib/webview-available.ts +47 -0
- package/src/liveness/useLiveness.ts +6 -1
- package/src/liveness/useModelReady.ts +65 -0
- package/src/liveness/visionCameraFaceDetector.ts +35 -0
- package/src/presence/background-math.ts +100 -0
- package/src/presence/background-store.ts +82 -0
- package/src/presence/background.ts +165 -0
- package/src/presence/foreground-service.ts +193 -0
- package/src/presence/fs.ts +51 -0
- package/src/presence/geofence.ts +41 -0
- package/src/presence/math.ts +44 -0
- package/src/presence/post.ts +40 -0
- package/src/presence/report.ts +87 -0
- package/src/presence/sampler.ts +110 -0
- package/src/presence/status.ts +92 -0
- package/src/presence/store.ts +94 -0
- package/src/presence/tier.ts +36 -0
- package/src/presence/watch-wait.ts +81 -0
- package/src/screens/AddressCountryControl.tsx +115 -0
- package/src/screens/ApplicantRoleStep.tsx +9 -5
- package/src/screens/BusinessCheckPanel.tsx +89 -0
- package/src/screens/BusinessDetailsFields.tsx +112 -0
- package/src/screens/BusinessDetailsStep.tsx +133 -145
- package/src/screens/BusinessDocumentSlot.tsx +5 -5
- package/src/screens/BusinessDocumentsStep.tsx +5 -56
- 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 +91 -51
- package/src/screens/ConsentStep.tsx +37 -17
- package/src/screens/ContactActions.tsx +54 -0
- package/src/screens/ContactDestinationField.tsx +5 -0
- package/src/screens/ContactEntryPanel.tsx +75 -0
- package/src/screens/ContactVerificationStep.tsx +43 -42
- package/src/screens/CountrySelectStep.tsx +13 -147
- 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/LivenessAvatar.tsx +15 -3
- package/src/screens/LivenessStep.tsx +172 -52
- package/src/screens/NfcStep.tsx +14 -0
- package/src/screens/PoaDocumentTypeList.tsx +66 -0
- package/src/screens/ProofOfAddressParts.tsx +151 -0
- package/src/screens/ProofOfAddressStep.tsx +58 -96
- package/src/screens/SubmittedBadge.tsx +25 -0
- package/src/screens/SubmittedError.tsx +64 -0
- package/src/screens/SubmittedResult.tsx +117 -0
- package/src/screens/SubmittedStep.tsx +82 -119
- package/src/screens/SubmittedSuccess.tsx +127 -0
- package/src/screens/SubmittedWaiting.tsx +45 -0
- package/src/screens/address/AddressEntranceStep.tsx +161 -0
- package/src/screens/address/AddressIntroGate.tsx +148 -0
- package/src/screens/address/AddressMapStub.tsx +50 -0
- package/src/screens/address/AddressPinStep.tsx +178 -0
- package/src/screens/address/AddressReviewStep.tsx +162 -0
- package/src/screens/address/AddressSandboxTabs.tsx +158 -0
- package/src/screens/address/AddressSearchField.tsx +115 -0
- package/src/screens/address/AddressSearchStep.tsx +94 -0
- package/src/screens/address/CurrentLocationRow.tsx +135 -0
- package/src/screens/address/DetailsSheet.tsx +175 -0
- package/src/screens/address/DetailsSheetFields.tsx +184 -0
- package/src/screens/address/EntranceDropzone.tsx +178 -0
- package/src/screens/address/EntranceFraming.tsx +66 -0
- package/src/screens/address/EntrancePills.tsx +60 -0
- package/src/screens/address/FramedStreetView.tsx +155 -0
- package/src/screens/address/IntroDisclosures.tsx +210 -0
- package/src/screens/address/LabelDecisionRow.tsx +113 -0
- package/src/screens/address/PinSummaryRow.tsx +105 -0
- package/src/screens/address/ReviewAddressBand.tsx +111 -0
- package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
- package/src/screens/address/ReviewMapPicture.tsx +106 -0
- package/src/screens/address/SearchResults.tsx +105 -0
- package/src/screens/address/SearchScreen.tsx +176 -0
- package/src/screens/address/SkipForNow.tsx +43 -0
- package/src/screens/address/StreetViewChrome.tsx +81 -0
- package/src/screens/address/detail-values.ts +22 -0
- package/src/screens/address/fix-source.ts +27 -0
- package/src/screens/address/index.ts +8 -0
- package/src/screens/address/meta.ts +44 -0
- package/src/screens/address/use-address-flow.ts +200 -0
- package/src/screens/address/use-label-pin.ts +80 -0
- package/src/screens/address/use-pin-actions.ts +192 -0
- package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
- package/src/screens/consent/model.ts +79 -10
- package/src/screens/liveness/CaptureRing.tsx +91 -0
- package/src/screens/liveness/LivenessCamera.tsx +63 -0
- package/src/screens/liveness/LivenessHandover.tsx +43 -0
- package/src/screens/liveness/LivenessOutcome.tsx +4 -3
- package/src/screens/liveness/SelfiePreview.tsx +13 -4
- package/src/screens/liveness/ShutterFlash.tsx +25 -0
- package/src/screens/liveness/index.ts +4 -0
- package/src/screens/liveness/useSelfieUpload.ts +23 -3
- package/src/screens/nfc/NfcReadProgress.tsx +1 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
- package/src/screens/useAddressPhotoAttach.ts +77 -0
- package/src/screens/useAwaitingPeople.ts +90 -0
- package/src/screens/useBusinessDocumentAttach.ts +93 -0
- package/src/screens/usePoaAttach.ts +12 -15
- package/src/services/api-biometric.ts +37 -0
- package/src/services/api-types-biometric.ts +41 -0
- package/src/services/api-types.ts +251 -3
- package/src/services/api-verify-types.ts +90 -1
- package/src/services/api.ts +237 -3
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/errors.ts +10 -2
- package/src/services/location.ts +234 -0
- package/src/services/uploadErrors.ts +30 -0
- package/src/services/workflowGate.ts +8 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
- package/src/store/address.ts +97 -0
- package/src/store/businessCheck.ts +113 -0
- package/src/store/derive.ts +44 -4
- package/src/store/kycStore.ts +352 -16
- package/src/store/serverConfig.ts +20 -0
- package/src/store/session.ts +227 -0
- package/src/store/state.ts +289 -2
- package/src/store/submit.ts +80 -13
- package/src/store/submitApplicant.ts +5 -11
- package/src/types/business.ts +23 -2
- package/src/types/config.ts +70 -2
- package/src/types/verification.ts +13 -1
- package/src/types/workflow.ts +112 -1
- 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?: {
|
|
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
|
/**
|
package/src/services/api.ts
CHANGED
|
@@ -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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
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.
|
|
18
|
+
export const SDK_VERSION = '2.6.0';
|
|
19
19
|
|
|
20
20
|
export interface ReactNativeDeviceMetadata {
|
|
21
21
|
sdkType: 'react-native';
|
package/src/services/errors.ts
CHANGED
|
@@ -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
|
-
|
|
78
|
-
|
|
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
|
+
}
|