Feat/didit webapp (#1882)
* feat: replace Sumsub with Didit JS SDK in webview-app - Add @didit-protocol/sdk-web, remove @sumsub/websdk - Create diditProvider.ts with session creation + SDK launch - Update ProviderLaunchScreen to use Didit embedded mode - Delete sumsubProvider.ts and sumsub-websdk.d.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add Socket.IO attestation flow to webview KYC After Didit JS SDK completes, connect Socket.IO to the TEE, subscribe by sessionId, and wait for signed KYC data (attestation). Emit ack_success for session cleanup. Attach attestation to the provider result before navigating to the result screen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: update TEE URL to kyc.self.xyz, update SDK test app README for Didit Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only route KYC (Other IDs) to Didit provider, others to Coming Soon Passport, ID card, and Aadhaar require NFC/MRZ scanning which isn't available in the WebView. Only "Other IDs" goes through the Didit JS SDK flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Didit SDK full-width rendering and KYC routing - Wire onNotListedPress to launch Didit for "View other supported IDs" - Remove verificationId gate from ProviderLaunchScreen - Switch to modal mode with CSS overrides for full-screen on mobile - Force .shadow-card to 100% width/height in WebView context Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add camera permissions and file upload to Android WebView Add WebChromeClient to AndroidWebViewHost: - onPermissionRequest: auto-grants camera for Didit SDK - onShowFileChooser: opens system file picker for document upload - SelfVerificationActivity handles file chooser result callback Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: gitignore Gradle build artifacts for native-shell-android Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add runtime camera permission and CAMERA manifest declaration - Add CAMERA permission to sdk-test-app AndroidManifest.xml - Request runtime camera permission in onPermissionRequest before granting - Handle permission result in SelfVerificationActivity - Store pending PermissionRequest for async grant/deny after user response Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix ios camera * fix: address CodeRabbit review findings - Replace ngrok URL with kyc.self.xyz in Android and iOS test apps - Fix file chooser hang when context is not an Activity - Move NSCameraUsageDescription to project.yml (survives xcodegen regen) - Delete manual Info.plist that would be overwritten Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace ngrok URL with kyc.self.xyz in diditProvider and diditAttestation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: explicitly disable Didit SDK debug logging Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: webview lint * fix: validate origin and handle audio permission in WebView permission grants - Deny permission requests from untrusted origins - Deny instead of grant when context is not an Activity - Handle RECORD_AUDIO alongside CAMERA for liveness checks - Add RECORD_AUDIO to AndroidManifest.xml Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: seshanthS <seshanth@protonmail.com>
2
.gitignore
vendored
@@ -55,3 +55,5 @@ contracts/broadcast/
|
||||
# Keep RN test app config files tracked (global gitignore may ignore *.config.*)
|
||||
!packages/rn-sdk-test-app/metro.config.cjs
|
||||
!packages/rn-sdk-test-app/react-native.config.cjs
|
||||
packages/native-shell-android/.gradle/
|
||||
packages/native-shell-android/build/
|
||||
|
||||
|
After Width: | Height: | Size: 538 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 454 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 220 KiB |
@@ -0,0 +1 @@
|
||||
@font-face{font-family:Advercase-Regular;src:url(../fonts/Advercase-Regular.otf) format("opentype");font-display:swap}@font-face{font-family:DINOT-Bold;src:url(../fonts/DINOT-Bold.otf) format("opentype");font-weight:700;font-display:swap}@font-face{font-family:DINOT-Medium;src:url(../fonts/DINOT-Medium.otf) format("opentype");font-weight:500;font-display:swap}@font-face{font-family:IBMPlexMono-Regular;src:url(../fonts/IBMPlexMono-Regular.otf) format("opentype");font-display:swap}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}html,body,#root{height:100%;width:100%;overflow:hidden}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;-webkit-user-select:none;user-select:none}@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 560 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 381 KiB |
BIN
packages/native-shell-android/src/main/assets/self-wallet/fonts/DINOT-Medium.otf
Executable file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Self</title>
|
||||
<script type="module" crossorigin src="./assets/index-JxbVYeGE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-VdzGwUkN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,15 +2,25 @@
|
||||
|
||||
package xyz.self.sdk.webview
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.PermissionRequest
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.ValueCallback
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import xyz.self.sdk.bridge.MessageRouter
|
||||
|
||||
@@ -20,6 +30,8 @@ class AndroidWebViewHost(
|
||||
private val isDebugMode: Boolean = false,
|
||||
) {
|
||||
private lateinit var webView: WebView
|
||||
var fileUploadCallback: ValueCallback<Array<Uri>>? = null
|
||||
var pendingPermissionRequest: PermissionRequest? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun createWebView(queryParams: String): WebView {
|
||||
@@ -91,6 +103,71 @@ class AndroidWebViewHost(
|
||||
}
|
||||
}
|
||||
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onPermissionRequest(request: PermissionRequest?) {
|
||||
request ?: return
|
||||
|
||||
// Only allow permissions from trusted origins
|
||||
val origin = request.origin?.toString() ?: ""
|
||||
val isTrusted = origin.startsWith("https://appassets.androidplatform.net") ||
|
||||
origin.startsWith("https://verify.didit.me") ||
|
||||
(isDebugMode && origin.startsWith("http://127.0.0.1"))
|
||||
if (!isTrusted) {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
|
||||
val activity = context as? Activity ?: run {
|
||||
request.deny()
|
||||
return
|
||||
}
|
||||
|
||||
// Collect required Android permissions
|
||||
val neededPermissions = mutableListOf<String>()
|
||||
if (request.resources.contains(PermissionRequest.RESOURCE_VIDEO_CAPTURE)) {
|
||||
neededPermissions.add(Manifest.permission.CAMERA)
|
||||
}
|
||||
if (request.resources.contains(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) {
|
||||
neededPermissions.add(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
|
||||
// Check if any runtime permissions are missing
|
||||
val missingPermissions = neededPermissions.filter {
|
||||
ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (missingPermissions.isNotEmpty()) {
|
||||
pendingPermissionRequest = request
|
||||
ActivityCompat.requestPermissions(activity, missingPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST_CODE)
|
||||
return
|
||||
}
|
||||
|
||||
request.grant(request.resources)
|
||||
}
|
||||
|
||||
override fun onShowFileChooser(
|
||||
webView: WebView?,
|
||||
filePathCallback: ValueCallback<Array<Uri>>?,
|
||||
fileChooserParams: FileChooserParams?,
|
||||
): Boolean {
|
||||
fileUploadCallback?.onReceiveValue(null)
|
||||
fileUploadCallback = filePathCallback
|
||||
|
||||
val intent = fileChooserParams?.createIntent() ?: return false
|
||||
val activity = context as? Activity ?: run {
|
||||
fileUploadCallback = null
|
||||
return false
|
||||
}
|
||||
try {
|
||||
activity.startActivityForResult(intent, FILE_CHOOSER_REQUEST_CODE)
|
||||
} catch (e: Exception) {
|
||||
fileUploadCallback = null
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
|
||||
|
||||
if (isDebugMode) {
|
||||
@@ -121,4 +198,9 @@ class AndroidWebViewHost(
|
||||
router.onMessageReceived(json)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILE_CHOOSER_REQUEST_CODE = 1001
|
||||
const val CAMERA_PERMISSION_REQUEST_CODE = 1002
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
package xyz.self.sdk.webview
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebChromeClient
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import xyz.self.sdk.bridge.MessageRouter
|
||||
import xyz.self.sdk.handlers.CryptoHandler
|
||||
@@ -43,6 +45,35 @@ class SelfVerificationActivity : AppCompatActivity() {
|
||||
setContentView(webView)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == AndroidWebViewHost.CAMERA_PERMISSION_REQUEST_CODE) {
|
||||
val pending = webViewHost.pendingPermissionRequest
|
||||
if (pending != null) {
|
||||
if (grantResults.isNotEmpty() && grantResults[0] == android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
pending.grant(pending.resources)
|
||||
} else {
|
||||
pending.deny()
|
||||
}
|
||||
webViewHost.pendingPermissionRequest = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use Activity Result API")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
if (requestCode == AndroidWebViewHost.FILE_CHOOSER_REQUEST_CODE) {
|
||||
val results = if (resultCode == RESULT_OK && data != null) {
|
||||
WebChromeClient.FileChooserParams.parseResult(resultCode, data)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
webViewHost.fileUploadCallback?.onReceiveValue(results)
|
||||
webViewHost.fileUploadCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (::webViewHost.isInitialized) {
|
||||
webViewHost.destroy()
|
||||
|
||||
@@ -10,13 +10,18 @@ final class SecureStorageHandler: BridgeHandler {
|
||||
private let service = "xyz.self.sdk"
|
||||
|
||||
func handle(method: String, params: [String: Any]?) async throws -> Any? {
|
||||
let result: Any?
|
||||
|
||||
switch method {
|
||||
case "get":
|
||||
guard let key = params?["key"] as? String else {
|
||||
throw BridgeHandlerError.missingParam("key")
|
||||
}
|
||||
let value = get(key: key)
|
||||
return ["value": value as Any]
|
||||
if let value = get(key: key) {
|
||||
result = ["value": value]
|
||||
} else {
|
||||
result = ["value": NSNull()]
|
||||
}
|
||||
|
||||
case "set":
|
||||
guard let key = params?["key"] as? String else {
|
||||
@@ -26,18 +31,19 @@ final class SecureStorageHandler: BridgeHandler {
|
||||
throw BridgeHandlerError.missingParam("value")
|
||||
}
|
||||
try set(key: key, value: value)
|
||||
return nil
|
||||
result = nil
|
||||
|
||||
case "remove":
|
||||
guard let key = params?["key"] as? String else {
|
||||
throw BridgeHandlerError.missingParam("key")
|
||||
}
|
||||
remove(key: key)
|
||||
return nil
|
||||
result = nil
|
||||
|
||||
default:
|
||||
throw BridgeHandlerError.unknownMethod(method)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func get(key: String) -> String? {
|
||||
|
||||
@@ -21,6 +21,8 @@ final class SelfWebViewHost: NSObject {
|
||||
contentController.add(WeakScriptMessageProxy(handler: self), name: "SelfNativeIOS")
|
||||
config.userContentController = contentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.allowsInlineMediaPlayback = true
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.scrollView.bounces = false
|
||||
@@ -44,7 +46,7 @@ final class SelfWebViewHost: NSObject {
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
} else {
|
||||
guard let bundlePath = Bundle.main.path(forResource: "self-sdk-web", ofType: nil) else {
|
||||
guard let bundlePath = Bundle.module.path(forResource: "self-sdk-web", ofType: nil) else {
|
||||
return
|
||||
}
|
||||
let fileURL = URL(fileURLWithPath: "\(bundlePath)/index.html")
|
||||
|
||||
@@ -5,10 +5,10 @@ Minimal test apps for exercising the Self SDK native shells (Android + iOS) end-
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Host test app → Native shell (keychain/crypto/lifecycle) → WebView (webview-app bundle) → Sumsub KYC
|
||||
Host test app → Native shell (keychain/crypto/lifecycle) → WebView (webview-app bundle) → Didit KYC
|
||||
```
|
||||
|
||||
The test app launches the native shell, which hosts a WebView running the bundled `webview-app`. The WebView handles the full verification flow (Sumsub KYC → Self proof pipeline) and returns a terminal result to the test app via the bridge.
|
||||
The test app launches the native shell, which hosts a WebView running the bundled `webview-app`. The WebView handles the full verification flow (Didit KYC via JS SDK → Socket.IO attestation → Self proof pipeline) and returns a terminal result to the test app via the bridge.
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -100,11 +100,11 @@ The test app has three config fields:
|
||||
|
||||
| Field | Default | Description |
|
||||
|-------|---------|-------------|
|
||||
| TEE URL | `https://tee.staging.self.xyz` | Trusted execution environment endpoint |
|
||||
| TEE URL | `https://kyc.self.xyz` | Didit TEE backend endpoint for session creation and signed data delivery |
|
||||
| Verification ID | `test-verification-123` | Session correlation ID (use a real one for end-to-end testing) |
|
||||
| User ID | `test-user-456` | User correlation key |
|
||||
|
||||
For end-to-end testing with Sumsub, you need real `verificationId` and `teeUrl` values from the Self backend.
|
||||
For end-to-end testing, you need a real `teeUrl` pointing to a running didit-tee instance with valid Didit API credentials.
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -124,6 +124,17 @@ For end-to-end testing with Sumsub, you need real `verificationId` and `teeUrl`
|
||||
4. On completion, the `SelfSdkCallback` protocol methods are invoked
|
||||
5. The view controller is dismissed
|
||||
|
||||
## KYC Flow
|
||||
|
||||
The WebView app uses the Didit JS SDK (`@didit-protocol/sdk-web`) for identity verification:
|
||||
|
||||
1. WebView calls `POST /session` on the TEE to create a Didit session
|
||||
2. Didit JS SDK launches in embedded mode (iframe) for document capture + liveness
|
||||
3. After SDK completes, WebView connects Socket.IO to the TEE
|
||||
4. TEE delivers signed KYC data (EdDSA signature + 295-byte applicant info)
|
||||
5. WebView emits `ack_success` to trigger session deletion
|
||||
6. Document is stored and proving machine generates the ZK proof
|
||||
|
||||
## Full Build Pipeline
|
||||
|
||||
To build everything from scratch:
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
|
||||
@@ -68,7 +68,7 @@ fun TestAppScreen(
|
||||
resultText: String,
|
||||
onLaunch: (SelfSdkConfig) -> Unit
|
||||
) {
|
||||
var teeUrl by remember { mutableStateOf("https://tee.staging.self.xyz") }
|
||||
var teeUrl by remember { mutableStateOf("https://kyc.self.xyz") }
|
||||
var verificationId by remember { mutableStateOf("test-verification-123") }
|
||||
var userId by remember { mutableStateOf("test-user-456") }
|
||||
var debugMode by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||
networkTimeout=600000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class VerificationCallback: SelfSdkCallback {
|
||||
}
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var teeUrl = "https://tee.staging.self.xyz"
|
||||
@State private var teeUrl = "https://kyc.self.xyz"
|
||||
@State private var verificationId = "test-verification-123"
|
||||
@State private var userId = "test-user-456"
|
||||
@State private var debugMode = false
|
||||
|
||||
@@ -19,6 +19,19 @@ targets:
|
||||
- SelfTestApp
|
||||
dependencies:
|
||||
- package: SelfNativeShell
|
||||
info:
|
||||
properties:
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsLocalNetworking: true
|
||||
NSCameraUsageDescription: "Camera access is required for identity document capture and liveness verification."
|
||||
settings:
|
||||
SWIFT_VERSION: "5.9"
|
||||
DEVELOPMENT_TEAM: ""
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation: YES
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
MARKETING_VERSION: "1.0"
|
||||
CURRENT_PROJECT_VERSION: 1
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@didit-protocol/sdk-web": "^0.1.8",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@selfxyz/euclid": "1.3.0",
|
||||
"@selfxyz/euclid-core": "1.3.0",
|
||||
"@selfxyz/mobile-sdk-alpha": "workspace:^",
|
||||
"@selfxyz/webview-bridge": "workspace:^",
|
||||
"@sumsub/websdk": "^2.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"elliptic": "^6.5.4",
|
||||
"lottie-react": "^2.4.0",
|
||||
|
||||
@@ -65,13 +65,30 @@ export const IDSelectionScreen: React.FC = () => {
|
||||
countryCode,
|
||||
});
|
||||
|
||||
navigate('/onboarding/provider', {
|
||||
state: { countryCode, documentType: idType.id },
|
||||
});
|
||||
if (idType.id === 'kyc') {
|
||||
navigate('/onboarding/provider', {
|
||||
state: { countryCode, documentType: idType.id },
|
||||
});
|
||||
} else {
|
||||
navigate('/coming-soon', {
|
||||
state: { countryCode, documentType: idType.id },
|
||||
});
|
||||
}
|
||||
},
|
||||
[navigate, analytics, haptic, countryCode],
|
||||
);
|
||||
|
||||
// const onNotListed = useCallback(() => {
|
||||
// haptic.trigger('selection');
|
||||
// analytics.trackEvent('document_type_selected', {
|
||||
// documentType: 'kyc',
|
||||
// countryCode,
|
||||
// });
|
||||
// navigate('/onboarding/provider', {
|
||||
// state: { countryCode, documentType: 'kyc' },
|
||||
// });
|
||||
// }, [navigate, analytics, haptic, countryCode]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MockRegistrationFailureButton />
|
||||
|
||||
@@ -3,68 +3,168 @@
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button, colors, Description, spacing, Title } from '@selfxyz/euclid';
|
||||
|
||||
import { MockRegistrationFailureButton } from '../../components/MockRegistrationFailureButton';
|
||||
import { useSelfClient } from '../../providers/SelfClientProvider';
|
||||
import { useVerificationRequest } from '../../providers/VerificationRequestProvider';
|
||||
import type { MockOnboardingNavigationState } from '../../utils/mockOnboardingFlow';
|
||||
import {
|
||||
createMockProviderResult,
|
||||
getMockOutcomeFromSearch,
|
||||
getMockOutcomeSearch,
|
||||
} from '../../utils/mockOnboardingFlow';
|
||||
import type { KycProviderResult } from '../../types/kycProvider';
|
||||
import { waitForAttestation } from '../../utils/diditAttestation';
|
||||
import { createDiditSession, launchDiditWebSdk } from '../../utils/diditProvider';
|
||||
|
||||
const CONTAINER_ID = 'didit-sdk-container';
|
||||
|
||||
type Phase = 'loading' | 'active' | 'waiting' | 'error';
|
||||
|
||||
export const ProviderLaunchScreen: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { analytics, haptic, lifecycle } = useSelfClient();
|
||||
const { verificationId } = useVerificationRequest();
|
||||
const mockOutcome = getMockOutcomeFromSearch(location.search);
|
||||
const { verificationId: ctxVerificationId } = useVerificationRequest();
|
||||
|
||||
const { countryCode, documentType } = (location.state as MockOnboardingNavigationState | null) ?? {};
|
||||
const { countryCode = '', documentType = '' } =
|
||||
(location.state as {
|
||||
countryCode?: string;
|
||||
documentType?: string;
|
||||
}) || {};
|
||||
|
||||
const verificationId = ctxVerificationId ?? `didit-${Date.now()}`;
|
||||
|
||||
const [phase, setPhase] = useState<Phase>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const destroyRef = useRef<(() => void) | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
const handleComplete = useCallback(
|
||||
async (result: KycProviderResult) => {
|
||||
if (!mountedRef.current) return;
|
||||
analytics.trackEvent('provider_complete', {
|
||||
status: result.status,
|
||||
provider: result.provider,
|
||||
});
|
||||
|
||||
if ((result.status === 'success' || result.status === 'partial') && sessionIdRef.current) {
|
||||
setPhase('waiting');
|
||||
const attestationResult = await waitForAttestation(sessionIdRef.current);
|
||||
|
||||
if (!mountedRef.current) return;
|
||||
|
||||
if (attestationResult.status === 'success' && attestationResult.attestation) {
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: {
|
||||
providerResult: {
|
||||
...result,
|
||||
status: 'success' as const,
|
||||
attestation: attestationResult.attestation,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: {
|
||||
providerResult: {
|
||||
...result,
|
||||
status: 'error' as const,
|
||||
error: {
|
||||
code: 'provider_missing_attestation' as const,
|
||||
message: attestationResult.error ?? 'Failed to get signed verification data',
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: { providerResult: result },
|
||||
});
|
||||
},
|
||||
[analytics, navigate],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(result: KycProviderResult) => {
|
||||
if (!mountedRef.current) return;
|
||||
analytics.trackEvent('provider_error', {
|
||||
status: result.status,
|
||||
errorCode: result.error?.code,
|
||||
provider: result.provider,
|
||||
});
|
||||
navigate('/onboarding/provider-result', {
|
||||
state: { providerResult: result },
|
||||
});
|
||||
},
|
||||
[analytics, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
analytics.trackEvent('provider_launch_started', {
|
||||
countryCode,
|
||||
documentType,
|
||||
mockOutcome,
|
||||
});
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const providerResult = createMockProviderResult({
|
||||
outcome: mockOutcome,
|
||||
verificationId,
|
||||
});
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
analytics.trackEvent('provider_mock_completed', {
|
||||
status: providerResult.status,
|
||||
mockOutcome,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const session = await createDiditSession(controller.signal);
|
||||
if (cancelled) return;
|
||||
|
||||
navigate(`/onboarding/provider-result${getMockOutcomeSearch(mockOutcome)}`, {
|
||||
replace: true,
|
||||
state: {
|
||||
providerResult,
|
||||
countryCode,
|
||||
documentType,
|
||||
retryMockOutcome: mockOutcome,
|
||||
},
|
||||
});
|
||||
}, 700);
|
||||
sessionIdRef.current = session.sessionId;
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [analytics, countryCode, documentType, mockOutcome, navigate, verificationId]);
|
||||
const destroy = await launchDiditWebSdk({
|
||||
url: session.url,
|
||||
containerId: CONTAINER_ID,
|
||||
verificationId,
|
||||
onComplete: handleComplete,
|
||||
onError: handleError,
|
||||
onEvent: (type: string, payload: unknown) => {
|
||||
analytics.trackEvent('provider_message', {
|
||||
messageType: type,
|
||||
hasPayload: payload != null,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (cancelled) {
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
destroyRef.current = destroy;
|
||||
setPhase('active');
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
const message = err instanceof Error ? err.message : 'Failed to launch provider';
|
||||
analytics.trackEvent('provider_launch_failed', { error: message });
|
||||
setPhase('error');
|
||||
setErrorMessage(message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mountedRef.current = false;
|
||||
controller.abort();
|
||||
destroyRef.current?.();
|
||||
destroyRef.current = null;
|
||||
};
|
||||
}, [analytics, countryCode, documentType, handleComplete, handleError, verificationId, retryCount]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_launch_back_pressed', {
|
||||
countryCode,
|
||||
documentType,
|
||||
mockOutcome,
|
||||
});
|
||||
lifecycle.dismiss({ reason: 'back' });
|
||||
if (window.history.length > 1) {
|
||||
@@ -72,7 +172,59 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
} else {
|
||||
navigate('/', { state: { skipOnboardingRedirect: true } });
|
||||
}
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, mockOutcome, navigate]);
|
||||
}, [analytics, countryCode, documentType, haptic, lifecycle, navigate]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
haptic.trigger('selection');
|
||||
analytics.trackEvent('provider_launch_retry_pressed');
|
||||
setPhase('loading');
|
||||
setErrorMessage('');
|
||||
setRetryCount(c => c + 1);
|
||||
}, [haptic, analytics]);
|
||||
|
||||
if (phase === 'error') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.slate50,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
backgroundColor: colors.white,
|
||||
borderRadius: 24,
|
||||
padding: spacing.xl,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: spacing.md,
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Title textAlign="center">Unable to launch verification</Title>
|
||||
<Description>{errorMessage}</Description>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: spacing.sm,
|
||||
}}
|
||||
>
|
||||
<Button variant="secondary-label" text="Try Again" fullWidth onPress={handleRetry} />
|
||||
<Button variant="secondary-label" text="Back" fullWidth onPress={handleBack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -83,32 +235,65 @@ export const ProviderLaunchScreen: React.FC = () => {
|
||||
backgroundColor: colors.white,
|
||||
}}
|
||||
>
|
||||
<MockRegistrationFailureButton />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
flex: 1,
|
||||
gap: spacing.md,
|
||||
}}
|
||||
>
|
||||
{(phase === 'loading' || phase === 'waiting') && (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
flex: 1,
|
||||
}}
|
||||
/>
|
||||
<Title textAlign="center">Launching verification</Title>
|
||||
<Description textAlign="center">Preparing the mocked provider handoff for your registration flow.</Description>
|
||||
<Button variant="secondary-label" text="Back" fullWidth onPress={handleBack} />
|
||||
</div>
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: `3px solid ${colors.slate300}`,
|
||||
borderTopColor: colors.black,
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: spacing.md }}>
|
||||
<Title textAlign="center">
|
||||
{phase === 'waiting' ? 'Processing verification...' : 'Loading verification...'}
|
||||
</Title>
|
||||
{phase === 'waiting' && (
|
||||
<Description style={{ marginTop: 8 }}>
|
||||
Your documents are being verified. This may take a moment.
|
||||
</Description>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
.shadow-card {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
iframe[class*="in-iframe"] {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
div[class*="size-full"] {
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
id={CONTAINER_ID}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: phase === 'active' ? 'block' : 'none',
|
||||
width: '100%',
|
||||
minHeight: '100vh',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
declare module '@sumsub/websdk' {
|
||||
interface SnsWebSdkConf {
|
||||
lang?: string;
|
||||
theme?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
i18n?: Record<string, Record<string, string>>;
|
||||
uiConf?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SnsWebSdkOptions {
|
||||
addViewportTag?: boolean;
|
||||
adaptIframeHeight?: boolean;
|
||||
}
|
||||
|
||||
interface SnsWebSdkBuilder {
|
||||
withConf(conf: SnsWebSdkConf): SnsWebSdkBuilder;
|
||||
withOptions(options: SnsWebSdkOptions): SnsWebSdkBuilder;
|
||||
on(event: string, handler: (payload: any) => void): SnsWebSdkBuilder;
|
||||
onMessage(handler: (type: string, payload: unknown) => void): SnsWebSdkBuilder;
|
||||
build(): SnsWebSdkInstance;
|
||||
}
|
||||
|
||||
interface SnsWebSdkInstance {
|
||||
launch(container: HTMLElement): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
interface SnsWebSdk {
|
||||
init(accessToken: string, tokenRefreshCallback: () => Promise<string>): SnsWebSdkBuilder;
|
||||
}
|
||||
|
||||
const snsWebSdk: SnsWebSdk;
|
||||
export default snsWebSdk;
|
||||
}
|
||||
80
packages/webview-app/src/utils/diditAttestation.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
import type { KycProviderAttestation } from '../types/kycProvider';
|
||||
|
||||
const DIDIT_TEE_URL = import.meta.env.VITE_DIDIT_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
|
||||
const ATTESTATION_TIMEOUT_MS = 120_000; // 2 minutes
|
||||
|
||||
export interface AttestationResult {
|
||||
status: 'success' | 'failed' | 'timeout';
|
||||
attestation?: KycProviderAttestation;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to Socket.IO on the TEE and wait for the signed KYC attestation.
|
||||
* Returns the attestation (signature + applicantInfo + pubkey) or an error.
|
||||
*
|
||||
* After receiving data, emits `ack_success` to trigger session deletion on the TEE.
|
||||
*/
|
||||
export function waitForAttestation(sessionId: string, signal?: AbortSignal): Promise<AttestationResult> {
|
||||
return new Promise(resolve => {
|
||||
const socket = io(DIDIT_TEE_URL, {
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
socket.disconnect();
|
||||
resolve({ status: 'timeout', error: 'Timed out waiting for verification result' });
|
||||
}, ATTESTATION_TIMEOUT_MS);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
socket.disconnect();
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: 'Aborted' });
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('connect', () => {
|
||||
socket.emit('subscribe', sessionId);
|
||||
});
|
||||
|
||||
socket.on('success', (data: { signature: string; applicantInfo: string; pubkey: [string, string] }) => {
|
||||
socket.emit('ack_success', sessionId);
|
||||
cleanup();
|
||||
resolve({
|
||||
status: 'success',
|
||||
attestation: {
|
||||
serializedApplicantInfo: data.applicantInfo,
|
||||
signature: data.signature,
|
||||
pubkey: data.pubkey,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('verification_failed', (reason: string) => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: reason });
|
||||
});
|
||||
|
||||
socket.on('error', (err: string) => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: err });
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
cleanup();
|
||||
resolve({ status: 'failed', error: `Connection failed: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
149
packages/webview-app/src/utils/diditProvider.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { KycProviderResult } from '../types/kycProvider';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
const DIDIT_TEE_URL = import.meta.env.VITE_DIDIT_TEE_URL ?? 'https://kyc.self.xyz';
|
||||
|
||||
export interface DiditLaunchConfig {
|
||||
url: string;
|
||||
containerId: string;
|
||||
verificationId: string;
|
||||
onComplete: (result: KycProviderResult) => void;
|
||||
onError: (result: KycProviderResult) => void;
|
||||
onEvent?: (event: string, payload: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DiditSession {
|
||||
sessionId: string;
|
||||
sessionToken: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function buildProviderResult(verificationId: string, overrides: Partial<KycProviderResult>): KycProviderResult {
|
||||
return {
|
||||
status: 'error',
|
||||
verificationId,
|
||||
provider: 'didit',
|
||||
completedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiditSession(signal?: AbortSignal): Promise<DiditSession> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${DIDIT_TEE_URL}/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Didit session (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as DiditSession;
|
||||
}
|
||||
return body as DiditSession;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new Error(`Didit session request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
throw new Error(`Failed to create Didit session: ${err.message}`);
|
||||
}
|
||||
throw new Error('Failed to create Didit session: Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
export async function launchDiditWebSdk(config: DiditLaunchConfig): Promise<() => void> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { DiditSdk } = (await import('@didit-protocol/sdk-web')) as any;
|
||||
|
||||
let hasCompleted = false;
|
||||
|
||||
const emitOnce = (result: KycProviderResult, isError: boolean) => {
|
||||
if (hasCompleted) return;
|
||||
hasCompleted = true;
|
||||
if (isError) {
|
||||
config.onError(result);
|
||||
} else {
|
||||
config.onComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
DiditSdk.shared.onComplete = (sdkResult: {
|
||||
type: 'completed' | 'cancelled' | 'failed';
|
||||
session?: { status: string; sessionId: string };
|
||||
error?: { type: string; message: string };
|
||||
}) => {
|
||||
if (sdkResult.type === 'completed') {
|
||||
const status = sdkResult.session?.status;
|
||||
if (status === 'Declined') {
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: 'error',
|
||||
providerSessionId: sdkResult.session?.sessionId,
|
||||
error: {
|
||||
code: 'provider_rejected',
|
||||
message: 'Verification was declined by the provider',
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: status === 'Approved' ? 'success' : 'partial',
|
||||
providerSessionId: sdkResult.session?.sessionId,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else if (sdkResult.type === 'cancelled') {
|
||||
emitOnce(buildProviderResult(config.verificationId, { status: 'cancel' }), false);
|
||||
} else if (sdkResult.type === 'failed') {
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'provider_unknown_error',
|
||||
message: sdkResult.error?.message ?? 'Verification failed',
|
||||
retryable: true,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
DiditSdk.shared.onEvent = (event: { type?: string }) => {
|
||||
config.onEvent?.(event.type ?? 'unknown', event);
|
||||
};
|
||||
|
||||
DiditSdk.shared.startVerification({
|
||||
url: config.url,
|
||||
configuration: {
|
||||
loggingEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
DiditSdk.shared.close();
|
||||
};
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025-2026 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { KycProviderResult } from '../types/kycProvider';
|
||||
|
||||
const FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
const SUMSUB_TEE_URL = import.meta.env.VITE_SUMSUB_TEE_URL ?? 'https://sumsub-tee.self.xyz';
|
||||
|
||||
export interface SumsubAccessToken {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface SumsubLaunchConfig {
|
||||
accessToken: string;
|
||||
containerId: string;
|
||||
verificationId: string;
|
||||
locale?: string;
|
||||
onComplete: (result: KycProviderResult) => void;
|
||||
onError: (result: KycProviderResult) => void;
|
||||
onMessage?: (type: SumsubMessageType, payload: unknown) => void;
|
||||
}
|
||||
|
||||
type SumsubMessageType =
|
||||
| 'idCheck.onReady'
|
||||
| 'idCheck.onInitialized'
|
||||
| 'idCheck.applicantStatus'
|
||||
| 'idCheck.onApplicantLoaded'
|
||||
| 'idCheck.onApplicantResubmitted'
|
||||
| 'idCheck.onApplicantSubmitted'
|
||||
| 'idCheck.onActionSubmitted'
|
||||
| 'idCheck.applicantReviewComplete'
|
||||
| 'idCheck.moduleResultPresented'
|
||||
| 'idCheck.onError'
|
||||
| 'idCheck.onStepCompleted'
|
||||
| 'idCheck.onStepInitiated'
|
||||
| string;
|
||||
|
||||
interface SumsubMessage {
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SumsubApplicantStatus {
|
||||
reviewStatus?: string;
|
||||
reviewResult?: {
|
||||
reviewAnswer?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSumsubAccessToken(signal?: AbortSignal): Promise<SumsubAccessToken> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const combinedSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SUMSUB_TEE_URL}/access-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: combinedSignal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get Sumsub access token (HTTP ${response.status})`);
|
||||
}
|
||||
|
||||
const body: unknown = await response.json();
|
||||
if (typeof body === 'string') {
|
||||
return JSON.parse(body) as SumsubAccessToken;
|
||||
}
|
||||
return body as SumsubAccessToken;
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new Error(`Sumsub access token request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
throw new Error(`Failed to get Sumsub access token: ${err.message}`);
|
||||
}
|
||||
throw new Error('Failed to get Sumsub access token: Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
function buildProviderResult(verificationId: string, overrides: Partial<KycProviderResult>): KycProviderResult {
|
||||
return {
|
||||
status: 'error',
|
||||
verificationId,
|
||||
provider: 'sumsub',
|
||||
completedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function launchSumsubWebSdk(config: SumsubLaunchConfig): Promise<() => void> {
|
||||
const { default: snsWebSdk } = await import('@sumsub/websdk');
|
||||
|
||||
const container = document.getElementById(config.containerId);
|
||||
if (!container) {
|
||||
throw new Error(`Container element #${config.containerId} not found`);
|
||||
}
|
||||
|
||||
let hasCompleted = false;
|
||||
|
||||
const emitOnce = (result: KycProviderResult, isError: boolean) => {
|
||||
if (hasCompleted) return;
|
||||
hasCompleted = true;
|
||||
if (isError) {
|
||||
config.onError(result);
|
||||
} else {
|
||||
config.onComplete(result);
|
||||
}
|
||||
};
|
||||
|
||||
const snsWebSdkInstance = snsWebSdk
|
||||
.init(config.accessToken, () => fetchSumsubAccessToken().then(t => t.token))
|
||||
.withConf({ lang: config.locale ?? 'en' })
|
||||
.withOptions({ addViewportTag: false, adaptIframeHeight: true })
|
||||
.on('idCheck.onReady', () => {
|
||||
config.onMessage?.('idCheck.onReady', {});
|
||||
})
|
||||
.on('idCheck.onError', (error: unknown) => {
|
||||
config.onMessage?.('idCheck.onError', error);
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : 'Provider error';
|
||||
emitOnce(
|
||||
buildProviderResult(config.verificationId, {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'provider_unknown_error',
|
||||
message,
|
||||
retryable: true,
|
||||
},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
})
|
||||
.on('idCheck.applicantStatus', (status: SumsubApplicantStatus) => {
|
||||
config.onMessage?.('idCheck.applicantStatus', status);
|
||||
})
|
||||
.on('idCheck.onApplicantSubmitted', () => {
|
||||
config.onMessage?.('idCheck.onApplicantSubmitted', {});
|
||||
emitOnce(buildProviderResult(config.verificationId, { status: 'partial' }), false);
|
||||
})
|
||||
.on('idCheck.applicantReviewComplete', (status: SumsubApplicantStatus) => {
|
||||
config.onMessage?.('idCheck.applicantReviewComplete', status);
|
||||
const result = normalizeSumsubStatus(config.verificationId, status);
|
||||
const isError = result.status === 'error';
|
||||
emitOnce(result, isError);
|
||||
})
|
||||
.on('idCheck.moduleResultPresented', (payload: SumsubMessage) => {
|
||||
config.onMessage?.('idCheck.moduleResultPresented', payload);
|
||||
})
|
||||
.onMessage((type: SumsubMessageType, payload: unknown) => {
|
||||
config.onMessage?.(type, payload);
|
||||
})
|
||||
.build();
|
||||
|
||||
snsWebSdkInstance.launch(container);
|
||||
|
||||
return () => {
|
||||
snsWebSdkInstance.destroy();
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSumsubStatus(
|
||||
verificationId: string,
|
||||
applicantStatus: SumsubApplicantStatus | undefined,
|
||||
): KycProviderResult {
|
||||
const reviewAnswer = applicantStatus?.reviewResult?.reviewAnswer;
|
||||
const reviewStatus = applicantStatus?.reviewStatus;
|
||||
|
||||
if (reviewAnswer === 'GREEN') {
|
||||
return buildProviderResult(verificationId, { status: 'success' });
|
||||
}
|
||||
|
||||
if (reviewAnswer === 'RED') {
|
||||
return buildProviderResult(verificationId, {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'provider_rejected',
|
||||
message: 'Verification was rejected by the provider',
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (reviewStatus === 'pending' || reviewStatus === 'onHold' || reviewStatus === 'queued') {
|
||||
return buildProviderResult(verificationId, { status: 'partial' });
|
||||
}
|
||||
|
||||
return buildProviderResult(verificationId, { status: 'partial' });
|
||||
}
|
||||
16
yarn.lock
@@ -3842,6 +3842,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@didit-protocol/sdk-web@npm:^0.1.8":
|
||||
version: 0.1.8
|
||||
resolution: "@didit-protocol/sdk-web@npm:0.1.8"
|
||||
checksum: 10c0/c8fc35b8a8f73e678f4d868676bf70fe3095c62725f9b6ebc242310f93db70e3b17d298fc5d7df844d1ec18c445aac5a4dde329dac2ecef3fff03973265fed17
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@discoveryjs/json-ext@npm:0.6.3":
|
||||
version: 0.6.3
|
||||
resolution: "@discoveryjs/json-ext@npm:0.6.3"
|
||||
@@ -11260,12 +11267,12 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@selfxyz/webview-app@workspace:packages/webview-app"
|
||||
dependencies:
|
||||
"@didit-protocol/sdk-web": "npm:^0.1.8"
|
||||
"@scure/bip39": "npm:^1.6.0"
|
||||
"@selfxyz/euclid": "npm:1.3.0"
|
||||
"@selfxyz/euclid-core": "npm:1.3.0"
|
||||
"@selfxyz/mobile-sdk-alpha": "workspace:^"
|
||||
"@selfxyz/webview-bridge": "workspace:^"
|
||||
"@sumsub/websdk": "npm:^2.0.0"
|
||||
"@testing-library/react": "npm:^14.1.2"
|
||||
"@types/react": "npm:^18.3.4"
|
||||
"@types/react-dom": "npm:^18.3.0"
|
||||
@@ -13714,13 +13721,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sumsub/websdk@npm:^2.0.0":
|
||||
version: 2.6.1
|
||||
resolution: "@sumsub/websdk@npm:2.6.1"
|
||||
checksum: 10c0/02c31ca25d1ec3ce0e90f7874a40f2c0996f45d173e237147c9fcf59668651cc33ed61871013ab6b95b5ad10dc38abd4ac40a8043cb287442aa278924b93b9ee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0":
|
||||
version: 8.0.0
|
||||
resolution: "@svgr/babel-plugin-add-jsx-attribute@npm:8.0.0"
|
||||
|
||||