Merge pull request #1759 from selfxyz/release/staging-2026-02-16

Release to Staging v2.9.16 - 2026-02-16
This commit is contained in:
Justin Hernandez
2026-02-16 00:48:51 -08:00
committed by GitHub
9 changed files with 5068 additions and 38 deletions

View File

@@ -712,6 +712,7 @@ jobs:
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SELFXYZ_APP_TOKEN: ${{ steps.github-token.outputs.token }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SUMSUB_TEE_URL: ${{ secrets.SUMSUB_TEE_URL }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}
@@ -1170,6 +1171,7 @@ jobs:
NODE_OPTIONS: "--max-old-space-size=6144"
SEGMENT_KEY: ${{ secrets.SEGMENT_KEY }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SUMSUB_TEE_URL: ${{ secrets.SUMSUB_TEE_URL }}
TURNKEY_AUTH_PROXY_CONFIG_ID: ${{ secrets.TURNKEY_AUTH_PROXY_CONFIG_ID }}
TURNKEY_GOOGLE_CLIENT_ID: ${{ secrets.TURNKEY_GOOGLE_CLIENT_ID }}
TURNKEY_ORGANIZATION_ID: ${{ secrets.TURNKEY_ORGANIZATION_ID }}

View File

@@ -16,14 +16,8 @@ import * as Keychain from 'react-native-keychain';
* platform's secure hardware-backed Keystore (Android) or Keychain (iOS).
* This is a production-ready, secure approach for mobile.
*
* - WEB/OTHER: Falls back to an INSECURE `localStorage` implementation.
* This is for development and demo purposes ONLY.
*
* Security Limitations of the Web Implementation:
* 1. localStorage is NOT secure - accessible to any JavaScript on the same origin
* 2. Vulnerable to XSS attacks
* 3. No encryption at rest
* 4. Visible in browser DevTools
* - WEB/OTHER: Falls back to an in-memory store for development and demo
* purposes ONLY. Secrets are NOT persisted across page reloads.
*
* DO NOT use the web fallback in a production web environment with real user data.
*/
@@ -54,38 +48,32 @@ export const generateSecret = (): string => {
.join('');
};
// --- Web (Insecure) Implementation ---
// --- Web (In-Memory) Implementation ---
// Uses an in-memory store instead of localStorage to avoid clear-text storage.
// Secrets do not persist across page reloads; this is acceptable for a demo app.
const memoryStore = new Map<string, string>();
const getOrCreateSecretWeb = async (): Promise<string> => {
try {
// Try to load existing secret
const existingSecret = localStorage.getItem(SECRET_STORAGE_KEY);
const metadataStr = localStorage.getItem(SECRET_VERSION_KEY);
const existingSecret = memoryStore.get(SECRET_STORAGE_KEY);
if (existingSecret && metadataStr) {
// Update last accessed time
const metadata: SecretMetadata = JSON.parse(metadataStr);
metadata.lastAccessed = new Date().toISOString();
localStorage.setItem(SECRET_VERSION_KEY, JSON.stringify(metadata));
console.log('[SecureStorage] Loaded existing secret from localStorage');
return existingSecret; // lgtm[js/clear-text-storage-of-sensitive-data]
if (existingSecret) {
console.log('[SecureStorage] Loaded existing secret from memory');
return existingSecret;
}
// Generate new secret (intentionally stored in localStorage for demo purposes only)
const newSecret = generateSecret(); // lgtm[js/clear-text-storage-of-sensitive-data]
const newSecret = generateSecret();
const metadata: SecretMetadata = {
version: CURRENT_VERSION,
createdAt: new Date().toISOString(),
lastAccessed: new Date().toISOString(),
};
// Store secret and metadata
localStorage.setItem(SECRET_STORAGE_KEY, newSecret);
localStorage.setItem(SECRET_VERSION_KEY, JSON.stringify(metadata));
memoryStore.set(SECRET_STORAGE_KEY, newSecret);
memoryStore.set(SECRET_VERSION_KEY, JSON.stringify(metadata));
console.log('[SecureStorage] Generated new secret for demo app');
console.warn('[SecureStorage] ⚠️ SECRET STORED IN INSECURE localStorage - DEMO ONLY ⚠️');
console.log('[SecureStorage] Generated new secret for demo app (in-memory only)');
return newSecret;
} catch (error) {
@@ -95,11 +83,11 @@ const getOrCreateSecretWeb = async (): Promise<string> => {
};
const hasSecretWeb = (): boolean => {
return !!localStorage.getItem(SECRET_STORAGE_KEY);
return memoryStore.has(SECRET_STORAGE_KEY);
};
const getSecretMetadataWeb = (): SecretMetadata | null => {
const metadataStr = localStorage.getItem(SECRET_VERSION_KEY);
const metadataStr = memoryStore.get(SECRET_VERSION_KEY);
if (!metadataStr) return null;
try {
@@ -110,9 +98,9 @@ const getSecretMetadataWeb = (): SecretMetadata | null => {
};
const clearSecretWeb = (): void => {
localStorage.removeItem(SECRET_STORAGE_KEY);
localStorage.removeItem(SECRET_VERSION_KEY);
console.log('[SecureStorage] Secret cleared from localStorage');
memoryStore.delete(SECRET_STORAGE_KEY);
memoryStore.delete(SECRET_VERSION_KEY);
console.log('[SecureStorage] Secret cleared from memory');
};
// --- Native (Secure) Implementation ---
@@ -173,7 +161,7 @@ const clearSecretNative = async (): Promise<void> => {
/**
* Get or create a secret for the demo app.
* Uses Keychain on native and localStorage on web.
* Uses Keychain on native and in-memory storage on web.
*
* @returns A Promise resolving to the secret as a hex string (64 characters).
*/
@@ -186,7 +174,7 @@ export const getOrCreateSecret = async (): Promise<string> => {
/**
* Check if a secret exists in storage.
* Uses Keychain on native and localStorage on web.
* Uses Keychain on native and in-memory storage on web.
*
* @returns A Promise resolving to true if a secret exists, false otherwise.
*/
@@ -216,7 +204,7 @@ export const getSecretMetadata = async (): Promise<SecretMetadata | null> => {
/**
* Clear the stored secret (for testing/reset).
* ⚠️ This will permanently delete the user's identity commitment!
* Uses Keychain on native and localStorage on web.
* Uses Keychain on native and in-memory storage on web.
*
* @returns A Promise that resolves when the secret has been cleared.
*/

1532
specs/SPEC-COMMON-LIB.md Normal file

File diff suppressed because it is too large Load Diff

1129
specs/SPEC-IOS-HANDLERS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,20 @@
# Person 2: KMP SDK / Native Handlers — Implementation Spec
## Current Status
| Chunk | Description | Status |
|-------|-------------|--------|
| 2A | KMP Setup + Bridge Protocol | ✅ Complete |
| 2B | Android WebView Host | ✅ Complete |
| 2C | Android Native Handlers | ✅ Complete (all 9) |
| 2D | iOS WebView Host + cinterop | ⚠️ Partial (cinterop blocked by Xcode SDK compatibility issues, stubs in place) |
| 2E | iOS Native Handlers | ❌ Not Done (all 9 handlers are stubs throwing `NotImplementedError`) |
| 2F | SDK Public API + Test App | ⚠️ Partial (Android works end-to-end, iOS uses Swift workarounds via factory pattern in test app) |
> **Note:** Remaining iOS handler work has moved to [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — uses a Swift wrapper pattern instead of cinterop. The native proving client (for headless SDK use without WebView) is specified in [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md). A MiniPay sample app demonstrating the headless flow is in [SPEC-MINIPAY-SAMPLE.md](./SPEC-MINIPAY-SAMPLE.md).
---
## Overview
You are building the **native side** of the Self Mobile SDK. This means:

View File

@@ -0,0 +1,623 @@
# MiniPay Sample App — Headless KMP SDK Demo
## Overview
A native Compose Multiplatform app demonstrating the **headless SDK flow** — no WebView. This is the reference implementation for integrating the Self KMP SDK into a crypto wallet (MiniPay) or any app that needs native proof generation.
The app scans a passport, generates a zero-knowledge proof using the native `ProvingClient`, and displays the result — all without launching a WebView.
**Prerequisites**:
- [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) — Bridge protocol, common models
- [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — iOS native handlers (NFC, Camera via Swift providers)
- [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md) — Native proving client (`ProvingClient`)
---
## Architecture
```
┌─────────────────────────────────────────────┐
│ MiniPay Sample App (Compose Multiplatform) │
├─────────────────────────────────────────────┤
│ Screens: │
│ HomeScreen → QrScanScreen → │
│ DocumentScanScreen → ProvingScreen → │
│ ResultScreen │
├─────────────────────────────────────────────┤
│ ViewModel Layer: │
│ MainViewModel (navigation + state) │
├─────────────────────────────────────────────┤
│ KMP SDK (Native APIs — no WebView): │
│ NFC scan → ProvingClient.prove() → result │
│ SecureStorage for secrets │
│ Crypto for key management │
└─────────────────────────────────────────────┘
```
### Key Difference from Test App
| | Test App (`kmp-test-app`) | MiniPay Sample |
|---|---|---|
| Proof generation | WebView (Person 1's Vite bundle) | Native `ProvingClient` |
| UI | Compose Multiplatform + WebView overlay | Pure Compose Multiplatform |
| SDK entry point | `SelfSdk.launch()` → WebView | `ProvingClient.prove()` → native |
| Use case | Full Self verification flow | Wallet integration demo |
---
## Directory Structure
```
packages/kmp-minipay-sample/
build.gradle.kts
composeApp/
build.gradle.kts
src/
commonMain/kotlin/xyz/self/minipay/
App.kt # Root composable + navigation
MainViewModel.kt # App state management
screens/
HomeScreen.kt # Landing screen with "Verify" button
QrScanScreen.kt # QR code scanner
DocumentScanScreen.kt # MRZ camera + NFC passport scan
ProvingScreen.kt # Proving progress UI
ResultScreen.kt # Success/failure display
models/
AppState.kt # Navigation state
VerificationRequest.kt # Parsed QR code data
theme/
Theme.kt # MiniPay-style theming
androidMain/kotlin/xyz/self/minipay/
MainActivity.kt # Android entry point
QrScannerAndroid.kt # CameraX QR scanner (expect/actual)
iosMain/kotlin/xyz/self/minipay/
MainViewController.kt # iOS entry point
QrScannerIos.kt # AVFoundation QR scanner (expect/actual)
androidApp/
build.gradle.kts
src/main/
AndroidManifest.xml
java/.../MainApplication.kt
iosApp/
iosApp/
iOSApp.swift # SwiftUI wrapper
ContentView.swift
iosApp.xcodeproj/
```
---
## Screens
### 1. HomeScreen
**Purpose**: Landing page with verification status and "Verify Identity" button.
**UI**:
- App title: "MiniPay" with Self branding
- Status card: Shows current verification state (unverified / verified / expired)
- "Verify Identity" button → navigates to QR scanner
- Previously verified proof summary (if any)
**State**:
```kotlin
data class HomeState(
val isVerified: Boolean = false,
val lastProofDate: String? = null,
val verifiedClaims: Map<String, String>? = null,
)
```
### 2. QrScanScreen
**Purpose**: Scan a QR code containing a verification request URL.
**QR Code Format**: The QR code encodes a URL with verification parameters:
```
https://self.xyz/verify?scope=<scope>&endpoint=<endpoint>&endpointType=<type>
&chainId=<id>&userId=<userId>&disclosures=<json>&version=<v>
&userDefinedData=<data>&selfDefinedData=<data>
```
**UI**:
- Full-screen camera preview with QR code overlay
- "Scan a verification QR code" instruction text
- Cancel button to return to home
**Platform Implementation**:
- Android: CameraX + ML Kit `BarcodeScanning`
- iOS: AVFoundation `AVCaptureMetadataOutput` with `.qr` metadata type
```kotlin
// commonMain — expect declaration
expect class QrScanner {
fun startScanning(onQrDetected: (String) -> Unit, onError: (String) -> Unit)
fun stopScanning()
}
```
**QR Parsing**:
```kotlin
fun parseVerificationUrl(url: String): ProvingRequest {
val uri = Url(url)
return ProvingRequest(
circuitType = CircuitType.DISCLOSE, // QR codes are always disclosure requests
scope = uri.parameters["scope"],
endpoint = uri.parameters["endpoint"],
endpointType = EndpointType.valueOf(uri.parameters["endpointType"]?.uppercase() ?: "CELO"),
chainId = uri.parameters["chainId"]?.toIntOrNull(),
userId = uri.parameters["userId"],
disclosures = parseDisclosures(uri.parameters["disclosures"]),
version = uri.parameters["version"]?.toIntOrNull() ?: 1,
userDefinedData = uri.parameters["userDefinedData"] ?: "",
selfDefinedData = uri.parameters["selfDefinedData"] ?: "",
)
}
```
### 3. DocumentScanScreen
**Purpose**: Two-phase document scanning — MRZ camera detection, then NFC passport read.
**Phase 1 — MRZ Camera Scan**:
- Camera preview focused on passport MRZ zone
- Visual overlay showing the MRZ detection region
- Progress states: NO_TEXT → TEXT_DETECTED → ONE_MRZ_LINE → TWO_MRZ_LINES
- Auto-transitions to Phase 2 when MRZ is detected
**Phase 2 — NFC Passport Scan**:
- Instruction: "Hold your phone against the passport"
- Progress animation showing NFC scan states (07):
- 0: "Hold your phone near the passport"
- 1: "Passport detected..."
- 2: "Authenticating..."
- 3: "Reading passport data..."
- 4: "Reading security data..."
- 5: "Verifying passport..."
- 6: "Processing..."
- 7: "Scan complete!"
- Progress bar reflecting percentage
- Cancel button
**SDK Integration**:
```kotlin
// Phase 1: MRZ detection via Camera bridge handler
val mrzResult = sdk.cameraMrz.scanMrz()
val mrzData = Json.decodeFromString<MrzData>(mrzResult)
// Phase 2: NFC scan using MRZ data for BAC/PACE authentication
val scanResult = sdk.nfc.scanPassport(
passportNumber = mrzData.documentNumber,
dateOfBirth = mrzData.dateOfBirth,
dateOfExpiry = mrzData.dateOfExpiry,
)
```
On Android, the NFC handler uses JMRTD directly. On iOS, it calls through the Swift `NfcProvider``NfcPassportHelper`.
### 4. ProvingScreen
**Purpose**: Show proving progress as the native `ProvingClient` runs.
**UI**:
- Stepper/progress indicator showing current state
- Each state maps to a user-friendly label:
- `FetchingData` → "Fetching verification data..."
- `ValidatingDocument` → "Validating your document..."
- `ConnectingTee` → "Connecting to secure enclave..."
- `Proving` → "Generating proof..." (with spinner)
- `PostProving` → "Finalizing..."
- Animated progress bar
- Cancel button (cancels the coroutine)
**SDK Integration**:
```kotlin
val provingClient = ProvingClient(ProvingConfig(
environment = if (request.endpointType == EndpointType.STAGING_CELO)
Environment.STG else Environment.PROD,
))
// Load secret from secure storage
val secret = sdk.secureStorage.get("user_secret")
?: throw IllegalStateException("No user secret found")
// Load parsed document from previous scan
val document = parsePassportScanResult(scanResult)
// Run proving with state callbacks
try {
val result = provingClient.prove(
document = document,
request = request,
secret = secret,
onStateChange = { state ->
// Update UI with current state
viewModel.updateProvingState(state)
},
)
viewModel.navigateToResult(result)
} catch (e: ProvingException) {
viewModel.navigateToResult(ProofResult(success = false), error = e)
}
```
### 5. ResultScreen
**Purpose**: Display proof result — success or failure.
**Success UI**:
- Checkmark animation
- "Identity Verified" title
- Disclosed claims list (name, nationality, age, etc. based on disclosure flags)
- Proof UUID for reference
- "Done" button → return to HomeScreen
**Failure UI**:
- Error icon
- Error code and human-readable message
- "Try Again" button → return to appropriate screen
- Error-specific guidance:
- `DOCUMENT_NOT_SUPPORTED` → "Your passport type is not yet supported"
- `NOT_REGISTERED` → "Please register your passport first"
- `TEE_CONNECT_FAILED` → "Connection failed. Check your internet and try again"
- `PROVE_FAILED` → "Proof generation failed. Please try again"
---
## ViewModel
```kotlin
class MainViewModel {
// Navigation state
var currentScreen by mutableStateOf<Screen>(Screen.Home)
// Data passed between screens
var verificationRequest: ProvingRequest? = null
var mrzData: MrzData? = null
var passportScanResult: JsonElement? = null
var provingState: ProvingState? = null
var proofResult: ProofResult? = null
var error: ProvingException? = null
// Navigation
fun navigateToQrScan() { currentScreen = Screen.QrScan }
fun onQrScanned(url: String) {
verificationRequest = parseVerificationUrl(url)
currentScreen = Screen.DocumentScan
}
fun onMrzDetected(data: MrzData) { mrzData = data }
fun onPassportScanned(result: JsonElement) {
passportScanResult = result
currentScreen = Screen.Proving
}
fun updateProvingState(state: ProvingState) { provingState = state }
fun navigateToResult(result: ProofResult, error: ProvingException? = null) {
proofResult = result
this.error = error
currentScreen = Screen.Result
}
fun returnToHome() {
currentScreen = Screen.Home
// Clear transient state
}
}
sealed class Screen {
data object Home : Screen()
data object QrScan : Screen()
data object DocumentScan : Screen()
data object Proving : Screen()
data object Result : Screen()
}
```
---
## Registration Flow
Before disclosure, the user must register their passport. The sample app detects this automatically:
1. User scans QR code (disclosure request)
2. App loads passport data from secure storage (or scans if first time)
3. `ProvingClient.prove()` with `CircuitType.DISCLOSE`
4. `DocumentValidator` detects user is NOT registered
5. State machine throws `ProvingException("NOT_REGISTERED")`
6. App catches this and shows: "You need to register first. Register now?"
7. If yes: runs `ProvingClient.prove()` with `CircuitType.REGISTER` (and DSC if needed)
8. On success: re-runs the original disclosure request
```kotlin
try {
val result = provingClient.prove(document, request, secret, onStateChange)
// Success — show result
} catch (e: ProvingException) {
if (e.code == "NOT_REGISTERED") {
// Auto-register flow
val registerRequest = ProvingRequest(circuitType = CircuitType.REGISTER)
provingClient.prove(document, registerRequest, secret, onStateChange)
// Retry original disclosure
val result = provingClient.prove(document, request, secret, onStateChange)
// Show result
} else {
// Show error
}
}
```
---
## Build Configuration
### `packages/kmp-minipay-sample/build.gradle.kts`
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
```
### `composeApp/build.gradle.kts`
```kotlin
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget {
compilations.all { kotlinOptions { jvmTarget = "17" } }
}
iosArm64()
iosSimulatorArm64()
listOf(iosArm64(), iosSimulatorArm64()).forEach {
it.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
// Compose
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
// Navigation
implementation(libs.navigation.compose)
// KMP SDK (local project dependency)
implementation(project(":kmp-sdk:shared"))
// Serialization
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
}
val androidMain by getting {
dependencies {
implementation(libs.compose.ui.tooling.preview)
implementation(libs.androidx.activity.compose)
// QR scanning
implementation("com.google.mlkit:barcode-scanning:17.2.0")
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
}
}
}
}
android {
namespace = "xyz.self.minipay"
compileSdk = 35
defaultConfig {
applicationId = "xyz.self.minipay"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
}
}
```
---
## Chunking Guide
### Chunk 5A: Project Setup + Navigation Shell
**Goal**: Create the Compose Multiplatform project with navigation between empty screens.
**Steps**:
1. Create `packages/kmp-minipay-sample/` directory structure
2. Configure `build.gradle.kts` with Compose Multiplatform + KMP SDK dependency
3. Implement `App.kt` with navigation controller
4. Implement `MainViewModel.kt` with screen state
5. Create placeholder screens (HomeScreen, QrScanScreen, DocumentScanScreen, ProvingScreen, ResultScreen)
6. Android: `MainActivity.kt`, `AndroidManifest.xml` (NFC + Camera permissions)
7. iOS: `MainViewController.kt`, `iOSApp.swift`, `ContentView.swift`
8. Validate: App builds and launches with navigation between placeholder screens
### Chunk 5B: QR Scanner
**Goal**: Camera-based QR code scanning with URL parsing.
**Steps**:
1. Define `expect class QrScanner` in commonMain
2. Implement Android actual: CameraX + ML Kit BarcodeScanning
3. Implement iOS actual: AVFoundation metadata output (via Swift provider if needed)
4. Implement `QrScanScreen.kt` — camera preview with QR overlay
5. Implement URL parser: `parseVerificationUrl(url) → ProvingRequest`
6. Wire QR detection → ViewModel → navigate to DocumentScan
7. Validate: Scan a test QR code, verify parsed parameters
### Chunk 5C: Document Scanner (MRZ + NFC)
**Goal**: Passport scanning using SDK native APIs.
**Steps**:
1. Implement `DocumentScanScreen.kt` with two-phase UI
2. Phase 1: Camera MRZ detection — use SDK's `CameraMrzBridgeHandler` via native API
3. Phase 2: NFC passport scan — use SDK's `NfcBridgeHandler` via native API
4. Progress UI: Map scan states to visual indicators
5. Parse `PassportScanResult` into `IDDocument` model for proving
6. Validate: Full MRZ detect → NFC scan on physical device
**Note**: On Android, the NFC scan uses the SDK's `NfcBridgeHandler` directly (JMRTD). On iOS, it calls through the Swift provider chain. The sample app calls the handler APIs directly rather than going through the WebView bridge — this is the "headless" pattern.
### Chunk 5D: Proving Screen + Integration
**Goal**: Wire up `ProvingClient` and show progress.
**Steps**:
1. Implement `ProvingScreen.kt` with state-based progress UI
2. Instantiate `ProvingClient` with config from parsed QR
3. Convert `PassportScanResult``IDDocument` (passport data model)
4. Load user secret from secure storage (or generate if first time)
5. Call `provingClient.prove()` with `onStateChange` callback
6. Handle success → navigate to ResultScreen
7. Handle registration requirement → auto-register flow
8. Handle errors → navigate to ResultScreen with error
9. Validate: Full end-to-end flow against staging TEE
### Chunk 5E: Result Screen + Polish
**Goal**: Display results and polish the app.
**Steps**:
1. Implement `ResultScreen.kt` with success/failure UI
2. Display disclosed claims based on verification request
3. Persist verification status for HomeScreen
4. Theme: MiniPay-style colors and typography
5. Error handling: User-friendly messages for each error code
6. iOS: Wire up `SelfSdkSwift.configure()` in `iOSApp.swift`
7. Validate: Full flow on both platforms, error cases handled
---
## Key SDK APIs Used
The sample app demonstrates calling SDK APIs directly (no WebView bridge):
```kotlin
// 1. MRZ Camera Scan
val cameraMrzProvider = SdkProviderRegistry.cameraMrz // iOS
// or direct call to CameraMrzBridgeHandler // Android
// 2. NFC Passport Scan
val nfcProvider = SdkProviderRegistry.nfc // iOS
// or direct call to NfcBridgeHandler // Android
// 3. Secure Storage (for user secret)
val storageProvider = SdkProviderRegistry.secureStorage // iOS
// or direct call to SecureStorageBridgeHandler // Android
// 4. Native Proving
val provingClient = ProvingClient(config)
val result = provingClient.prove(document, request, secret, onStateChange)
```
For a cleaner API, the SDK should expose a unified interface in `commonMain`:
```kotlin
// Future enhancement: SelfSdk headless API
class SelfSdk {
val nfc: NfcApi // Wraps NfcBridgeHandler / NfcProvider
val camera: CameraApi // Wraps CameraMrzBridgeHandler / CameraMrzProvider
val storage: StorageApi // Wraps SecureStorageBridgeHandler / SecureStorageProvider
val proving: ProvingClient
}
```
---
## Testing
### Unit Tests (`commonTest/`)
**QR URL Parsing** (~8 tests):
- `parseVerificationUrl()` extracts all parameters correctly
- Missing optional parameters use defaults
- Malformed URL throws with clear error
- URL with encoded characters decodes correctly
- `disclosures` JSON parameter parses into `Disclosures` object
**ViewModel Navigation** (~6 tests):
- Initial screen is `Home`
- `onQrScanned()` parses URL and navigates to `DocumentScan`
- `onPassportScanned()` navigates to `Proving`
- `navigateToResult()` stores result and navigates to `Result`
- `returnToHome()` clears transient state
### Device Tests (manual, per-chunk)
**Chunk 5A — Navigation Shell**:
- App launches on Android emulator and iOS simulator
- All 5 screens reachable via navigation
- Back navigation works correctly
**Chunk 5B — QR Scanner**:
- Camera permission prompt appears on first launch
- Camera preview renders full-screen
- Scanning a test QR code extracts correct URL
- Scanning a non-URL QR code shows error gracefully
- Cancel returns to home
**Chunk 5C — Document Scanner**:
- MRZ camera phase: Progress states advance as passport is positioned (0 → 1 → 2 → 3)
- MRZ camera phase: Auto-transitions to NFC phase when MRZ detected
- NFC phase: Progress states advance during passport scan (0 → 7)
- NFC phase: Cancel during scan returns to previous screen without crash
- NFC phase: Bad MRZ data (wrong dates) produces clear error
- Full scan produces valid `PassportScanResult` JSON
**Chunk 5D — Proving**:
- State callbacks fire in order: FetchingData → ValidatingDocument → ConnectingTee → Proving → PostProving → Completed
- UI updates for each state transition (progress indicator advances)
- Cancel during proving cancels the coroutine cleanly
- NOT_REGISTERED error triggers auto-register flow
- Other errors navigate to result screen with error details
- Full end-to-end against staging TEE succeeds with mock passport
**Chunk 5E — Result + Polish**:
- Success screen shows disclosed claims matching the request's disclosures
- Failure screen shows error code and human-readable message
- "Try Again" navigates back to appropriate screen
- "Done" returns to home, home shows verified status
- Both platforms: identical behavior for same QR code + passport combo
### End-to-End Acceptance Test
1. Launch app → Home screen shows "Unverified"
2. Tap "Verify Identity" → QR scanner opens
3. Scan test QR code → navigates to document scanner
4. Position passport → MRZ detected → "Hold phone near passport"
5. Tap passport → NFC scan completes
6. Proving screen shows progress through all states
7. Result screen shows "Identity Verified" with correct claims
8. Return to Home → shows "Verified" with proof date
Run on: Android physical device + iOS physical device.
---
## Dependencies
- **SPEC-KMP-SDK.md**: NFC handler (Android), Camera handler (Android), SecureStorage handler
- **SPEC-IOS-HANDLERS.md**: NFC provider (iOS), Camera provider (iOS), SecureStorage provider (iOS)
- **SPEC-PROVING-CLIENT.md**: `ProvingClient` API — the core of this app
- **SPEC-COMMON-LIB.md**: Passport data parsing, commitment generation (used by ProvingClient)

View File

@@ -49,9 +49,13 @@ MiniPay expects a single Kotlin Multiplatform interface that works on both iOS a
| **Person 1** | UI + WebView + Bridge JS | `@selfxyz/webview-bridge` (npm), `@selfxyz/webview-app` (Vite bundle) |
| **Person 2** | KMP SDK + Native Handlers + Test App | `packages/kmp-sdk/` → AAR + XCFramework, test app |
Each person has their own detailed spec:
- [SPEC-PERSON1-UI.md](./SPEC-PERSON1-UI.md) — UI / WebView / Bridge JS
- [SPEC-PERSON2-KMP.md](./SPEC-PERSON2-KMP.md) — KMP SDK / Native Handlers
Detailed specs:
- [SPEC-WEBVIEW-UI.md](./SPEC-WEBVIEW-UI.md) — UI / WebView / Bridge JS
- [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) — KMP SDK / Native Handlers (Android complete, iOS stubs)
- [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — iOS handlers via Swift wrapper pattern
- [SPEC-COMMON-LIB.md](./SPEC-COMMON-LIB.md) — Pure Kotlin common library (Poseidon, trees, parsing)
- [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md) — Native proving client (headless, no WebView)
- [SPEC-MINIPAY-SAMPLE.md](./SPEC-MINIPAY-SAMPLE.md) — MiniPay sample app (headless demo)
---

1737
specs/SPEC-PROVING-CLIENT.md Normal file

File diff suppressed because it is too large Load Diff