diff --git a/packages/kmp-sdk-test-app/composeApp/build.gradle.kts b/packages/kmp-sdk-test-app/composeApp/build.gradle.kts
index 9c729448c..5a4a06981 100644
--- a/packages/kmp-sdk-test-app/composeApp/build.gradle.kts
+++ b/packages/kmp-sdk-test-app/composeApp/build.gradle.kts
@@ -74,6 +74,12 @@ android {
.toInt()
versionCode = 1
versionName = "1.0.0"
+
+ buildConfigField("String", "WEBVIEW_DEV_URL", "\"${System.getenv("WEBVIEW_DEV_URL") ?: ""}\"")
+ }
+
+ buildFeatures {
+ buildConfig = true
}
compileOptions {
diff --git a/packages/kmp-sdk-test-app/composeApp/src/androidMain/kotlin/xyz/self/testapp/screens/DevServerUrl.android.kt b/packages/kmp-sdk-test-app/composeApp/src/androidMain/kotlin/xyz/self/testapp/screens/DevServerUrl.android.kt
new file mode 100644
index 000000000..334a76111
--- /dev/null
+++ b/packages/kmp-sdk-test-app/composeApp/src/androidMain/kotlin/xyz/self/testapp/screens/DevServerUrl.android.kt
@@ -0,0 +1,9 @@
+// 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.
+
+package xyz.self.testapp.screens
+
+import xyz.self.testapp.BuildConfig
+
+actual fun getDevServerUrl(): String? = BuildConfig.WEBVIEW_DEV_URL.ifBlank { null }
diff --git a/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt b/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt
index 0f0af5894..599fe5704 100644
--- a/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt
+++ b/packages/kmp-sdk-test-app/composeApp/src/commonMain/kotlin/xyz/self/testapp/screens/SdkLaunchScreen.kt
@@ -48,6 +48,8 @@ import xyz.self.sdk.api.SelfSdkError
import xyz.self.sdk.api.VerificationRequest
import xyz.self.sdk.api.VerificationResult
+expect fun getDevServerUrl(): String?
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SdkLaunchScreen(navController: NavController) {
@@ -65,6 +67,7 @@ fun SdkLaunchScreen(navController: NavController) {
val environment = if (useMockDocument) SelfEnvironment.STG else SelfEnvironment.PROD
val coroutineScope = rememberCoroutineScope()
+ val devServerUrl = remember { getDevServerUrl() }
val sdk =
remember(environment, appName, appEndpoint) {
SelfSdk.configure(
@@ -73,6 +76,7 @@ fun SdkLaunchScreen(navController: NavController) {
debug = true,
appName = appName.ifBlank { null },
appEndpoint = appEndpoint.ifBlank { null },
+ devServerUrl = devServerUrl,
),
)
}
diff --git a/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/DevServerUrl.ios.kt b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/DevServerUrl.ios.kt
new file mode 100644
index 000000000..e29bd98f3
--- /dev/null
+++ b/packages/kmp-sdk-test-app/composeApp/src/iosMain/kotlin/xyz/self/testapp/screens/DevServerUrl.ios.kt
@@ -0,0 +1,7 @@
+// 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.
+
+package xyz.self.testapp.screens
+
+actual fun getDevServerUrl(): String? = null
diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
index aa307f324..f0e689e11 100644
--- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
+++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/api/SelfSdk.android.kt
@@ -124,6 +124,9 @@ actual class SelfSdk private constructor(
putExtra(SelfVerificationActivity.EXTRA_DEBUG_MODE, config.debug)
putExtra(SelfVerificationActivity.EXTRA_VERIFICATION_REQUEST, serializeRequest(request))
putExtra(SelfVerificationActivity.EXTRA_CONFIG, serializeConfig(config))
+ if (config.devServerUrl != null) {
+ putExtra(SelfVerificationActivity.EXTRA_DEV_SERVER_URL, config.devServerUrl)
+ }
}
// Launch the verification activity
diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt
index 3fc66000b..2220869f9 100644
--- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt
+++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/AndroidWebViewHost.kt
@@ -27,6 +27,7 @@ class AndroidWebViewHost(
private val context: Context,
private val router: MessageRouter,
private val isDebugMode: Boolean = false,
+ private val devServerUrl: String? = null,
) {
private lateinit var webView: WebView
var pendingPermissionRequest: PermissionRequest? = null
@@ -55,9 +56,17 @@ class AndroidWebViewHost(
request: WebResourceRequest?,
): Boolean {
val uri = request?.url ?: return true
+ val devHost = devServerUrl?.let { Uri.parse(it) }
val isAllowed =
(uri.scheme == "https" && uri.host == "self-app-alpha.vercel.app") ||
- (isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173)
+ (isDebugMode && uri.scheme == "http" && uri.host == "127.0.0.1" && uri.port == 5173) ||
+ (
+ isDebugMode &&
+ devHost != null &&
+ uri.scheme == devHost.scheme &&
+ uri.host == devHost.host &&
+ uri.port == devHost.port
+ )
return !isAllowed
}
@@ -79,10 +88,18 @@ class AndroidWebViewHost(
request.deny()
return
}
+ val devHost = devServerUrl?.let { Uri.parse(it) }
val isTrusted =
(origin.scheme == "https" && origin.host == "self-app-alpha.vercel.app") ||
(origin.scheme == "https" && origin.host == "verify.didit.me") ||
- (isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1")
+ (isDebugMode && origin.scheme == "http" && origin.host == "127.0.0.1" && origin.port == 5173) ||
+ (
+ isDebugMode &&
+ devHost != null &&
+ origin.scheme == devHost.scheme &&
+ origin.host == devHost.host &&
+ origin.port == devHost.port
+ )
if (!isTrusted) {
request.deny()
return
@@ -156,7 +173,12 @@ class AndroidWebViewHost(
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
- val baseUrl = "https://self-app-alpha.vercel.app/tunnel/tour/1"
+ val baseUrl =
+ if (isDebugMode && devServerUrl != null) {
+ "${devServerUrl.trimEnd('/')}/tunnel/tour/1"
+ } else {
+ "https://self-app-alpha.vercel.app/tunnel/tour/1"
+ }
val url = if (queryParams.isNotEmpty()) "$baseUrl?$queryParams" else baseUrl
loadUrl(url)
}
diff --git a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt
index 43d1fe2ca..1b6c4bf27 100644
--- a/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt
+++ b/packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/webview/SelfVerificationActivity.kt
@@ -63,7 +63,8 @@ class SelfVerificationActivity : AppCompatActivity() {
return
}
- webViewHost = AndroidWebViewHost(this, router, isDebugMode)
+ val devServerUrl = intent.getStringExtra(EXTRA_DEV_SERVER_URL)
+ webViewHost = AndroidWebViewHost(this, router, isDebugMode, devServerUrl)
val webView = webViewHost.createWebView(queryParams)
setContentView(webView)
}
@@ -179,6 +180,7 @@ class SelfVerificationActivity : AppCompatActivity() {
const val EXTRA_DEBUG_MODE = "xyz.self.sdk.DEBUG_MODE"
const val EXTRA_VERIFICATION_REQUEST = "xyz.self.sdk.VERIFICATION_REQUEST"
const val EXTRA_CONFIG = "xyz.self.sdk.CONFIG"
+ const val EXTRA_DEV_SERVER_URL = "xyz.self.sdk.DEV_SERVER_URL"
const val RESULT_CODE_SUCCESS = RESULT_OK
const val RESULT_CODE_ERROR = RESULT_FIRST_USER
diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt
index 3aaac15f5..5e3188b4a 100644
--- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt
+++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/api/SelfSdkConfig.kt
@@ -34,4 +34,5 @@ data class SelfSdkConfig(
val appEndpoint: String? = null,
val endpointType: String? = null,
val chainID: Int? = null,
+ val devServerUrl: String? = null,
)
diff --git a/packages/webview-app/src/App.tsx b/packages/webview-app/src/App.tsx
index 0e06f52aa..dbb53ed5a 100644
--- a/packages/webview-app/src/App.tsx
+++ b/packages/webview-app/src/App.tsx
@@ -54,6 +54,7 @@ import { TourScreen as TunnelTourScreen } from './screens/tunnel/TourScreen';
import { TunnelCountryPickerScreen } from './screens/tunnel/TunnelCountryPickerScreen';
import { TunnelDiscloseScreen } from './screens/tunnel/TunnelDiscloseScreen';
import { TunnelIDTypeScreen } from './screens/tunnel/TunnelIDTypeScreen';
+import { TunnelKycFailureScreen } from './screens/tunnel/TunnelKycFailureScreen';
import { TunnelKycSuccessScreen } from './screens/tunnel/TunnelKycSuccessScreen';
import { TunnelKycWrapper } from './screens/tunnel/TunnelKycWrapper';
import { TunnelProofReceiptScreen } from './screens/tunnel/TunnelProofReceiptScreen';
@@ -110,6 +111,7 @@ export const App: React.FC = () => (
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/packages/webview-app/src/components/DevRouteMenu.tsx b/packages/webview-app/src/components/DevRouteMenu.tsx
index 1975f2faf..b00193be3 100644
--- a/packages/webview-app/src/components/DevRouteMenu.tsx
+++ b/packages/webview-app/src/components/DevRouteMenu.tsx
@@ -14,6 +14,7 @@ interface DevScreenLink {
interface DevScreenGroup {
title: string;
links: DevScreenLink[];
+ description?: string;
}
const screenGroups: DevScreenGroup[] = [
@@ -72,15 +73,26 @@ const screenGroups: DevScreenGroup[] = [
],
},
{
- title: 'Tunnel',
+ title: 'Tunnel — Screens',
links: [
{ href: '/tunnel/tour/1', label: 'Tour' },
- { href: '/tunnel/kyc', label: 'KYC Mock' },
{ href: '/tunnel/registration/country', label: 'Country Picker' },
{ href: '/tunnel/registration/id-type', label: 'ID Type' },
- { href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
+ { href: '/tunnel/kyc-failure', label: 'KYC Failure' },
+ { href: '/tunnel/recovery-required', label: 'Recovery Required' },
{ href: '/tunnel/proof/generating', label: 'Proving' },
{ href: '/tunnel/proof/result', label: 'Result' },
+ { href: '/tunnel/proof/receipt', label: 'Proof Receipt' },
+ ],
+ },
+ {
+ title: 'Tunnel — Mock KYC',
+ description: 'Mocks diverge after /tunnel/kyc; some outcomes intentionally share the same final route.',
+ links: [
+ { href: '/tunnel/tour/1?mock=success', label: 'Flow → KYC Success, Then Proof Failure' },
+ { href: '/tunnel/tour/1?mock=kyc-failure', label: 'Flow → KYC Error (Retryable)' },
+ { href: '/tunnel/tour/1?mock=registration-failure', label: 'Flow → KYC Error (Fatal → Tour Step 4)' },
+ { href: '/tunnel/tour/1?mock=cancel', label: 'Flow → KYC Cancel → Tour Step 4' },
],
},
{
@@ -103,10 +115,10 @@ export const DevRouteMenu: React.FC = () => {
}
}, [isOpen]);
- const currentLabel = useMemo(
- () => allLinks.find(link => link.href === location.pathname)?.label ?? 'Dev Screens',
- [location.pathname],
- );
+ const currentLabel = useMemo(() => {
+ const fullPath = `${location.pathname}${location.search}`;
+ return allLinks.find(link => link.href === fullPath)?.label ?? 'Dev Screens';
+ }, [location.pathname, location.search]);
return (
{
>
{group.title}
+ {group.description ? (
+
+ {group.description}
+
+ ) : null}
{group.links.map(link => {
- const isActive = location.pathname === link.href;
+ const isActive = `${location.pathname}${location.search}` === link.href;
return (