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>
This commit is contained in:
Nesopie
2026-03-30 15:53:14 +05:30
committed by GitHub
parent af1baec3f5
commit 2f2ec3abe6
58 changed files with 4512 additions and 317 deletions

2
.gitignore vendored
View File

@@ -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/

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

View 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>

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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? {

View File

@@ -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")

View File

@@ -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:

View File

@@ -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"

View File

@@ -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) }

View File

@@ -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

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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 />

View File

@@ -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>
);
};

View File

@@ -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;
}

View 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}` });
});
});
}

View 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();
};
}

View File

@@ -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' });
}

View File

@@ -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"