Files
self/specs/SPEC-OVERVIEW.md
Justin Hernandez 466fd5d8e7 update kmp specs (#1757)
* save new specs

* rename specs
2026-02-16 00:42:06 -08:00

13 KiB

Self Mobile SDK — Architecture & Implementation Spec

Why

MiniPay (Celo) needs to embed Self's identity verification in their KMP app. Today the wallet is a monolithic React Native app. We're rebuilding it as:

  • A React WebView (UI layer) — published as npm packages
  • A single KMP module (native layer) — hosts the WebView and provides NFC, biometrics, storage, camera, crypto via a bridge

MiniPay expects a single Kotlin Multiplatform interface that works on both iOS and Android.


Architecture Overview

┌──────────────────────────────────────────────┐
│  Host App (MiniPay, Self Wallet, etc.)       │
│  ↓ calls SelfSdk.launch(request, callback)   │
├──────────────────────────────────────────────┤
│  KMP SDK (single Kotlin module)              │
│  shared/                                      │
│    commonMain/  Bridge protocol, MessageRouter│
│                 SDK public API, data models   │
│    androidMain/ WebView host (Android WebView)│
│                 NFC (JMRTD), Biometrics,      │
│                 SecureStorage, Camera, Crypto  │
│    iosMain/     WebView host (WKWebView)      │
│                 NFC (CoreNFC), Biometrics,     │
│                 SecureStorage, Camera, Crypto  │
├──────────────────────────────────────────────┤
│  Bridge Layer (postMessage JSON protocol)     │
├──────────────────────────────────────────────┤
│  WebView (bundled inside SDK artifact)        │
│    @selfxyz/webview-bridge → npm (protocol)   │
│    @selfxyz/webview-app    → Vite bundle      │
│    @selfxyz/mobile-sdk-alpha → core logic     │
│    Vite build → single HTML + JS bundle       │
└──────────────────────────────────────────────┘

Key principle: No separate android-sdk/ or ios-sdk/ modules. Everything is in shared/ using KMP expect/actual. MiniPay gets one dependency that works on both platforms.


Workstreams

Person Scope Delivers
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

Detailed specs:


Shared Contract: Bridge Protocol

This is the interface both workstreams implement. It's the only coupling between them.

Message Format (JSON over postMessage)

// WebView → Native (request)
{
  type: "request",
  version: 1,
  id: "uuid-v4",           // correlation ID
  domain: "nfc",            // see domain list below
  method: "scan",           // method within domain
  params: { ... },          // JSON-serializable payload
  timestamp: 1234567890
}

// Native → WebView (response)
{
  type: "response",
  version: 1,
  id: "uuid-v4",
  domain: "nfc",
  requestId: "uuid-of-request",
  success: true,
  data: { ... },            // result when success=true
  error: null,              // BridgeError when success=false
  timestamp: 1234567890
}

// Native → WebView (unsolicited event)
{
  type: "event",
  version: 1,
  id: "uuid-v4",
  domain: "nfc",
  event: "scanProgress",
  data: { step: "reading_dg1", percent: 40 },
  timestamp: 1234567890
}

Error Format

{ code: "NFC_NOT_SUPPORTED", message: "...", details?: { ... } }

Domain Catalog

Domain Methods Events Notes
nfc scan, cancelScan, isSupported scanProgress, tagDiscovered, scanError 120s timeout, progress streaming
biometrics authenticate, isAvailable, getBiometryType Required for key access
secureStorage get, set, remove Encrypted key-value store
camera scanMRZ, isAvailable MRZ OCR from camera
crypto sign, generateKey, getPublicKey hash() stays in WebView (Web Crypto API)
haptic trigger Fire-and-forget
analytics trackEvent, trackNfcEvent, logNfcEvent Fire-and-forget, no PII
lifecycle ready, dismiss, setResult WebView → host app communication
documents loadCatalog, saveCatalog, loadById, save, delete Encrypted document CRUD
navigation goBack, goTo WebView-internal only (no bridge round-trip)

NFC Scan Params (most complex domain)

{
  passportNumber: string,
  dateOfBirth: string,     // YYMMDD
  dateOfExpiry: string,    // YYMMDD
  canNumber?: string,
  skipPACE?: boolean,
  skipCA?: boolean,
  extendedMode?: boolean,
  usePacePolling?: boolean,
  sessionId: string,
  useCan?: boolean,
  userId?: string
}

NFC Scan Result

{
  passportData: {
    mrz: string,
    dsc: string,            // PEM certificate
    dg1Hash: number[],
    dg2Hash: number[],
    dgPresents: number[],
    eContent: number[],
    signedAttr: number[],
    encryptedDigest: number[],
    documentType: string,   // "passport" | "id_card"
    documentCategory: string,
    parsed: boolean,
    mock: boolean
  }
}

Transport Mechanism

Android:

  • WebView → Native: addJavascriptInterface("SelfNativeAndroid") exposes postMessage(json) to JS
  • Native → WebView: evaluateJavascript("window.SelfNativeBridge._handleResponse('...')") and _handleEvent('...')

iOS:

  • WebView → Native: WKScriptMessageHandler named "SelfNativeIOS" receives postMessage(json)
  • Native → WebView: evaluateJavaScript("window.SelfNativeBridge._handleResponse('...')") and _handleEvent('...')

JS side (injected at document start by native, or self-initializing in WebViewBridge class):

window.SelfNativeBridge = {
  _pending: {},  // id → { resolve, reject, timeout }
  _listeners: {}, // domain:event → [callback]

  request(domain, method, params) {
    return new Promise((resolve, reject) => {
      const id = crypto.randomUUID();
      const msg = { type: "request", version: 1, id, domain, method, params, timestamp: Date.now() };
      this._pending[id] = { resolve, reject, timeout: setTimeout(() => { ... }, 30000) };
      // Android: SelfNativeAndroid.postMessage(JSON.stringify(msg))
      // iOS: webkit.messageHandlers.SelfNativeIOS.postMessage(JSON.stringify(msg))
    });
  },

  _handleResponse(json) { /* resolve/reject pending promise by requestId */ },
  _handleEvent(json) { /* dispatch to listeners by domain:event */ },
  on(domain, event, cb) { /* register listener */ },
  off(domain, event, cb) { /* unregister listener */ },
};

Adapter Mapping

How mobile-sdk-alpha adapter interfaces map to bridge domains:

SDK Adapter Interface Bridge? Bridge Domain.Method Notes
NFCScannerAdapter Yes nfc.scan Core flow: scan passport NFC chip
CryptoAdapter.hash() No Web Crypto API in WebView
CryptoAdapter.sign() Yes crypto.sign Native secure enclave
AuthAdapter Yes secureStorage.get (with requireBiometric: true) Private key gated by biometrics
DocumentsAdapter Yes documents.* CRUD on encrypted passport data
StorageAdapter Yes secureStorage.* Key-value storage
NavigationAdapter No React Router (WebView-internal)
NetworkAdapter No fetch() works in WebView
ClockAdapter No Date.now() + setTimeout
AnalyticsAdapter Yes analytics.* Fire-and-forget
LoggerAdapter No Console in WebView

How the Pieces Connect

Person 1 delivers:                     Person 2 delivers:

@selfxyz/webview-bridge (npm)         KMP SDK (AAR + XCFramework)
@selfxyz/webview-app (Vite bundle)    ├─ WebView host
  ↓                                   ├─ Native bridge handlers
  ↓ dist/index.html + bundle.js       ├─ Asset bundling
  ↓                                   ├─ SelfSdk.launch() API
  └────── bundled into ──────────────→ SDK artifact

Integration point: Person 2's Gradle/SPM build copies Person 1's Vite output (dist/) into the SDK's bundled assets. During development, Person 2 uses a mock HTML page or connects to Person 1's Vite dev server (http://10.0.2.2:5173).

Bridge contract: Both sides implement the same JSON protocol. Person 1 tests with MockNativeBridge (JS). Person 2 tests with a mock WebView that sends/receives bridge JSON.


Dependency Graph

Phase 1 (parallel — no inter-dependencies):
  Chunk 1F (bridge package)           ──→ Chunk 1E (app shell)
  Chunk 2A (KMP setup + bridge)       ──→ Chunks 2B, 2C, 2D, 2E

Phase 2 (parallel — after Phase 1):
  Chunk 1B, 1C, 1D (UI screens)      ──→ Chunk 1E (app shell)
  Chunks 2B, 2C (Android)            ──→ Chunk 2F (SDK API + test app)
  Chunks 2D, 2E (iOS)                ──→ Chunk 2F

Phase 3 (integration):
  Chunk 1E (app shell output)         ──→ Final integration
  Chunk 2F (SDK API + test app)       ──→ Final integration

Cleanup: What to Delete Before Starting

The previous prototype code should be deleted:

Path Reason
packages/webview-bridge/ Will be recreated with same name but clean implementation
packages/webview-app/ Will be recreated with proper architecture
packages/kmp-shell/ Will be recreated as packages/kmp-sdk/

Keep: packages/mobile-sdk-alpha/ changes (Platform.OS removal, platform config).


Design Tokens (shared between Person 1 and Person 2)

Colors (from packages/mobile-sdk-alpha/src/constants/colors.ts)

Token Value Usage
black #000000 Primary text, buttons
white #ffffff Backgrounds
amber50 #FFFBEB Button text on dark bg
slate50 #F8FAFC Page backgrounds
slate300 #CBD5E1 Borders
slate400 #94A3B8 Placeholder text
slate500 #64748B Secondary text
blue600 #2563EB Links, accents
green500 / green600 #22C55E / #16A34A Success states
red500 / red600 #EF4444 / #DC2626 Error states

Fonts

Token Family File
advercase Advercase-Regular Advercase-Regular.otf
dinot DINOT-Medium DINOT-Medium.otf
dinotBold DINOT-Bold DINOT-Bold.otf
plexMono IBMPlexMono-Regular IBMPlexMono-Regular.otf

Font files are at app/web/fonts/.

Tamagui Config

Both app/tamagui.config.ts and packages/webview-app/tamagui.config.ts share the same configuration. Key: extends @tamagui/config/v3 with custom fonts (advercase, dinot, plexMono) using createFont() with shared size/lineHeight/letterSpacing scales.


Verification Plan

Person 1 validates:

# Build bridge package
cd packages/webview-bridge && npm run build && npx vitest run

# Build WebView app
cd packages/webview-app && npx tsc --noEmit && npx vite build

# Dev server for visual testing
cd packages/webview-app && npx vite dev  # → http://localhost:5173

Person 2 validates:

# Compile shared module
cd packages/kmp-sdk && ./gradlew :shared:compileKotlinJvm
cd packages/kmp-sdk && ./gradlew :shared:jvmTest

# Compile Android
cd packages/kmp-sdk && ./gradlew :shared:compileDebugKotlinAndroid

# Compile iOS
cd packages/kmp-sdk && ./gradlew :shared:compileKotlinIosArm64

# Test app
cd packages/kmp-test-app && ./gradlew :androidApp:installDebug

Integration test:

  1. Person 1 runs vite build → produces dist/
  2. Person 2 copies dist/ into KMP test app assets
  3. KMP test app launches WebView → loads dist/index.html
  4. Tap "Launch Verification" → WebView renders screens
  5. Bridge messages flow between JS and native (visible in console)
  6. NFC scan on physical device with real passport (final validation)

Key Reference Files

File What it Contains
packages/mobile-sdk-alpha/src/types/public.ts All adapter interfaces (NFCScannerAdapter, CryptoAdapter, etc.)
packages/mobile-sdk-alpha/src/constants/colors.ts Color tokens
packages/mobile-sdk-alpha/src/constants/fonts.ts Font family names
app/tamagui.config.ts Tamagui configuration (fonts, scales)
app/web/fonts/ Font files (otf)
app/android/.../RNPassportReaderModule.kt Android NFC implementation to port
app/ios/PassportReader.swift iOS NFC implementation to reference
app/src/screens/ Existing RN app screens (UI reference)