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

358 lines
13 KiB
Markdown

# 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:
- [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)
---
## Shared Contract: Bridge Protocol
This is the interface both workstreams implement. It's the only coupling between them.
### Message Format (JSON over postMessage)
```typescript
// 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
```typescript
{ 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)
```typescript
{
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
```typescript
{
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):
```javascript
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:
```bash
# 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:
```bash
# 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) |