Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions packages/webview-app/src/providers/SelfClientProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
bridgeLifecycleAdapter,
bridgeStorageAdapter,
consoleAnalyticsAdapter,
createKeychainDocumentsAdapter,
createSdkAdapters,
} from '@selfxyz/webview-bridge/adapters';

Expand Down Expand Up @@ -74,15 +73,13 @@ export const SelfClientProvider: React.FC<{ children: React.ReactNode }> = ({ ch
listeners,
});

const documents = createKeychainDocumentsAdapter(bridge);

return {
client,
lifecycle: bridgeLifecycleAdapter(bridge),
haptic: bridgeHapticAdapter(bridge),
biometrics: bridgeBiometricsAdapter(bridge),
analytics: withCohortTags(consoleAnalyticsAdapter()),
documents,
documents: sdkAdapters.documents,
};
}, [bridge, stableNavigate, stableGoBack]);

Expand Down
23 changes: 9 additions & 14 deletions packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@
}, [documents, addLog]);

const handleClearAll = useCallback(async () => {
const ALL_KEYS = ['self_document_catalog', 'self_mnemonic', 'self_private_key'];
// Documents no longer live in secureStorage — clear them via "Delete catalog".
const ALL_KEYS = ['self_mnemonic', 'self_private_key'];
try {
for (const k of ALL_KEYS) {
const existing = await storage.get(k);
Expand All @@ -162,23 +163,17 @@
}, [storage, addLog]);

const handleDumpAll = useCallback(async () => {
const ALL_KEYS = ['self_document_catalog', 'self_mnemonic', 'self_private_key'];
const ALL_KEYS = ['self_mnemonic', 'self_private_key'];
try {
const catalog = await documents.loadDocumentCatalog();
addLog(`documents catalog -> ${catalog.documents.length} doc(s), selected: ${catalog.selectedDocumentId ?? 'none'}`);

Check warning on line 169 in packages/webview-app/src/screens/debug/KeychainDebugScreen.tsx

View workflow job for this annotation

GitHub Actions / lint

Replace ``documents·catalog·->·${catalog.documents.length}·doc(s),·selected:·${catalog.selectedDocumentId·??·'none'}`` with `⏎········`documents·catalog·->·${catalog.documents.length}·doc(s),·selected:·${catalog.selectedDocumentId·??·'none'}`,⏎······`
for (const doc of catalog.documents) {
addLog(` [${doc.id}] type=${doc.documentType} mock=${doc.mock} registered=${doc.isRegistered}`);
}
for (const k of ALL_KEYS) {
const val = await storage.get(k);
if (val === null) {
addLog(`${k} -> null`);
} else if (k === 'self_document_catalog') {
try {
const catalog = JSON.parse(val);
const docs = catalog.documents ?? [];
addLog(`${k} -> ${docs.length} doc(s), selected: ${catalog.selectedDocumentId ?? 'none'}`);
for (const doc of docs) {
addLog(` [${doc.id}] type=${doc.documentType} mock=${doc.mock} registered=${doc.isRegistered}`);
}
} catch {
addLog(`${k} -> ${val.length} chars (parse error)`, true);
}
} else if (k === 'self_mnemonic') {
addLog(`${k} -> ${val.split(' ').length} words`);
} else {
Expand All @@ -188,7 +183,7 @@
} catch (e) {
addLog(`DUMP FAILED: ${e}`, true);
}
}, [storage, addLog]);
}, [storage, documents, addLog]);

const handleDeleteDoc = useCallback(async () => {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useCallback, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { EuIdNfcInstructionsScreen } from '@selfxyz/euclid';
import { normalizeNfcPassport } from '@selfxyz/mobile-sdk-alpha/browser';
import { normalizeNfcPassport, storePassportData } from '@selfxyz/mobile-sdk-alpha/browser';
import { bridgeNFCScannerAdapter } from '@selfxyz/webview-bridge/adapters';

import { useBridge } from '../../../providers/BridgeProvider';
Expand All @@ -30,7 +30,7 @@ export const EuIdNfcInstructionsRoute: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const bridge = useBridge();
const { analytics, haptic, documents } = useSelfClient();
const { analytics, haptic, client } = useSelfClient();
const state = (location.state as RouteState | null) ?? {};
const nfcScanner = useMemo(() => bridgeNFCScannerAdapter(bridge), [bridge]);
const startedRef = useRef(false);
Expand Down Expand Up @@ -78,27 +78,11 @@ export const EuIdNfcInstructionsRoute: React.FC = () => {
try {
const result = await nfcScanner.scan(scanParams);
const documentData = normalizeNfcPassport(result);
const documentNumber = state.mrz?.passportNumber ?? state.canValue ?? 'unknown';
const docId = `id_card-${documentNumber}`;
await documents.saveDocument(docId, documentData as never);

const catalog = await documents.loadDocumentCatalog();
const entry = {
id: docId,
documentType: state.documentType ?? 'id_card',
documentCategory: 'id_card' as const,
data: '',
mock: false,
isRegistered: false,
};
const existingIndex = catalog.documents.findIndex((d: { id: string }) => d.id === docId);
if (existingIndex >= 0) {
catalog.documents[existingIndex] = entry;
} else {
catalog.documents.push(entry);
}
catalog.selectedDocumentId = docId;
await documents.saveDocumentCatalog(catalog);
// Content-hash id + dedup + catalog upsert via the SDK store. Never
// key documents by document number / CAN: keychain service names leak
// into native logs (RNKeychainManager logs missing services verbatim).
await storePassportData(client, documentData);

analytics.trackEvent('eu_id_nfc_scan_succeeded');
haptic.trigger('success');
Expand Down Expand Up @@ -126,7 +110,7 @@ export const EuIdNfcInstructionsRoute: React.FC = () => {
}, [
analytics,
busy,
documents,
client,
haptic,
navigate,
nfcScanner,
Expand Down
29 changes: 7 additions & 22 deletions packages/webview-app/src/screens/onboarding/passport/NfcRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { PassportNfcInstructionsScreen } from '@selfxyz/euclid';
import { normalizeNfcPassport } from '@selfxyz/mobile-sdk-alpha/browser';
import { normalizeNfcPassport, storePassportData } from '@selfxyz/mobile-sdk-alpha/browser';
import { bridgeNFCScannerAdapter, onNfcProgress } from '@selfxyz/webview-bridge/adapters';

import { useBridge } from '../../../providers/BridgeProvider';
Expand Down Expand Up @@ -58,7 +58,7 @@ export const PassportNfcRoute: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const bridge = useBridge();
const { analytics, haptic, documents } = useSelfClient();
const { analytics, haptic, client } = useSelfClient();
const state = (location.state as RouteState | null) ?? {};
const mrz = state.mrz;
const nfcScanner = useMemo(() => bridgeNFCScannerAdapter(bridge), [bridge]);
Expand Down Expand Up @@ -101,25 +101,10 @@ export const PassportNfcRoute: React.FC = () => {

const passportData = normalizeNfcPassport(result);

const docId = `passport-${mrz.passportNumber}`;
await documents.saveDocument(docId, passportData as never);
const catalog = await documents.loadDocumentCatalog();
const entry = {
id: docId,
documentType: state.documentType ?? 'passport',
documentCategory: 'passport' as const,
data: '',
mock: false,
isRegistered: false,
};
const existingIndex = catalog.documents.findIndex((d: { id: string }) => d.id === docId);
if (existingIndex >= 0) {
catalog.documents[existingIndex] = entry;
} else {
catalog.documents.push(entry);
}
catalog.selectedDocumentId = docId;
await documents.saveDocumentCatalog(catalog);
// Content-hash id + dedup + catalog upsert via the SDK store. Never
// key documents by passport number: keychain service names leak into
// native logs (RNKeychainManager logs missing services verbatim).
await storePassportData(client, passportData);

analytics.trackEvent('passport_nfc_scan_succeeded');
haptic.trigger('success');
Expand Down Expand Up @@ -158,7 +143,7 @@ export const PassportNfcRoute: React.FC = () => {
}
}, 0);
};
}, [analytics, bridge, documents, haptic, mrz, navigate, nfcScanner, state.countryCode, state.documentType]);
}, [analytics, bridge, client, haptic, mrz, navigate, nfcScanner, state.countryCode, state.documentType]);

const handleBack = useCallback(() => {
haptic.trigger('selection');
Expand Down
17 changes: 17 additions & 0 deletions packages/webview-app/tests/flows/onboarding.passport.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ describe('passport onboarding flow', () => {
});

await waitFor(() => expect(currentPath(result)).toBe('/capture/passport/nfc-success'));

// The captured document must be persisted over the documents bridge
// domain (host store), not tunneled through secureStorage.
expect(result.documents.byId.size).toBe(1);
const catalog = result.documents.catalog.value as {
documents: Array<{ id: string; isRegistered: boolean }>;
selectedDocumentId?: string;
};
expect(catalog.documents).toHaveLength(1);
expect(catalog.documents[0].isRegistered).toBe(false);
// Document ids are content hashes, never document numbers — keychain
// service names derived from the id leak into native logs.
const [docId] = result.documents.byId.keys();
expect(catalog.documents[0].id).toBe(docId);
expect(catalog.selectedDocumentId).toBe(docId);
expect(docId).not.toContain('L898902C3'); // chip MRZ document number
expect(docId).not.toContain(MRZ_FIXTURE.documentNumber);
});

it('NFC failure routes to the error screen and shows the support reference', async () => {
Expand Down
37 changes: 33 additions & 4 deletions packages/webview-app/tests/utils/renderWithBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,23 @@ export interface RenderWithBridgeOptions {
config?: HostConfig;
// Seed values for the in-memory secure storage map (key -> string value).
storage?: Record<string, string>;
// Seed for the documents-domain store (catalog object + documents by id).
documents?: { catalog?: unknown; byId?: Record<string, unknown> };
// Caller hook to register flow-specific handlers (camera/scanMRZ, nfc/scanPassport,
// etc.) or to override any baseline handler. Runs after defaults are registered.
setupHandlers?: (mock: MockNativeBridge) => void;
}

export interface HarnessDocumentsStore {
catalog: { value: unknown };
byId: Map<string, unknown>;
}

export interface RenderWithBridgeResult extends RenderResult {
mock: MockNativeBridge;
bridge: WebViewBridge;
storage: Map<string, string>;
documents: HarnessDocumentsStore;
}

const LocationProbe: React.FC = () => {
Expand All @@ -56,10 +64,14 @@ export function currentPath(result: RenderWithBridgeResult): string {
* real routing and real bridge round-trips; only the native side is mocked.
*/
export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBridgeResult {
const { initialEntries, config, storage: seed, setupHandlers } = options;
const { initialEntries, config, storage: seed, documents: documentsSeed, setupHandlers } = options;

const mock = new MockNativeBridge();
const storage = new Map<string, string>(Object.entries(seed ?? {}));
const documents: HarnessDocumentsStore = {
catalog: { value: documentsSeed?.catalog ?? null },
byId: new Map(Object.entries(documentsSeed?.byId ?? {})),
};

// Awaited boot request: operating mode + reference id.
mock.handle('lifecycle', 'getConfig', () => ({ mode: 'self-app', ...config }));
Expand All @@ -70,8 +82,8 @@ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBr
mock.handleWith('lifecycle', 'dismiss', {});
mock.handleWith('haptic', 'trigger', {});

// Secure storage backed by an in-memory map. Covers the secret manager,
// document catalog (self_document_catalog) and document bodies (self_doc_*).
// Secure storage backed by an in-memory map. Covers the secret manager
// (mnemonic/private key); documents live on the documents domain below.
mock.handle('secureStorage', 'get', params => {
const key = params.key as string;
return storage.has(key) ? storage.get(key) : null;
Expand All @@ -85,6 +97,23 @@ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBr
return {};
});

// Documents domain, mirroring rn-sdk DocumentsHandler semantics (an unset
// catalog loads as null; the web-side typed adapter normalizes it).
mock.handle('documents', 'loadCatalog', () => documents.catalog.value);
mock.handle('documents', 'saveCatalog', params => {
documents.catalog.value = params.catalog;
return {};
});
mock.handle('documents', 'loadById', params => documents.byId.get(params.id as string) ?? null);
mock.handle('documents', 'save', params => {
documents.byId.set(params.id as string, params.data);
return {};
});
mock.handle('documents', 'delete', params => {
documents.byId.delete(params.id as string);
return {};
});

setupHandlers?.(mock);

const bridge = new WebViewBridge({ transport: mock });
Expand All @@ -103,7 +132,7 @@ export function renderWithBridge(options: RenderWithBridgeOptions): RenderWithBr
</BridgeProvider>,
);

return Object.assign(result, { mock, bridge, storage });
return Object.assign(result, { mock, bridge, storage, documents });
}

export type { BridgeDomain };
51 changes: 51 additions & 0 deletions packages/webview-bridge/src/__tests__/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

Check failure on line 5 in packages/webview-bridge/src/__tests__/adapters.test.ts

View workflow job for this annotation

GitHub Actions / lint

Run autofix to sort these imports!

import {
bridgeAnalyticsAdapter,
Expand All @@ -11,6 +11,7 @@
bridgeCameraAdapter,
bridgeCryptoAdapter,
bridgeDocumentsAdapter,
createBridgeDocumentsSdkAdapter,
bridgeHapticAdapter,
bridgeLifecycleAdapter,
bridgeNFCScannerAdapter,
Expand Down Expand Up @@ -278,6 +279,56 @@
});
});

describe('SDK Documents Adapter (typed)', () => {
it('normalizes a null catalog (unset host store) to an empty catalog', async () => {
mock.handleWith('documents', 'loadCatalog', null);

const docs = createBridgeDocumentsSdkAdapter(bridge);
expect(await docs.loadDocumentCatalog()).toEqual({ documents: [] });
});

it('normalizes a malformed catalog to an empty catalog', async () => {
mock.handleWith('documents', 'loadCatalog', { documents: 'not-an-array' });

const docs = createBridgeDocumentsSdkAdapter(bridge);
expect(await docs.loadDocumentCatalog()).toEqual({ documents: [] });
});

it('passes a valid catalog through unchanged (app schema pin)', async () => {
// Shape of the Self app's real DocumentCatalog (common DocumentMetadata):
// this fixture surviving normalization unchanged is the B2 compatibility pin.
const catalog = {
documents: [
{
id: 'a1b2c3',
documentType: 'passport',
documentCategory: 'passport',
data: 'P<UTO...',
mock: false,
isRegistered: true,
},
],
selectedDocumentId: 'a1b2c3',
};
mock.handleWith('documents', 'loadCatalog', catalog);

const docs = createBridgeDocumentsSdkAdapter(bridge);
expect(await docs.loadDocumentCatalog()).toEqual(catalog);
});

it('returns null for a missing document and routes writes to the documents domain', async () => {
mock.handleWith('documents', 'loadById', null);
mock.handleWith('documents', 'saveCatalog', {});

const docs = createBridgeDocumentsSdkAdapter(bridge);
expect(await docs.loadDocumentById('missing')).toBeNull();

await docs.saveDocumentCatalog({ documents: [] });
const saveMessage = mock.messagesFor('documents').find(m => m.method === 'saveCatalog');
expect(saveMessage?.params).toEqual({ catalog: { documents: [] } });
});
});

describe('Storage Adapter', () => {
it('should get/set/remove values', async () => {
mock.handleWith('secureStorage', 'get', { value: 'stored' });
Expand Down
Loading
Loading