From f47b46d465a7eb414569a2a4e13bed64db76083e Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Thu, 12 Mar 2026 07:50:18 -0700 Subject: [PATCH] Add browser host callback contract for WebView verification flow (#1846) * push code for wv-02 and 03 * save wip * fixes * pr feedback fix pipelines * save spec * address feedback * pr feedbacak * fixes * format * fix pipelines * fix * fix * fixes --- .github/workflows/rn-sdk-test-app-ci.yml | 10 + .../sdk/handlers/LifecycleSetResultOutcome.kt | 2 + .../handlers/LifecycleBridgeHandlerTest.kt | 41 ++++ .../mobile-sdk-alpha/scripts/build-android.sh | 12 + .../src/providers/BridgeProvider.tsx | 15 +- .../src/providers/SelfClientProvider.tsx | 27 ++- .../providers/VerificationRequestProvider.tsx | 89 +------- .../src/screens/account/SettingsScreen.tsx | 2 +- .../ConfirmIdentificationScreen.tsx | 18 +- .../onboarding/ProviderLaunchScreen.tsx | 8 +- .../src/screens/proving/ProvingScreen.tsx | 45 +++- .../proving/VerificationResultScreen.tsx | 24 +- .../src/utils/verificationRequest.test.ts | 83 +++++++ .../src/utils/verificationRequest.ts | 119 ++++++++++ .../src/__tests__/adapters.test.ts | 42 +++- .../src/__tests__/analytics-web.test.ts | 2 +- .../src/__tests__/bridge.test.ts | 69 ++++++ .../src/__tests__/documents-web.test.ts | 2 +- .../src/__tests__/helpers/mockWindow.ts | 41 ++++ .../webview-bridge/src/adapters/crypto.ts | 2 +- packages/webview-bridge/src/adapters/index.ts | 4 +- .../webview-bridge/src/adapters/lifecycle.ts | 25 ++- packages/webview-bridge/src/bridge.ts | 206 +++++++++++++++++- packages/webview-bridge/src/index.ts | 5 + packages/webview-bridge/src/types.ts | 24 ++ specs/projects/sdk/OVERVIEW.md | 6 +- .../projects/sdk/workstreams/webview/SPEC.md | 68 +++++- .../plans/WV-04-host-callback-contract.md | 99 +++++++++ 28 files changed, 954 insertions(+), 136 deletions(-) create mode 100644 packages/webview-app/src/utils/verificationRequest.test.ts create mode 100644 packages/webview-app/src/utils/verificationRequest.ts create mode 100644 packages/webview-bridge/src/__tests__/helpers/mockWindow.ts create mode 100644 specs/projects/sdk/workstreams/webview/plans/WV-04-host-callback-contract.md diff --git a/.github/workflows/rn-sdk-test-app-ci.yml b/.github/workflows/rn-sdk-test-app-ci.yml index e36851267..5c96eeb09 100644 --- a/.github/workflows/rn-sdk-test-app-ci.yml +++ b/.github/workflows/rn-sdk-test-app-ci.yml @@ -37,6 +37,10 @@ jobs: exit 1 fi echo "βœ… No nested require() patterns found" + - name: Build common + run: yarn workspace @selfxyz/common build + - name: Build mobile-sdk-alpha + run: yarn workspace @selfxyz/mobile-sdk-alpha build - name: Build webview-bridge run: yarn workspace @selfxyz/webview-bridge build - name: Build webview-app @@ -96,6 +100,12 @@ jobs: node-version-file: .nvmrc - name: Install dependencies uses: ./.github/actions/yarn-install + - name: Build common + run: yarn workspace @selfxyz/common build + - name: Build mobile-sdk-alpha iOS artifacts + run: yarn workspace @selfxyz/mobile-sdk-alpha build:ios + - name: Build mobile-sdk-alpha TypeScript bundle + run: yarn workspace @selfxyz/mobile-sdk-alpha build:ts-only - name: Build webview-bridge run: yarn workspace @selfxyz/webview-bridge build - name: Build webview-app diff --git a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/LifecycleSetResultOutcome.kt b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/LifecycleSetResultOutcome.kt index 72514ce83..a4cbcfe1d 100644 --- a/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/LifecycleSetResultOutcome.kt +++ b/packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/handlers/LifecycleSetResultOutcome.kt @@ -65,6 +65,8 @@ internal fun resolveLifecycleSetResult(params: Map): Lifecy ), ) } + success == true && (params.containsKey("userId") || params.containsKey("verificationId")) -> + LifecycleSetResultOutcome.Success(verificationResultFromLifecycleParams(params)) else -> LifecycleSetResultOutcome.Cancelled } } diff --git a/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandlerTest.kt b/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandlerTest.kt index 3cae9776e..4ff451b40 100644 --- a/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandlerTest.kt +++ b/packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/handlers/LifecycleBridgeHandlerTest.kt @@ -107,5 +107,46 @@ class LifecycleBridgeHandlerTest { assertEquals("Proof generation failed", failure.error.message) } + @Test + fun resolveLifecycleSetResult_flatPayloadWithoutTypeOrDataRoutesToSuccess() { + val outcome = + resolveLifecycleSetResult( + params( + """ + { + "success": true, + "userId": "user-1", + "verificationId": "verif-1", + "claims": { + "resultType": "proofRequested" + } + } + """.trimIndent(), + ), + ) + + val success = assertIs(outcome) + assertEquals(true, success.result.success) + assertEquals("user-1", success.result.userId) + assertEquals("verif-1", success.result.verificationId) + assertEquals("proofRequested", success.result.claims?.get("resultType")) + } + + @Test + fun resolveLifecycleSetResult_bareSuccessTrueWithoutIdentifiersRoutesToCancelled() { + val outcome = + resolveLifecycleSetResult( + params( + """ + { + "success": true + } + """.trimIndent(), + ), + ) + + assertIs(outcome) + } + private fun params(rawJson: String) = json.parseToJsonElement(rawJson).jsonObject } diff --git a/packages/mobile-sdk-alpha/scripts/build-android.sh b/packages/mobile-sdk-alpha/scripts/build-android.sh index 8a74b2cdc..28114f680 100755 --- a/packages/mobile-sdk-alpha/scripts/build-android.sh +++ b/packages/mobile-sdk-alpha/scripts/build-android.sh @@ -24,6 +24,18 @@ echo "πŸ” Checking for Android build options..." if [ -d "$MOBILE_SDK_NATIVE" ]; then echo "βœ… Native modules source submodule found, building from source..." + # Check if Java is actually available (required for Gradle build) + # Note: macOS has a /usr/bin/java stub that passes `command -v` but fails at runtime + if ! java -version 2>/dev/null 1>/dev/null; then + echo "⚠️ Java not available β€” skipping Android AAR build" + if [ -f "dist/android/mobile-sdk-alpha-release.aar" ]; then + echo "πŸ“¦ Using existing prebuilt AAR: dist/android/mobile-sdk-alpha-release.aar" + else + echo "⚠️ No prebuilt AAR available. Android native modules will not be included." + fi + exit 0 + fi + # Check if we already have a valid AAR file if [ -f "dist/android/mobile-sdk-alpha-release.aar" ]; then echo "πŸ” AAR file found, validating contents..." diff --git a/packages/webview-app/src/providers/BridgeProvider.tsx b/packages/webview-app/src/providers/BridgeProvider.tsx index 9b3d93d07..cf7785bb0 100644 --- a/packages/webview-app/src/providers/BridgeProvider.tsx +++ b/packages/webview-app/src/providers/BridgeProvider.tsx @@ -4,6 +4,7 @@ import React, { createContext, useContext, useMemo } from 'react'; import { WebViewBridge } from '@selfxyz/webview-bridge'; +import { parseBrowserHostTargetOrigin } from '../utils/verificationRequest'; const BridgeContext = createContext(null); @@ -19,7 +20,19 @@ export const BridgeProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const bridge = useMemo( - () => new WebViewBridge({ debug: import.meta.env.DEV }), + () => { + const isDev = import.meta.env.DEV; + + return new WebViewBridge({ + debug: isDev, + browserHost: { + targetOrigin: + parseBrowserHostTargetOrigin(window.location.search, { + allowWildcard: isDev, + }) ?? (isDev ? '*' : undefined), + }, + }); + }, [], ); diff --git a/packages/webview-app/src/providers/SelfClientProvider.tsx b/packages/webview-app/src/providers/SelfClientProvider.tsx index 175a216e4..0081ec957 100644 --- a/packages/webview-app/src/providers/SelfClientProvider.tsx +++ b/packages/webview-app/src/providers/SelfClientProvider.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import React, { createContext, useContext, useEffect, useMemo } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { bridgeCryptoAdapter, @@ -27,6 +27,7 @@ import type { BridgeBiometricsAdapter, } from '@selfxyz/webview-bridge/adapters'; import { useBridge } from './BridgeProvider'; +import { useVerificationRequest } from './VerificationRequestProvider'; export interface SelfClientAdapters { crypto: BridgeCryptoAdapter; @@ -55,6 +56,7 @@ export const SelfClientProvider: React.FC<{ children: React.ReactNode }> = ({ }) => { const bridge = useBridge(); const navigate = useNavigate(); + const { verificationId } = useVerificationRequest(); const adapters = useMemo(() => { const lifecycle = bridgeLifecycleAdapter(bridge); @@ -75,9 +77,28 @@ export const SelfClientProvider: React.FC<{ children: React.ReactNode }> = ({ }; }, [bridge, navigate]); + const lastReadyRef = useRef<{ + lifecycle: BridgeLifecycleAdapter; + verificationId?: string; + } | null>(null); useEffect(() => { - adapters.lifecycle.ready(); - }, [adapters.lifecycle]); + if ( + lastReadyRef.current?.lifecycle === adapters.lifecycle && + lastReadyRef.current?.verificationId === verificationId + ) { + return; + } + adapters.lifecycle.ready( + verificationId ? { verificationId } : {}, + ); + lastReadyRef.current = { lifecycle: adapters.lifecycle, verificationId }; + }, [adapters.lifecycle, verificationId]); + + useEffect(() => { + return bridge.on('lifecycle', 'cancel', () => { + navigate('/', { replace: true }); + }); + }, [bridge, navigate]); return ( diff --git a/packages/webview-app/src/providers/VerificationRequestProvider.tsx b/packages/webview-app/src/providers/VerificationRequestProvider.tsx index 495658a95..f0300bcd9 100644 --- a/packages/webview-app/src/providers/VerificationRequestProvider.tsx +++ b/packages/webview-app/src/providers/VerificationRequestProvider.tsx @@ -3,28 +3,10 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import React, { createContext, useContext, useMemo } from 'react'; -import type { VerificationRequest } from '@selfxyz/mobile-sdk-alpha'; +import type { ParsedVerificationRequestContext } from '../utils/verificationRequest'; +import { parseVerificationRequestContext } from '../utils/verificationRequest'; -export interface VerificationRequestContext { - /** Parsed verification request from URL params. */ - request: VerificationRequest; - /** Optional display-label overrides from the host (proofItems param). */ - displayLabels: string[] | null; - /** Display name for the requesting application. */ - appName: string; - /** Sanitized host/endpoint string for the requesting application. */ - appEndpoint: string; - /** Timestamp of the request (epoch ms). */ - timestamp: number; - /** The request type (e.g. 'proofRequested'). */ - requestType: string; -} - -const ALLOWED_REQUEST_TYPES = new Set([ - 'proofRequested', - 'documentOwnershipConfirmed', -]); -const DEFAULT_REQUEST_TYPE = 'proofRequested'; +export type VerificationRequestContext = ParsedVerificationRequestContext; const Ctx = createContext(null); @@ -38,70 +20,13 @@ export function useVerificationRequest(): VerificationRequestContext { return ctx; } -function normalizeRequestType(value: string | null | undefined): string { - if (!value) return DEFAULT_REQUEST_TYPE; - return ALLOWED_REQUEST_TYPES.has(value) ? value : DEFAULT_REQUEST_TYPE; -} - -function normalizeAppEndpoint(value: string | null | undefined): string { - if (!value) return ''; - try { - const endpoint = new URL(value); - const isHttps = endpoint.protocol === 'https:'; - const isLocalHttp = - endpoint.protocol === 'http:' && - (endpoint.hostname === 'localhost' || endpoint.hostname === '127.0.0.1'); - if (!isHttps && !isLocalHttp) return ''; - return endpoint.host; - } catch { - return ''; - } -} - -function splitCSV(value: string): string[] { - return value.split(',').map((s) => s.trim()).filter(Boolean); -} - -function parseDisclosures(params: URLSearchParams): string[] | undefined { - const raw = params.get('disclosures'); - if (!raw) return undefined; - const items = splitCSV(raw); - return items.length > 0 ? items : undefined; -} - -function parseDisplayLabels(params: URLSearchParams): string[] | null { - const raw = params.get('proofItems'); - if (!raw) return null; - const items = splitCSV(raw); - return items.length > 0 ? items : null; -} - -function parseFromURL(): VerificationRequestContext { - const params = new URLSearchParams(window.location.search); - - const request: VerificationRequest = { - userId: params.get('userId') ?? undefined, - scope: params.get('scope') ?? undefined, - disclosures: parseDisclosures(params), - }; - - const queryTimestamp = params.get('timestamp'); - const parsed = queryTimestamp ? Number(queryTimestamp) : Number.NaN; - - return { - request, - displayLabels: parseDisplayLabels(params), - appName: params.get('appName') ?? 'Verification', - appEndpoint: normalizeAppEndpoint(params.get('appEndpoint')), - timestamp: Number.isFinite(parsed) ? parsed : Date.now(), - requestType: normalizeRequestType(params.get('resultType')), - }; -} - export const VerificationRequestProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { - const value = useMemo(() => parseFromURL(), []); + const value = useMemo( + () => parseVerificationRequestContext(window.location.search), + [], + ); return {children}; }; diff --git a/packages/webview-app/src/screens/account/SettingsScreen.tsx b/packages/webview-app/src/screens/account/SettingsScreen.tsx index a95361cc3..dbc5effbb 100644 --- a/packages/webview-app/src/screens/account/SettingsScreen.tsx +++ b/packages/webview-app/src/screens/account/SettingsScreen.tsx @@ -29,7 +29,7 @@ export const SettingsScreen: React.FC = () => { const onDismiss = useCallback(async () => { haptic.trigger('selection'); analytics.trackEvent('settings_dismiss_pressed'); - lifecycle.dismiss(); + lifecycle.dismiss({ reason: 'user_cancel' }); }, [haptic, analytics, lifecycle]); return ( diff --git a/packages/webview-app/src/screens/onboarding/ConfirmIdentificationScreen.tsx b/packages/webview-app/src/screens/onboarding/ConfirmIdentificationScreen.tsx index 112e3657d..7de570edb 100644 --- a/packages/webview-app/src/screens/onboarding/ConfirmIdentificationScreen.tsx +++ b/packages/webview-app/src/screens/onboarding/ConfirmIdentificationScreen.tsx @@ -5,32 +5,42 @@ import React, { useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { StatusState, CheckCircleIcon, colors } from '@selfxyz/euclid-web'; +import type { VerificationResult } from '@selfxyz/webview-bridge'; import { useSelfClient } from '../../providers/SelfClientProvider'; +import { useVerificationRequest } from '../../providers/VerificationRequestProvider'; export const ConfirmIdentificationScreen: React.FC = () => { const navigate = useNavigate(); const { analytics, haptic, lifecycle } = useSelfClient(); + const { request, verificationId } = useVerificationRequest(); useEffect(() => { haptic.trigger('success'); }, [haptic]); const onConfirm = useCallback(async () => { + const result: VerificationResult = { + success: true, + userId: request.userId, + verificationId, + claims: { + resultType: 'documentOwnershipConfirmed', + }, + }; + haptic.trigger('selection'); analytics.trackEvent('ownership_confirmed'); try { - await lifecycle.setResult({ - type: 'documentOwnershipConfirmed', - }); + await lifecycle.setResult(result); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; analytics.trackEvent('proving_process_error', { error: message }); } navigate('/'); - }, [navigate, analytics, haptic, lifecycle]); + }, [analytics, haptic, lifecycle, navigate, request.userId, verificationId]); return ( { const navigate = useNavigate(); const location = useLocation(); - const { analytics, haptic } = useSelfClient(); + const { analytics, haptic, lifecycle } = useSelfClient(); const { countryCode = '', documentType = '' } = (location.state as { @@ -24,7 +24,6 @@ export const ProviderLaunchScreen: React.FC = () => { countryCode, documentType, }); - // TODO(WV-04): replace this placeholder with the actual provider launch and return handling flow. }, [analytics, countryCode, documentType]); return ( @@ -77,6 +76,11 @@ export const ProviderLaunchScreen: React.FC = () => { fullWidth onPress={() => { haptic.trigger('selection'); + analytics.trackEvent('provider_launch_back_pressed', { + countryCode, + documentType, + }); + lifecycle.dismiss({ reason: 'back' }); if (window.history.length > 1) { navigate(-1); } else { diff --git a/packages/webview-app/src/screens/proving/ProvingScreen.tsx b/packages/webview-app/src/screens/proving/ProvingScreen.tsx index df258fca6..d374df3a0 100644 --- a/packages/webview-app/src/screens/proving/ProvingScreen.tsx +++ b/packages/webview-app/src/screens/proving/ProvingScreen.tsx @@ -5,6 +5,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ProofRequestScreen, SelfLogo } from '@selfxyz/euclid-web'; +import type { VerificationResult } from '@selfxyz/webview-bridge'; import { useSelfClient } from '../../providers/SelfClientProvider'; import { useVerificationRequest } from '../../providers/VerificationRequestProvider'; @@ -20,8 +21,15 @@ function titleCaseDisclosure(disclosure: string): string { export const ProvingScreen: React.FC = () => { const navigate = useNavigate(); const { analytics, haptic, lifecycle } = useSelfClient(); - const { request, displayLabels, requestType, appName, appEndpoint, timestamp } = - useVerificationRequest(); + const { + request, + displayLabels, + requestType, + appName, + appEndpoint, + timestamp, + verificationId, + } = useVerificationRequest(); const [proving, setProving] = useState(false); const proofItems = useMemo(() => { @@ -34,31 +42,50 @@ export const ProvingScreen: React.FC = () => { }, [displayLabels, request.disclosures]); const onVerify = useCallback(async () => { + const result: VerificationResult = { + success: true, + userId: request.userId, + verificationId, + claims: { + resultType: requestType, + }, + }; + haptic.trigger('selection'); analytics.trackEvent('prove_verify_pressed'); setProving(true); try { - await lifecycle.setResult({ - type: requestType, - }); + await lifecycle.setResult(result); - navigate('/proving/result', { state: { success: true } }); + navigate('/proving/result', { + state: { success: true, result, resultSent: true }, + }); } catch (err) { const message = err instanceof Error ? err.message : 'Proving failed'; analytics.trackEvent('prove_verify_failed', { error: message }); navigate('/proving/result', { - state: { success: false, error: message }, + state: { success: false, error: message, result, resultSent: false }, }); } finally { setProving(false); } - }, [navigate, analytics, haptic, lifecycle, requestType]); + }, [ + analytics, + haptic, + lifecycle, + navigate, + request.userId, + requestType, + verificationId, + ]); const onCancel = useCallback(() => { haptic.trigger('selection'); + analytics.trackEvent('prove_verify_cancelled'); + lifecycle.dismiss({ reason: 'user_cancel' }); navigate('/'); - }, [navigate, haptic]); + }, [analytics, haptic, lifecycle, navigate]); return ( { const navigate = useNavigate(); const location = useLocation(); - const { haptic } = useSelfClient(); + const { analytics, haptic, lifecycle } = useSelfClient(); - const { success = true, error } = + const { success = true, error, result, resultSent = true } = (location.state as { success?: boolean; error?: string; + result?: VerificationResult; + resultSent?: boolean; }) || {}; - const onContinue = useCallback(() => { + const onContinue = useCallback(async () => { haptic.trigger('selection'); + if (!resultSent && result) { + try { + await lifecycle.setResult(result); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Failed to deliver result'; + analytics.trackEvent('verification_result_callback_failed', { + error: message, + }); + } + } else if (!resultSent) { + lifecycle.dismiss(); + } navigate('/'); - }, [navigate, haptic]); + }, [analytics, haptic, lifecycle, navigate, result, resultSent]); return ( { + describe('parseBrowserHostTargetOrigin', () => { + it('should reject wildcard target origin by default', () => { + expect(parseBrowserHostTargetOrigin('?targetOrigin=*')).toBeUndefined(); + }); + + it('should allow wildcard target origin only when explicitly enabled', () => { + expect( + parseBrowserHostTargetOrigin('?targetOrigin=*', { + allowWildcard: true, + }), + ).toBe('*'); + }); + + it('should normalize an https target origin', () => { + expect( + parseBrowserHostTargetOrigin( + '?targetOrigin=https://host.example/path?foo=bar', + ), + ).toBe('https://host.example'); + }); + + it('should allow localhost http target origins', () => { + expect( + parseBrowserHostTargetOrigin('?targetOrigin=http://localhost:3000/embed'), + ).toBe('http://localhost:3000'); + }); + + it('should reject non-local http target origins', () => { + expect( + parseBrowserHostTargetOrigin('?targetOrigin=http://host.example/embed'), + ).toBeUndefined(); + }); + + it('should reject invalid target origins', () => { + expect( + parseBrowserHostTargetOrigin('?targetOrigin=not-a-valid-url'), + ).toBeUndefined(); + }); + }); + + describe('parseVerificationRequestContext', () => { + it('should parse the expected request context fields', () => { + const context = parseVerificationRequestContext( + '?userId=user-1&scope=kyc&disclosures=full_name,dob&proofItems=Full%20Name,Date%20of%20Birth&appName=Partner&appEndpoint=https://partner.example/request×tamp=123456789&resultType=documentOwnershipConfirmed&verificationId=verif-1', + ); + + expect(context).toEqual({ + request: { + userId: 'user-1', + scope: 'kyc', + disclosures: ['full_name', 'dob'], + }, + displayLabels: ['Full Name', 'Date of Birth'], + appName: 'Partner', + appEndpoint: 'partner.example', + timestamp: 123456789, + requestType: 'documentOwnershipConfirmed', + verificationId: 'verif-1', + }); + }); + + it('should fall back when request type or endpoint are invalid', () => { + const context = parseVerificationRequestContext( + '?appEndpoint=http://evil.example/path&resultType=unexpected', + ); + + expect(context.requestType).toBe('proofRequested'); + expect(context.appEndpoint).toBe(''); + expect(context.verificationId).toBeUndefined(); + }); + }); +}); diff --git a/packages/webview-app/src/utils/verificationRequest.ts b/packages/webview-app/src/utils/verificationRequest.ts new file mode 100644 index 000000000..f84787bf1 --- /dev/null +++ b/packages/webview-app/src/utils/verificationRequest.ts @@ -0,0 +1,119 @@ +// 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 { VerificationRequest } from '@selfxyz/mobile-sdk-alpha'; + +export interface ParsedVerificationRequestContext { + request: VerificationRequest; + displayLabels: string[] | null; + appName: string; + appEndpoint: string; + timestamp: number; + requestType: string; + verificationId?: string; +} + +const ALLOWED_REQUEST_TYPES = new Set([ + 'proofRequested', + 'documentOwnershipConfirmed', +]); +const DEFAULT_REQUEST_TYPE = 'proofRequested'; + +interface TargetOriginOptions { + allowWildcard?: boolean; +} + +export function parseVerificationRequestContext( + search: string, +): ParsedVerificationRequestContext { + const params = new URLSearchParams(search); + const request: VerificationRequest = { + userId: params.get('userId') ?? undefined, + scope: params.get('scope') ?? undefined, + disclosures: parseDisclosures(params), + }; + + const queryTimestamp = params.get('timestamp'); + const parsedTimestamp = queryTimestamp ? Number(queryTimestamp) : Number.NaN; + + return { + request, + displayLabels: parseDisplayLabels(params), + appName: params.get('appName') ?? 'Verification', + appEndpoint: normalizeAppEndpoint(params.get('appEndpoint')), + timestamp: Number.isFinite(parsedTimestamp) ? parsedTimestamp : Date.now(), + requestType: normalizeRequestType(params.get('resultType')), + verificationId: params.get('verificationId') ?? undefined, + }; +} + +export function parseBrowserHostTargetOrigin( + search: string, + options: TargetOriginOptions = {}, +): string | undefined { + const params = new URLSearchParams(search); + return normalizeTargetOrigin(params.get('targetOrigin'), options); +} + +function normalizeRequestType(value: string | null | undefined): string { + if (!value) return DEFAULT_REQUEST_TYPE; + return ALLOWED_REQUEST_TYPES.has(value) ? value : DEFAULT_REQUEST_TYPE; +} + +function normalizeAppEndpoint(value: string | null | undefined): string { + if (!value) return ''; + try { + const endpoint = new URL(value); + const isHttps = endpoint.protocol === 'https:'; + const isLocalHttp = + endpoint.protocol === 'http:' && + (endpoint.hostname === 'localhost' || endpoint.hostname === '127.0.0.1'); + if (!isHttps && !isLocalHttp) return ''; + return endpoint.host; + } catch { + return ''; + } +} + +function normalizeTargetOrigin( + value: string | null | undefined, + options: TargetOriginOptions = {}, +): string | undefined { + if (!value) return undefined; + if (value === '*') { + return options.allowWildcard ? '*' : undefined; + } + + try { + const origin = new URL(value); + const isHttps = origin.protocol === 'https:'; + const isLocalHttp = + origin.protocol === 'http:' && + (origin.hostname === 'localhost' || origin.hostname === '127.0.0.1'); + if (!isHttps && !isLocalHttp) { + return undefined; + } + return origin.origin; + } catch { + return undefined; + } +} + +function splitCSV(value: string): string[] { + return value.split(',').map((s) => s.trim()).filter(Boolean); +} + +function parseDisclosures(params: URLSearchParams): string[] | undefined { + const raw = params.get('disclosures'); + if (!raw) return undefined; + const items = splitCSV(raw); + return items.length > 0 ? items : undefined; +} + +function parseDisplayLabels(params: URLSearchParams): string[] | null { + const raw = params.get('proofItems'); + if (!raw) return null; + const items = splitCSV(raw); + return items.length > 0 ? items : null; +} diff --git a/packages/webview-bridge/src/__tests__/adapters.test.ts b/packages/webview-bridge/src/__tests__/adapters.test.ts index a91b7f43f..2cff3b46a 100644 --- a/packages/webview-bridge/src/__tests__/adapters.test.ts +++ b/packages/webview-bridge/src/__tests__/adapters.test.ts @@ -11,7 +11,7 @@ const engineBrowserMocks = vi.hoisted(() => ({ createWebCryptoAdapter: vi.fn(), })); -vi.mock('@selfxyz/mobile-sdk-alpha/adapters/browser', () => engineBrowserMocks); +vi.mock('@selfxyz/mobile-sdk-alpha/browser', () => engineBrowserMocks); import { WebViewBridge } from '../bridge'; import { MockNativeBridge } from '../mock'; @@ -31,6 +31,8 @@ import { noOpHapticAdapter, } from '../adapters'; +import { createMockWindow } from './helpers/mockWindow'; + describe('Adapter integration tests', () => { let mock: MockNativeBridge; let bridge: WebViewBridge; @@ -56,6 +58,7 @@ describe('Adapter integration tests', () => { afterEach(() => { bridge.destroy(); + vi.unstubAllGlobals(); }); describe('NFC Scanner Adapter', () => { @@ -303,6 +306,43 @@ describe('Adapter integration tests', () => { expect(mock.messagesFor('lifecycle')[0].method).toBe('setResult'); }); + + it('should send browser-host results without creating a pending request', async () => { + bridge.destroy(); + + const hostTarget = { + postMessage: vi.fn(), + } as unknown as Window; + + vi.stubGlobal( + 'window', + createMockWindow({ + parent: hostTarget, + }), + ); + + bridge = new WebViewBridge({ + browserHost: { + targetOrigin: 'https://host.example', + }, + }); + + const lifecycle = bridgeLifecycleAdapter(bridge); + await lifecycle.setResult({ success: true, verificationId: 'v-1' }); + + expect(bridge.pendingCount).toBe(0); + expect(hostTarget.postMessage).toHaveBeenCalledWith( + { + type: 'self:result', + version: 1, + payload: { + success: true, + verificationId: 'v-1', + }, + }, + 'https://host.example', + ); + }); }); describe('Navigation Adapter', () => { diff --git a/packages/webview-bridge/src/__tests__/analytics-web.test.ts b/packages/webview-bridge/src/__tests__/analytics-web.test.ts index 045909672..5ba2f20ae 100644 --- a/packages/webview-bridge/src/__tests__/analytics-web.test.ts +++ b/packages/webview-bridge/src/__tests__/analytics-web.test.ts @@ -11,7 +11,7 @@ const engineBrowserMocks = vi.hoisted(() => ({ createWebCryptoAdapter: vi.fn(), })); -vi.mock('@selfxyz/mobile-sdk-alpha/adapters/browser', () => engineBrowserMocks); +vi.mock('@selfxyz/mobile-sdk-alpha/browser', () => engineBrowserMocks); import { consoleAnalyticsAdapter } from '../adapters'; diff --git a/packages/webview-bridge/src/__tests__/bridge.test.ts b/packages/webview-bridge/src/__tests__/bridge.test.ts index 06e9691fa..89f32e361 100644 --- a/packages/webview-bridge/src/__tests__/bridge.test.ts +++ b/packages/webview-bridge/src/__tests__/bridge.test.ts @@ -5,6 +5,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { WebViewBridge } from '../bridge'; import { MockNativeBridge } from '../mock'; +import type { SelfHostMessage } from '../types'; +import type { MockWindowWithListeners } from './helpers/mockWindow'; +import { createMockWindow } from './helpers/mockWindow'; describe('WebViewBridge', () => { let mock: MockNativeBridge; @@ -18,6 +21,7 @@ describe('WebViewBridge', () => { afterEach(() => { bridge.destroy(); + vi.unstubAllGlobals(); }); describe('request/response', () => { @@ -152,6 +156,71 @@ describe('WebViewBridge', () => { }); }); + describe('browser host transport', () => { + beforeEach(() => { + bridge.destroy(); + const hostTarget = { + postMessage: vi.fn(), + } as unknown as Window; + + vi.stubGlobal( + 'window', + createMockWindow({ + parent: hostTarget, + }), + ); + + bridge = new WebViewBridge({ + browserHost: { + targetOrigin: 'https://host.example', + }, + }); + }); + + it('should post lifecycle messages to the host', () => { + bridge.fire('lifecycle', 'ready', { verificationId: 'verif-1' }); + bridge.fire('lifecycle', 'dismiss', { reason: 'back' }); + + const hostTarget = window.parent; + expect(hostTarget.postMessage).toHaveBeenCalledTimes(2); + expect(hostTarget.postMessage).toHaveBeenNthCalledWith( + 1, + { + type: 'self:ready', + version: 1, + payload: { verificationId: 'verif-1' }, + } satisfies SelfHostMessage, + 'https://host.example', + ); + expect(hostTarget.postMessage).toHaveBeenNthCalledWith( + 2, + { + type: 'self:dismiss', + version: 1, + payload: { reason: 'back' }, + } satisfies SelfHostMessage, + 'https://host.example', + ); + }); + + it('should emit lifecycle cancel events from the host', () => { + const handler = vi.fn(); + bridge.on('lifecycle', 'cancel', handler); + + (window as unknown as MockWindowWithListeners).__dispatchMessage({ + origin: 'https://host.example', + source: window.parent, + data: { + type: 'self:cancel', + version: 1, + payload: { reason: 'user_cancel' }, + }, + } as MessageEvent); + + expect(handler).toHaveBeenCalledWith({ reason: 'user_cancel' }); + }); + }); + describe('message recording', () => { it('should record all sent messages', () => { mock.handleWith('secureStorage', 'get', { value: 'v' }); diff --git a/packages/webview-bridge/src/__tests__/documents-web.test.ts b/packages/webview-bridge/src/__tests__/documents-web.test.ts index 711f10f47..c214c9b30 100644 --- a/packages/webview-bridge/src/__tests__/documents-web.test.ts +++ b/packages/webview-bridge/src/__tests__/documents-web.test.ts @@ -11,7 +11,7 @@ const engineBrowserMocks = vi.hoisted(() => ({ createWebCryptoAdapter: vi.fn(), })); -vi.mock('@selfxyz/mobile-sdk-alpha/adapters/browser', () => engineBrowserMocks); +vi.mock('@selfxyz/mobile-sdk-alpha/browser', () => engineBrowserMocks); import { indexedDBDocumentsAdapter } from '../adapters'; import type { BridgeDocumentsAdapter } from '../adapters/documents'; diff --git a/packages/webview-bridge/src/__tests__/helpers/mockWindow.ts b/packages/webview-bridge/src/__tests__/helpers/mockWindow.ts new file mode 100644 index 000000000..746bb1fa9 --- /dev/null +++ b/packages/webview-bridge/src/__tests__/helpers/mockWindow.ts @@ -0,0 +1,41 @@ +// 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 { vi } from 'vitest'; + +export type MockWindowWithListeners = Window & { + __dispatchMessage(event: MessageEvent): void; +}; + +export function createMockWindow({ + parent, + opener = null, +}: { + parent: Window; + opener?: Window | null; +}): MockWindowWithListeners { + let messageListener: ((event: MessageEvent) => void) | undefined; + + return { + parent, + opener, + addEventListener: vi.fn( + (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'message' && typeof listener === 'function') { + messageListener = listener as (event: MessageEvent) => void; + } + }, + ), + removeEventListener: vi.fn( + (type: string, listener: EventListenerOrEventListenerObject) => { + if (type === 'message' && listener === messageListener) { + messageListener = undefined; + } + }, + ), + __dispatchMessage(event: MessageEvent) { + messageListener?.(event); + }, + } as unknown as MockWindowWithListeners; +} diff --git a/packages/webview-bridge/src/adapters/crypto.ts b/packages/webview-bridge/src/adapters/crypto.ts index 8bcd38e6c..930e33511 100644 --- a/packages/webview-bridge/src/adapters/crypto.ts +++ b/packages/webview-bridge/src/adapters/crypto.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: BUSL-1.1 // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. -import { createWebCryptoAdapter } from '@selfxyz/mobile-sdk-alpha/adapters/browser'; +import { createWebCryptoAdapter } from '@selfxyz/mobile-sdk-alpha/browser'; import type { WebViewBridge } from '../bridge'; diff --git a/packages/webview-bridge/src/adapters/index.ts b/packages/webview-bridge/src/adapters/index.ts index 7c2f6dcef..6f714c63b 100644 --- a/packages/webview-bridge/src/adapters/index.ts +++ b/packages/webview-bridge/src/adapters/index.ts @@ -6,8 +6,8 @@ import { createIndexedDBDocumentsAdapter, createNoOpHapticAdapter, createWebAnalyticsAdapter, -} from '@selfxyz/mobile-sdk-alpha/adapters/browser'; -import type { WebAnalyticsOptions } from '@selfxyz/mobile-sdk-alpha/adapters/browser'; +} from '@selfxyz/mobile-sdk-alpha/browser'; +import type { WebAnalyticsOptions } from '@selfxyz/mobile-sdk-alpha/browser'; import type { BridgeAnalyticsAdapter } from './analytics'; import type { BridgeDocumentsAdapter } from './documents'; diff --git a/packages/webview-bridge/src/adapters/lifecycle.ts b/packages/webview-bridge/src/adapters/lifecycle.ts index ca2d99de4..f558d9dcc 100644 --- a/packages/webview-bridge/src/adapters/lifecycle.ts +++ b/packages/webview-bridge/src/adapters/lifecycle.ts @@ -3,26 +3,35 @@ // NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE. import type { WebViewBridge } from '../bridge'; +import type { + VerificationDismissPayload, + VerificationResult, +} from '../types'; export interface BridgeLifecycleAdapter { - ready(): void; - dismiss(): void; - setResult(result: Record): Promise; + ready(payload?: Record): void; + dismiss(payload?: VerificationDismissPayload): void; + setResult(result: VerificationResult): Promise; } export function bridgeLifecycleAdapter( bridge: WebViewBridge, ): BridgeLifecycleAdapter { return { - ready(): void { - bridge.fire('lifecycle', 'ready', {}); + ready(payload: Record = {}): void { + bridge.fire('lifecycle', 'ready', payload); }, - dismiss(): void { - bridge.fire('lifecycle', 'dismiss', {}); + dismiss(payload: VerificationDismissPayload = {}): void { + bridge.fire('lifecycle', 'dismiss', payload); }, - async setResult(result: Record): Promise { + async setResult(result: VerificationResult): Promise { + if (bridge.usesBrowserHostTransport) { + bridge.fire('lifecycle', 'setResult', result); + return; + } + await bridge.request('lifecycle', 'setResult', result); }, }; diff --git a/packages/webview-bridge/src/bridge.ts b/packages/webview-bridge/src/bridge.ts index 22548d43c..aa39f7a39 100644 --- a/packages/webview-bridge/src/bridge.ts +++ b/packages/webview-bridge/src/bridge.ts @@ -12,6 +12,8 @@ import type { WebViewBridgeOptions, PendingRequest, EventHandler, + BrowserHostOptions, + SelfHostMessage, } from './types'; import { BRIDGE_PROTOCOL_VERSION, DEFAULT_TIMEOUT_MS } from './types'; import { parseMessage, isResponse, isEvent } from './schema'; @@ -32,22 +34,52 @@ declare global { } } +class BrowserHostTransport implements NativeTransport { + readonly kind = 'browser-host' as const; + + constructor( + public readonly target: Window, + public readonly targetOrigin: string, + ) {} + + postMessage(json: string): void { + const request = parseOutgoingRequest(json); + if (!request || request.domain !== 'lifecycle') { + return; + } + + const message = mapLifecycleRequestToHostMessage(request); + if (!message) { + return; + } + + this.target.postMessage(message, this.targetOrigin); + } +} + export class WebViewBridge { private readonly transport: NativeTransport | null; private readonly pending = new Map(); private readonly listeners = new Map>(); private readonly debug: boolean; + private readonly hostMessageListener?: (event: MessageEvent) => void; private destroyed = false; constructor(options: WebViewBridgeOptions = {}) { this.debug = options.debug ?? false; - this.transport = options.transport ?? this.detectTransport(); + this.transport = + options.transport ?? this.detectTransport(options.browserHost); // Register global bridge for native callbacks globalThis.SelfNativeBridge = this; + + if (this.transport instanceof BrowserHostTransport) { + this.hostMessageListener = this.createHostMessageListener(this.transport); + window.addEventListener('message', this.hostMessageListener); + } } - private detectTransport(): NativeTransport | null { + private detectTransport(browserHost?: BrowserHostOptions): NativeTransport | null { // Android (KMP) if (globalThis.SelfNativeAndroid?.postMessage) { return globalThis.SelfNativeAndroid; @@ -63,7 +95,36 @@ export class WebViewBridge { if (typeof window !== 'undefined' && window.ReactNativeWebView?.postMessage) { return window.ReactNativeWebView; } - return null; + + return this.detectBrowserHostTransport(browserHost); + } + + private detectBrowserHostTransport( + browserHost?: BrowserHostOptions, + ): NativeTransport | null { + if (typeof window === 'undefined') { + return null; + } + + const hostTarget = + window.parent !== window + ? window.parent + : window.opener && !window.opener.closed + ? window.opener + : null; + + if (!hostTarget) { + return null; + } + + if (!browserHost?.targetOrigin) { + this.log( + 'Browser host detected but no targetOrigin was configured; transport disabled', + ); + return null; + } + + return new BrowserHostTransport(hostTarget, browserHost.targetOrigin); } private log(...args: unknown[]): void { @@ -88,13 +149,45 @@ export class WebViewBridge { this.transport.postMessage(json); } + private createHostMessageListener( + transport: BrowserHostTransport, + ): (event: MessageEvent) => void { + return (event: MessageEvent) => { + if ( + transport.targetOrigin !== '*' && + event.origin !== transport.targetOrigin + ) { + return; + } + + if (event.source !== transport.target) { + return; + } + + const message = parseHostMessage(event.data); + if (!message || message.type !== 'self:cancel') { + return; + } + + this.dispatchEvent({ + type: 'event', + version: BRIDGE_PROTOCOL_VERSION, + id: uuidv4(), + domain: 'lifecycle', + event: 'cancel', + data: message.payload, + timestamp: Date.now(), + }); + }; + } + /** * Send a request and wait for a response. */ request( domain: BridgeDomain, method: string, - params: Record = {}, + params: object = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS, ): Promise { if (this.destroyed) { @@ -108,7 +201,7 @@ export class WebViewBridge { id, domain, method, - params, + params: params as Record, timestamp: Date.now(), }; @@ -138,7 +231,7 @@ export class WebViewBridge { fire( domain: BridgeDomain, method: string, - params: Record = {}, + params: object = {}, ): void { const id = uuidv4(); const message: BridgeRequest = { @@ -147,7 +240,7 @@ export class WebViewBridge { id, domain, method, - params, + params: params as Record, timestamp: Date.now(), }; this.send(message); @@ -261,6 +354,13 @@ export class WebViewBridge { return this.transport !== null; } + /** + * True when lifecycle traffic is being proxied to a browser host via postMessage. + */ + get usesBrowserHostTransport(): boolean { + return this.transport?.kind === 'browser-host'; + } + /** * Number of pending requests. */ @@ -274,6 +374,13 @@ export class WebViewBridge { destroy(): void { this.destroyed = true; + if ( + this.hostMessageListener && + typeof window !== 'undefined' + ) { + window.removeEventListener('message', this.hostMessageListener); + } + // Reject all pending requests for (const [id, pending] of this.pending) { clearTimeout(pending.timeout); @@ -290,3 +397,88 @@ export class WebViewBridge { } } } + +function parseOutgoingRequest(json: string): BridgeRequest | null { + try { + const candidate = JSON.parse(json) as Partial; + if ( + candidate.type !== 'request' || + candidate.version !== BRIDGE_PROTOCOL_VERSION || + typeof candidate.id !== 'string' || + typeof candidate.domain !== 'string' || + typeof candidate.method !== 'string' || + typeof candidate.timestamp !== 'number' || + typeof candidate.params !== 'object' || + candidate.params === null + ) { + return null; + } + + return candidate as BridgeRequest; + } catch { + return null; + } +} + +function mapLifecycleRequestToHostMessage( + request: BridgeRequest, +): SelfHostMessage | null { + switch (request.method) { + case 'ready': + return { + type: 'self:ready', + version: BRIDGE_PROTOCOL_VERSION, + payload: request.params, + }; + case 'setResult': + return { + type: 'self:result', + version: BRIDGE_PROTOCOL_VERSION, + payload: request.params, + }; + case 'dismiss': + return { + type: 'self:dismiss', + version: BRIDGE_PROTOCOL_VERSION, + payload: request.params, + }; + default: + return null; + } +} + +function parseHostMessage(data: unknown): SelfHostMessage | null { + let candidate: unknown = data; + + if (typeof candidate === 'string') { + try { + candidate = JSON.parse(candidate) as unknown; + } catch { + return null; + } + } + + if (typeof candidate !== 'object' || candidate === null) { + return null; + } + + const message = candidate as Partial; + if ( + message.version !== BRIDGE_PROTOCOL_VERSION || + (message.type !== 'self:ready' && + message.type !== 'self:result' && + message.type !== 'self:dismiss' && + message.type !== 'self:cancel') + ) { + return null; + } + + return { + type: message.type, + version: BRIDGE_PROTOCOL_VERSION, + payload: + typeof message.payload === 'object' && message.payload !== null + ? message.payload + : {}, + }; +} diff --git a/packages/webview-bridge/src/index.ts b/packages/webview-bridge/src/index.ts index 4689dd917..301c9b856 100644 --- a/packages/webview-bridge/src/index.ts +++ b/packages/webview-bridge/src/index.ts @@ -15,7 +15,12 @@ export type { NfcScanProgress, BiometricAuthParams, VerificationResult, + VerificationDismissReason, + VerificationDismissPayload, + SelfHostMessageType, + SelfHostMessage, NativeTransport, + BrowserHostOptions, WebViewBridgeOptions, EventHandler, NfcMethod, diff --git a/packages/webview-bridge/src/types.ts b/packages/webview-bridge/src/types.ts index e5dcf473b..f8dd41fff 100644 --- a/packages/webview-bridge/src/types.ts +++ b/packages/webview-bridge/src/types.ts @@ -115,14 +115,38 @@ export interface VerificationResult { error?: BridgeError; } +export type VerificationDismissReason = 'user_cancel' | 'back' | 'timeout'; + +export interface VerificationDismissPayload { + reason?: VerificationDismissReason; +} + +export type SelfHostMessageType = + | 'self:ready' + | 'self:result' + | 'self:dismiss' + | 'self:cancel'; + +export interface SelfHostMessage> { + type: SelfHostMessageType; + version: 1; + payload: TPayload; +} + // Transport interface export interface NativeTransport { postMessage(json: string): void; + kind?: 'native' | 'browser-host'; +} + +export interface BrowserHostOptions { + targetOrigin?: string; } export interface WebViewBridgeOptions { debug?: boolean; transport?: NativeTransport; + browserHost?: BrowserHostOptions; } // Pending request tracker diff --git a/specs/projects/sdk/OVERVIEW.md b/specs/projects/sdk/OVERVIEW.md index 539772dc7..39e85571f 100644 --- a/specs/projects/sdk/OVERVIEW.md +++ b/specs/projects/sdk/OVERVIEW.md @@ -30,6 +30,7 @@ On **March 11, 2026**, the active SDK delivery target changed: - [x] `WV-01` completed request-context sourcing for dynamic proof request items - [x] `WV-02` formalized the provider-agnostic KYC capture and handoff contract - [x] `WV-03` removed native-scan and NFC assumptions from the active WebView flow/docs +- [x] `WV-04` added the browser/native host callback contract for ready, result, dismiss, and cancel handling ### Paused @@ -65,8 +66,9 @@ On **March 11, 2026**, the active SDK delivery target changed: β–Ό β–Ό β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Host callback contract β”‚ β”‚ Third-party KYC provider β”‚ -β”‚ postMessage / URL / JS APIβ”‚ β”‚ web capture + attestation β”‚ -β”‚ receives final Self resultβ”‚ β”‚ e.g. Sumsub β”‚ +β”‚ native bridge or browser β”‚ β”‚ web capture + attestation β”‚ +β”‚ postMessage receives Self β”‚ β”‚ e.g. Sumsub β”‚ +β”‚ lifecycle/result messages β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` diff --git a/specs/projects/sdk/workstreams/webview/SPEC.md b/specs/projects/sdk/workstreams/webview/SPEC.md index 9de727f29..46c655170 100644 --- a/specs/projects/sdk/workstreams/webview/SPEC.md +++ b/specs/projects/sdk/workstreams/webview/SPEC.md @@ -44,12 +44,12 @@ On **March 11, 2026**, the active SDK scope changed to **WebView only, with no c ## Backlog -| ID | Title | Status | Priority | Depends On | Plan | Notes | -| ----- | ----------------------------------------------------------------------------------------------- | ------ | -------- | ---------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | -| WV-01 | Dynamic proof request items sourced from request context | Done | High | - | [plans/WV-01-dynamic-proof-request-items.md](./plans/WV-01-dynamic-proof-request-items.md) | Existing active follow-up | -| WV-02 | Define the KYC-provider contract for document capture, MRZ/liveness handoff, and result mapping | Done | High | - | [plans/WV-02-kyc-provider-contract.md](./plans/WV-02-kyc-provider-contract.md) | Provider-backed path replaces Self-owned native scan flow; active contract is now documented | -| WV-03 | Remove native NFC and native-scan assumptions from active WebView screens, copy, and docs | Done | High | WV-02 | [plans/WV-03-remove-native-scan-assumptions.md](./plans/WV-03-remove-native-scan-assumptions.md) | Active UX/docs now route to a provider placeholder instead of Self-managed scan screens | -| WV-04 | Define the host callback contract for launch, dismiss, and final result without native modules | Ready | Medium | WV-02 | - | Build on existing `SdkInitialConfig` and `VERIFICATION_COMPLETE` work; define only the WebView-host transport and embedding delta | +| ID | Title | Status | Priority | Depends On | Plan | Notes | +| ----- | ----------------------------------------------------------------------------------------------- | ------ | -------- | ---------- | ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| WV-01 | Dynamic proof request items sourced from request context | Done | High | - | [plans/WV-01-dynamic-proof-request-items.md](./plans/WV-01-dynamic-proof-request-items.md) | Existing active follow-up | +| WV-02 | Define the KYC-provider contract for document capture, MRZ/liveness handoff, and result mapping | Done | High | - | [plans/WV-02-kyc-provider-contract.md](./plans/WV-02-kyc-provider-contract.md) | Provider-backed path replaces Self-owned native scan flow; active contract is now documented | +| WV-03 | Remove native NFC and native-scan assumptions from active WebView screens, copy, and docs | Done | High | WV-02 | [plans/WV-03-remove-native-scan-assumptions.md](./plans/WV-03-remove-native-scan-assumptions.md) | Active UX/docs now route to a provider placeholder instead of Self-managed scan screens | +| WV-04 | Define the host callback contract for launch, dismiss, and final result without native modules | Done | Medium | WV-02 | [plans/WV-04-host-callback-contract.md](./plans/WV-04-host-callback-contract.md) | Browser host fallback now uses `postMessage` for iframe/popup embedding while native transports keep their current behavior | Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done` @@ -60,13 +60,14 @@ Allowed statuses: `Ready`, `In Progress`, `Blocked`, `Deferred`, `Done` | [plans/WV-01-dynamic-proof-request-items.md](./plans/WV-01-dynamic-proof-request-items.md) | WV-01 | Done | | [plans/WV-02-kyc-provider-contract.md](./plans/WV-02-kyc-provider-contract.md) | WV-02 | Done | | [plans/WV-03-remove-native-scan-assumptions.md](./plans/WV-03-remove-native-scan-assumptions.md) | WV-03 | Done | +| [plans/WV-04-host-callback-contract.md](./plans/WV-04-host-callback-contract.md) | WV-04 | Done | ## Completion Checklist - [x] Active backlog reflects the WebView-only client scope - [x] KYC-provider dependency is explicit wherever scan/KYC UX is described - [x] Active docs do not imply Self-managed NFC or native scanning for the current client path -- [ ] Host integration contract is clear without assuming custom native modules +- [x] Host integration contract is clear without assuming custom native modules ## Problem Statement @@ -218,6 +219,59 @@ Host-facing mapping rules: - If provider `success` unlocks the KYC proof path and the later Self proof flow completes, Self emits `VERIFICATION_COMPLETE { success: true, verificationId, userId }`. - If the verification session terminates after provider `partial`, `cancel`, or `error`, Self emits `VERIFICATION_COMPLETE { success: false, verificationId, userId, error }` using the normalized Self error code rather than the raw provider payload. +## Host Callback Contract + +`WV-04` defines the lightweight host contract for the active WebView/browser path. The goal is to let a parent page, popup opener, or mobile WebView wrapper launch the flow and receive lifecycle callbacks without any custom native module. + +### Transport Selection + +- Android KMP, iOS KMP, and RN WebView transports remain the first-choice bridge path and are unchanged. +- When no native transport is available, `packages/webview-bridge` falls back to a browser host transport. +- The browser transport posts to `window.parent` when the flow is embedded in an iframe. +- If there is no parent frame but the flow was opened as a popup, the browser transport posts to `window.opener`. +- Browser host transport requires a `targetOrigin`. In development, the app may default to `*`. In production, the host must supply an explicit `targetOrigin` value in the launch URL or equivalent configuration, and URL-supplied `*` is rejected. + +### Host Message Envelope + +All browser-host lifecycle callbacks use this envelope: + +```ts +type SelfHostMessage = { + type: 'self:ready' | 'self:result' | 'self:dismiss'; + version: 1; + payload: Record; +}; +``` + +Message semantics: + +- `self:ready`: sent once the Self client mounts. Payload is `{}` or `{ verificationId }`. +- `self:result`: sent on the terminal verification outcome. Payload is `VerificationResult` with `success`, optional `userId`, optional `verificationId`, and optional `error`. +- `self:dismiss`: sent when the user abandons or closes the flow. Payload is `{}` for generic teardown or `{ reason: 'user_cancel' | 'back' | 'timeout' }` when Self can classify the exit path. + +### Request Context + +Hosts may supply these browser-host fields in the launch URL: + +- `verificationId`: optional correlation key echoed in `self:ready` and terminal result payloads. +- `targetOrigin`: optional in development, required for production browser embedding. The app normalizes it to an origin string before using `postMessage`. + +The existing request fields (`userId`, `scope`, `disclosures`, `appName`, `appEndpoint`, `timestamp`) remain unchanged. + +### Lifecycle Wiring Rules + +- `lifecycle.ready()` fires from `SelfClientProvider` as soon as the flow mounts, including `verificationId` when present. +- `lifecycle.setResult()` must receive the full `VerificationResult` payload from terminal screens, not `{ type }`. +- In browser-host mode, `lifecycle.setResult()` is fire-and-forget and must not wait for a native response or hit the 30-second bridge timeout. +- Explicit cancel and back-out actions must call `lifecycle.dismiss()` so the host can tear down the iframe, popup, or WebView shell. +- Result screens use `dismiss` for teardown when the terminal result was already sent. If a terminal screen failed before sending the result, it may retry `setResult()` instead. + +### Host-Initiated Cancellation + +- Hosts may post `self:cancel` with `version: 1` to the embedded flow. +- The browser bridge normalizes that into a `lifecycle:cancel` event inside the app. +- The active WebView app handles that event by returning to the home route without emitting another host callback. + ## In Scope - WebView/browser UX for the active verification flow diff --git a/specs/projects/sdk/workstreams/webview/plans/WV-04-host-callback-contract.md b/specs/projects/sdk/workstreams/webview/plans/WV-04-host-callback-contract.md new file mode 100644 index 000000000..361bd937d --- /dev/null +++ b/specs/projects/sdk/workstreams/webview/plans/WV-04-host-callback-contract.md @@ -0,0 +1,99 @@ +# Host Callback Contract + +> Last updated: 2026-03-11 +> Status: Complete + +- Workstream: webview +- Backlog IDs: WV-04 +- Owner: WebView / Product Platform +- Branch: `justin/kmp-wv-04` +- PR: n/a + +### Why + +- The active WebView flow still assumes a native lifecycle responder for terminal callbacks, which breaks browser, iframe, and popup embedding. +- Hosts need a single contract for ready, result, and dismiss that works the same across native containers and pure browser embedding. +- `WV-04` must stay additive so existing RN/KMP transports keep working unchanged. + +### Scope + +- Add a browser host transport fallback in `packages/webview-bridge` that uses `window.parent.postMessage` for iframes and `window.opener.postMessage` for popups when no native transport exists. +- Define the host message envelope for `self:ready`, `self:result`, and `self:dismiss`, and make `lifecycle.setResult()` non-blocking in browser mode. +- Update `packages/webview-app` to send full `VerificationResult` payloads plus dismiss signals from terminal and cancel paths. +- Document the host callback contract in the active WebView spec and mark `WV-04` complete. + +### Out of Scope + +- Real KYC provider SDK integration or replacing the provider placeholder screen +- New native bridge handlers, native modules, or mobile SDK export changes +- Changes under `specs/projects/sdk/paused/**`, `packages/kmp-sdk/**`, or `packages/mobile-sdk-alpha/**` + +### Files to Modify + +- `specs/projects/sdk/workstreams/webview/plans/WV-04-host-callback-contract.md` +- `specs/projects/sdk/workstreams/webview/SPEC.md` +- `packages/webview-bridge/src/bridge.ts` +- `packages/webview-bridge/src/types.ts` +- `packages/webview-bridge/src/adapters/lifecycle.ts` +- `packages/webview-bridge/src/__tests__/bridge.test.ts` +- `packages/webview-bridge/src/__tests__/adapters.test.ts` +- `packages/webview-app/src/providers/VerificationRequestProvider.tsx` +- `packages/webview-app/src/providers/BridgeProvider.tsx` +- `packages/webview-app/src/screens/proving/ProvingScreen.tsx` +- `packages/webview-app/src/screens/proving/VerificationResultScreen.tsx` +- `packages/webview-app/src/screens/onboarding/ProviderLaunchScreen.tsx` +- `packages/webview-app/src/screens/onboarding/ConfirmIdentificationScreen.tsx` + +### Files Not to Modify + +- `packages/mobile-sdk-alpha/**` +- `packages/kmp-sdk/**` +- `specs/projects/sdk/paused/**` +- `noir/**` + +### Preconditions + +- `WV-02` remains the source of truth for provider launch/result normalization before any host callback. +- Existing native transports for Android KMP, iOS KMP, and RN WebView must remain the first-choice bridge path when present. + +### Implementation Notes + +- Keep the browser transport behind native transport detection. If a native transport exists, lifecycle behavior must stay unchanged. +- Use a small host envelope: + +```ts +type SelfHostMessage = { + type: 'self:ready' | 'self:result' | 'self:dismiss'; + version: 1; + payload: Record; +}; +``` + +- Read `verificationId` and optional `targetOrigin` from the launch URL so browser embedding can correlate sessions and limit `postMessage` origin in production. +- Let browser-mode `lifecycle.setResult()` resolve immediately after posting the message instead of waiting for a response that will never arrive. + +### Validation + +```bash +cd packages/webview-bridge && yarn build && yarn test +cd packages/webview-app && yarn build +cd packages/mobile-sdk-alpha && yarn test && yarn types +rg -n "lifecycle\\.(setResult|dismiss|ready)" packages/webview-app/src/ +``` + +### Definition of Done + +- [x] Browser host transport added and used only when no native transport exists +- [x] Host envelope type defined for `self:ready`, `self:result`, and `self:dismiss` +- [x] Browser-mode `setResult()` no longer times out +- [x] Terminal and cancel flows in `webview-app` call `lifecycle.setResult()` or `lifecycle.dismiss()` +- [x] WebView spec documents the host callback contract and marks `WV-04` done +- [x] Validation passes +- [x] Plan status updated +- [x] PR marked n/a for local execution handoff + +### Status Log + +- 2026-03-11: Started plan for WebView/browser host callbacks and terminal lifecycle wiring. +- 2026-03-11: Implemented browser host fallback transport, typed lifecycle payloads, screen wiring, and spec updates. Validation passed for `webview-bridge`, `webview-app`, and `mobile-sdk-alpha`. +- 2026-03-12: Tightened `targetOrigin` parsing so URL-supplied `*` is rejected outside development, closing a production browser-host origin bypass.