mirror of
https://github.com/selfxyz/self.git
synced 2026-04-05 03:00:53 -04:00
[SELF-723] feat: add structured NFC and Proof logging (#1048)
* feat: add structured NFC logging * fix ci * Fix: add deps * logging fixes. use breadcrumbs * fix android build * update SeverityLevel * [SELF-705] feat: add proof event logging (#1057) * feat: add proof event logging * refactor: unify sentry event logging * fix types * fix mock * simplify * code rabbit feedback * fix tests --------- Co-authored-by: seshanthS <seshanth@protonmail.com>
This commit is contained in:
@@ -4,15 +4,100 @@
|
||||
|
||||
import { SENTRY_DSN } from '@env';
|
||||
import {
|
||||
addBreadcrumb,
|
||||
captureException as sentryCaptureException,
|
||||
captureFeedback as sentryCaptureFeedback,
|
||||
captureMessage as sentryCaptureMessage,
|
||||
consoleLoggingIntegration,
|
||||
feedbackIntegration,
|
||||
init as sentryInit,
|
||||
withScope,
|
||||
wrap,
|
||||
} from '@sentry/react-native';
|
||||
|
||||
interface BaseContext {
|
||||
sessionId: string;
|
||||
userId?: string;
|
||||
platform: 'ios' | 'android';
|
||||
stage: string;
|
||||
}
|
||||
|
||||
// Security: Whitelist of allowed tag keys to prevent XSS
|
||||
const ALLOWED_TAG_KEYS = new Set([
|
||||
'session_id',
|
||||
'platform',
|
||||
'stage',
|
||||
'circuitType',
|
||||
'currentState',
|
||||
'scanType',
|
||||
'error_code',
|
||||
'proof_step',
|
||||
'scan_result',
|
||||
'verification_status',
|
||||
'document_type',
|
||||
]);
|
||||
|
||||
// Security: Sanitize tag values to prevent XSS
|
||||
const sanitizeTagValue = (value: unknown): string => {
|
||||
if (value == null) return '';
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
// Truncate to safe length
|
||||
const MAX_TAG_LENGTH = 200;
|
||||
const truncated =
|
||||
stringValue.length > MAX_TAG_LENGTH
|
||||
? stringValue.substring(0, MAX_TAG_LENGTH) + '...'
|
||||
: stringValue;
|
||||
|
||||
// Escape HTML characters and remove potentially dangerous characters
|
||||
return (
|
||||
truncated
|
||||
.replace(/[<>&"']/g, char => {
|
||||
switch (char) {
|
||||
case '<':
|
||||
return '<';
|
||||
case '>':
|
||||
return '>';
|
||||
case '&':
|
||||
return '&';
|
||||
case '"':
|
||||
return '"';
|
||||
case "'":
|
||||
return ''';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
})
|
||||
// Remove control characters and non-printable characters
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
);
|
||||
};
|
||||
|
||||
// Security: Sanitize tag key to prevent XSS
|
||||
const sanitizeTagKey = (key: string): string | null => {
|
||||
// Only allow whitelisted keys
|
||||
if (!ALLOWED_TAG_KEYS.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Additional validation: alphanumeric and underscores only
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
export interface NFCScanContext extends BaseContext, Record<string, unknown> {
|
||||
scanType: 'mrz' | 'can';
|
||||
}
|
||||
|
||||
export interface ProofContext extends BaseContext, Record<string, unknown> {
|
||||
circuitType: 'register' | 'dsc' | 'disclose' | null;
|
||||
currentState: string;
|
||||
}
|
||||
|
||||
export const captureException = (
|
||||
error: Error,
|
||||
context?: Record<string, unknown>,
|
||||
@@ -118,6 +203,78 @@ export const initSentry = () => {
|
||||
|
||||
export const isSentryDisabled = !SENTRY_DSN;
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
type LogCategory = 'proof' | 'nfc';
|
||||
|
||||
export const logEvent = (
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
context: BaseContext & Record<string, unknown>,
|
||||
extra?: Record<string, unknown>,
|
||||
) => {
|
||||
if (isSentryDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, userId, platform, stage, ...rest } = context;
|
||||
const data = {
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
platform,
|
||||
stage,
|
||||
...rest,
|
||||
...extra,
|
||||
};
|
||||
|
||||
if (level === 'error') {
|
||||
withScope(scope => {
|
||||
scope.setLevel('error');
|
||||
scope.setTag('session_id', sessionId);
|
||||
scope.setTag('platform', platform);
|
||||
scope.setTag('stage', stage);
|
||||
Object.entries(rest).forEach(([key, value]) => {
|
||||
const sanitizedKey = sanitizeTagKey(key);
|
||||
if (sanitizedKey) {
|
||||
const sanitizedValue = sanitizeTagValue(value);
|
||||
scope.setTag(sanitizedKey, sanitizedValue);
|
||||
}
|
||||
});
|
||||
if (userId) {
|
||||
scope.setUser({ id: userId });
|
||||
}
|
||||
if (extra) {
|
||||
Object.entries(extra).forEach(([key, value]) => {
|
||||
scope.setExtra(key, value);
|
||||
});
|
||||
}
|
||||
sentryCaptureMessage(message);
|
||||
});
|
||||
} else {
|
||||
addBreadcrumb({
|
||||
message,
|
||||
level: level === 'warn' ? 'warning' : 'info',
|
||||
category,
|
||||
data,
|
||||
timestamp: Date.now() / 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const logNFCEvent = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context: NFCScanContext,
|
||||
extra?: Record<string, unknown>,
|
||||
) => logEvent(level, 'nfc', message, context, extra);
|
||||
|
||||
export const logProofEvent = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context: ProofContext,
|
||||
extra?: Record<string, unknown>,
|
||||
) => logEvent(level, 'proof', message, context, extra);
|
||||
|
||||
export const wrapWithSentry = (App: React.ComponentType) => {
|
||||
return isSentryDisabled ? App : wrap(App);
|
||||
};
|
||||
|
||||
@@ -4,14 +4,99 @@
|
||||
|
||||
import { SENTRY_DSN } from '@env';
|
||||
import {
|
||||
addBreadcrumb,
|
||||
captureException as sentryCaptureException,
|
||||
captureFeedback as sentryCaptureFeedback,
|
||||
captureMessage as sentryCaptureMessage,
|
||||
feedbackIntegration,
|
||||
init as sentryInit,
|
||||
withProfiler,
|
||||
withScope,
|
||||
} from '@sentry/react';
|
||||
|
||||
interface BaseContext {
|
||||
sessionId: string;
|
||||
userId?: string;
|
||||
platform: 'ios' | 'android';
|
||||
stage: string;
|
||||
}
|
||||
|
||||
// Security: Whitelist of allowed tag keys to prevent XSS
|
||||
const ALLOWED_TAG_KEYS = new Set([
|
||||
'session_id',
|
||||
'platform',
|
||||
'stage',
|
||||
'circuitType',
|
||||
'currentState',
|
||||
'scanType',
|
||||
'error_code',
|
||||
'proof_step',
|
||||
'scan_result',
|
||||
'verification_status',
|
||||
'document_type',
|
||||
]);
|
||||
|
||||
// Security: Sanitize tag values to prevent XSS
|
||||
const sanitizeTagValue = (value: unknown): string => {
|
||||
if (value == null) return '';
|
||||
|
||||
const stringValue = String(value);
|
||||
|
||||
// Truncate to safe length
|
||||
const MAX_TAG_LENGTH = 200;
|
||||
const truncated =
|
||||
stringValue.length > MAX_TAG_LENGTH
|
||||
? stringValue.substring(0, MAX_TAG_LENGTH) + '...'
|
||||
: stringValue;
|
||||
|
||||
// Escape HTML characters and remove potentially dangerous characters
|
||||
return (
|
||||
truncated
|
||||
.replace(/[<>&"']/g, char => {
|
||||
switch (char) {
|
||||
case '<':
|
||||
return '<';
|
||||
case '>':
|
||||
return '>';
|
||||
case '&':
|
||||
return '&';
|
||||
case '"':
|
||||
return '"';
|
||||
case "'":
|
||||
return ''';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
})
|
||||
// Remove control characters and non-printable characters
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
);
|
||||
};
|
||||
|
||||
// Security: Sanitize tag key to prevent XSS
|
||||
const sanitizeTagKey = (key: string): string | null => {
|
||||
// Only allow whitelisted keys
|
||||
if (!ALLOWED_TAG_KEYS.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Additional validation: alphanumeric and underscores only
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
export interface NFCScanContext extends BaseContext, Record<string, unknown> {
|
||||
scanType: 'mrz' | 'can';
|
||||
}
|
||||
|
||||
export interface ProofContext extends BaseContext, Record<string, unknown> {
|
||||
circuitType: 'register' | 'dsc' | 'disclose' | null;
|
||||
currentState: string;
|
||||
}
|
||||
|
||||
export const captureException = (
|
||||
error: Error,
|
||||
context?: Record<string, unknown>,
|
||||
@@ -110,6 +195,78 @@ export const initSentry = () => {
|
||||
|
||||
export const isSentryDisabled = !SENTRY_DSN;
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
type LogCategory = 'proof' | 'nfc';
|
||||
|
||||
export const logEvent = (
|
||||
level: LogLevel,
|
||||
category: LogCategory,
|
||||
message: string,
|
||||
context: BaseContext & Record<string, unknown>,
|
||||
extra?: Record<string, unknown>,
|
||||
) => {
|
||||
if (isSentryDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sessionId, userId, platform, stage, ...rest } = context;
|
||||
const data = {
|
||||
session_id: sessionId,
|
||||
user_id: userId,
|
||||
platform,
|
||||
stage,
|
||||
...rest,
|
||||
...extra,
|
||||
};
|
||||
|
||||
if (level === 'error') {
|
||||
withScope(scope => {
|
||||
scope.setLevel('error');
|
||||
scope.setTag('session_id', sessionId);
|
||||
scope.setTag('platform', platform);
|
||||
scope.setTag('stage', stage);
|
||||
Object.entries(rest).forEach(([key, value]) => {
|
||||
const sanitizedKey = sanitizeTagKey(key);
|
||||
if (sanitizedKey) {
|
||||
const sanitizedValue = sanitizeTagValue(value);
|
||||
scope.setTag(sanitizedKey, sanitizedValue);
|
||||
}
|
||||
});
|
||||
if (userId) {
|
||||
scope.setUser({ id: userId });
|
||||
}
|
||||
if (extra) {
|
||||
Object.entries(extra).forEach(([key, value]) => {
|
||||
scope.setExtra(key, value);
|
||||
});
|
||||
}
|
||||
sentryCaptureMessage(message);
|
||||
});
|
||||
} else {
|
||||
addBreadcrumb({
|
||||
message,
|
||||
level: level === 'warn' ? 'warning' : 'info',
|
||||
category,
|
||||
data,
|
||||
timestamp: Date.now() / 1000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const logNFCEvent = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context: NFCScanContext,
|
||||
extra?: Record<string, unknown>,
|
||||
) => logEvent(level, 'nfc', message, context, extra);
|
||||
|
||||
export const logProofEvent = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context: ProofContext,
|
||||
extra?: Record<string, unknown>,
|
||||
) => logEvent(level, 'proof', message, context, extra);
|
||||
|
||||
export const wrapWithSentry = (App: React.ComponentType) => {
|
||||
return isSentryDisabled ? App : withProfiler(App);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import NfcManager from 'react-native-nfc-manager';
|
||||
import { Button, Image, XStack } from 'tamagui';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import {
|
||||
useFocusEffect,
|
||||
@@ -44,6 +45,7 @@ import NFC_IMAGE from '@/images/nfc.png';
|
||||
import { ExpandableBottomLayout } from '@/layouts/ExpandableBottomLayout';
|
||||
import { useFeedback } from '@/providers/feedbackProvider';
|
||||
import { storePassportData } from '@/providers/passportDataProvider';
|
||||
import { logNFCEvent } from '@/Sentry';
|
||||
import useUserStore from '@/stores/userStore';
|
||||
import {
|
||||
flushAllAnalytics,
|
||||
@@ -104,6 +106,13 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
const [nfcMessage, setNfcMessage] = useState<string | null>(null);
|
||||
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scanCancelledRef = useRef(false);
|
||||
const sessionIdRef = useRef(uuidv4());
|
||||
|
||||
const baseContext = {
|
||||
sessionId: sessionIdRef.current,
|
||||
platform: Platform.OS as 'ios' | 'android',
|
||||
scanType: route.params?.useCan ? 'can' : 'mrz',
|
||||
} as const;
|
||||
|
||||
const animationRef = useRef<LottieView>(null);
|
||||
|
||||
@@ -111,6 +120,16 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
animationRef.current?.play();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logNFCEvent('info', 'screen_mount', { ...baseContext, stage: 'mount' });
|
||||
return () => {
|
||||
logNFCEvent('info', 'screen_unmount', {
|
||||
...baseContext,
|
||||
stage: 'unmount',
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup timeout on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -144,6 +163,15 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
const openErrorModal = useCallback(
|
||||
(message: string) => {
|
||||
flushAllAnalytics();
|
||||
logNFCEvent(
|
||||
'error',
|
||||
'nfc_error_modal',
|
||||
{
|
||||
...baseContext,
|
||||
stage: 'error',
|
||||
},
|
||||
{ message: sanitizeErrorMessage(message) },
|
||||
);
|
||||
showModal({
|
||||
titleText: 'NFC Scan Error',
|
||||
bodyText: message,
|
||||
@@ -171,6 +199,18 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
setDialogMessage('NFC is not enabled. Please enable it in settings.');
|
||||
}
|
||||
setIsNfcSupported(true);
|
||||
logNFCEvent(
|
||||
'info',
|
||||
'nfc_capability',
|
||||
{
|
||||
...baseContext,
|
||||
stage: 'check',
|
||||
},
|
||||
{
|
||||
supported: true,
|
||||
enabled: isEnabled,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setDialogMessage(
|
||||
"Sorry, your device doesn't seem to have an NFC reader.",
|
||||
@@ -179,6 +219,18 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
// near the disabled button when NFC isn't supported
|
||||
setIsNfcEnabled(false);
|
||||
setIsNfcSupported(false);
|
||||
logNFCEvent(
|
||||
'warn',
|
||||
'nfc_capability',
|
||||
{
|
||||
...baseContext,
|
||||
stage: 'check',
|
||||
},
|
||||
{
|
||||
supported: false,
|
||||
enabled: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -200,7 +252,12 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
const onVerifyPress = useCallback(async () => {
|
||||
buttonTap();
|
||||
if (isNfcEnabled) {
|
||||
logNFCEvent('info', 'verify_pressed', {
|
||||
...baseContext,
|
||||
stage: 'ui',
|
||||
});
|
||||
setIsNfcSheetOpen(true);
|
||||
logNFCEvent('info', 'sheet_open', { ...baseContext, stage: 'ui' });
|
||||
// Add timestamp when scan starts
|
||||
scanCancelledRef.current = false;
|
||||
const scanStartTime = Date.now();
|
||||
@@ -213,8 +270,16 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
trackEvent(PassportEvents.NFC_SCAN_FAILED, {
|
||||
error: 'timeout',
|
||||
});
|
||||
logNFCEvent('warn', 'scan_timeout', {
|
||||
...baseContext,
|
||||
stage: 'timeout',
|
||||
});
|
||||
openErrorModal('Scan timed out. Please try again.');
|
||||
setIsNfcSheetOpen(false);
|
||||
logNFCEvent('info', 'sheet_close', {
|
||||
...baseContext,
|
||||
stage: 'ui',
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
// Mark NFC scanning as active to prevent analytics flush interference
|
||||
@@ -233,8 +298,16 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
trackNfcEvent(PassportEvents.NFC_SCAN_FAILED, {
|
||||
error: 'timeout',
|
||||
});
|
||||
logNFCEvent('warn', 'scan_timeout', {
|
||||
...baseContext,
|
||||
stage: 'timeout',
|
||||
});
|
||||
openErrorModal('Scan timed out. Please try again.');
|
||||
setIsNfcSheetOpen(false);
|
||||
logNFCEvent('info', 'sheet_close', {
|
||||
...baseContext,
|
||||
stage: 'ui',
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
try {
|
||||
@@ -251,6 +324,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
skipCA,
|
||||
extendedMode,
|
||||
usePacePolling: isPacePolling,
|
||||
sessionId: sessionIdRef.current,
|
||||
});
|
||||
|
||||
// Check if scan was cancelled by timeout
|
||||
@@ -270,6 +344,15 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
trackEvent(PassportEvents.NFC_SCAN_SUCCESS, {
|
||||
duration_seconds: parseFloat(scanDurationSeconds),
|
||||
});
|
||||
logNFCEvent(
|
||||
'info',
|
||||
'scan_success',
|
||||
{
|
||||
...baseContext,
|
||||
stage: 'complete',
|
||||
},
|
||||
{ duration_seconds: parseFloat(scanDurationSeconds) },
|
||||
);
|
||||
let passportData: PassportData | null = null;
|
||||
let parsedPassportData: PassportData | null = null;
|
||||
try {
|
||||
@@ -388,6 +471,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
scanTimeoutRef.current = null;
|
||||
}
|
||||
setIsNfcSheetOpen(false);
|
||||
logNFCEvent('info', 'sheet_close', { ...baseContext, stage: 'ui' });
|
||||
setNfcScanningActive(false);
|
||||
}
|
||||
} else if (isNfcSupported) {
|
||||
@@ -419,6 +503,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
|
||||
const onCancelPress = async () => {
|
||||
flushAllAnalytics();
|
||||
logNFCEvent('info', 'scan_cancelled', { ...baseContext, stage: 'cancel' });
|
||||
const hasValidDocument = await hasAnyValidRegisteredDocument(selfClient);
|
||||
if (hasValidDocument) {
|
||||
navigateToHome();
|
||||
@@ -435,6 +520,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
logNFCEvent('info', 'screen_focus', { ...baseContext, stage: 'focus' });
|
||||
checkNfcSupport();
|
||||
|
||||
if (Platform.OS === 'android' && emitter) {
|
||||
@@ -469,6 +555,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
);
|
||||
|
||||
return () => {
|
||||
logNFCEvent('info', 'screen_blur', { ...baseContext, stage: 'blur' });
|
||||
subscription.remove();
|
||||
// Clear scan timeout when component loses focus
|
||||
scanCancelledRef.current = true;
|
||||
@@ -481,6 +568,7 @@ const DocumentNFCScanScreen: React.FC = () => {
|
||||
|
||||
// For iOS or when no emitter, still handle timeout cleanup on blur
|
||||
return () => {
|
||||
logNFCEvent('info', 'screen_blur', { ...baseContext, stage: 'blur' });
|
||||
scanCancelledRef.current = true;
|
||||
if (scanTimeoutRef.current) {
|
||||
clearTimeout(scanTimeoutRef.current);
|
||||
|
||||
@@ -10,6 +10,7 @@ declare module 'react-native-passport-reader' {
|
||||
canNumber: string;
|
||||
useCan: boolean;
|
||||
quality?: number;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface PassportReader {
|
||||
@@ -27,6 +28,7 @@ declare module 'react-native-passport-reader' {
|
||||
skipCA: boolean,
|
||||
extendedMode: boolean,
|
||||
usePacePolling: boolean,
|
||||
sessionId: string,
|
||||
): Promise<{
|
||||
mrz: string;
|
||||
eContent: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Platform } from 'react-native';
|
||||
|
||||
import type { PassportData } from '@selfxyz/common/types';
|
||||
|
||||
import { logNFCEvent, type NFCScanContext } from '@/Sentry';
|
||||
import { configureNfcAnalytics } from '@/utils/analytics';
|
||||
import {
|
||||
PassportReader,
|
||||
@@ -39,6 +40,8 @@ interface Inputs {
|
||||
skipCA?: boolean;
|
||||
extendedMode?: boolean;
|
||||
usePacePolling?: boolean;
|
||||
sessionId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export const parseScanResponse = (response: unknown) => {
|
||||
@@ -50,18 +53,46 @@ export const parseScanResponse = (response: unknown) => {
|
||||
export const scan = async (inputs: Inputs) => {
|
||||
await configureNfcAnalytics();
|
||||
|
||||
return Platform.OS === 'android'
|
||||
? await scanAndroid(inputs)
|
||||
: await scanIOS(inputs);
|
||||
const baseContext = {
|
||||
sessionId: inputs.sessionId,
|
||||
userId: inputs.userId,
|
||||
platform: Platform.OS as 'ios' | 'android',
|
||||
scanType: inputs.useCan ? 'can' : 'mrz',
|
||||
} as const;
|
||||
|
||||
logNFCEvent('info', 'scan_start', { ...baseContext, stage: 'start' });
|
||||
|
||||
try {
|
||||
return Platform.OS === 'android'
|
||||
? await scanAndroid(inputs, baseContext)
|
||||
: await scanIOS(inputs, baseContext);
|
||||
} catch (error) {
|
||||
logNFCEvent(
|
||||
'error',
|
||||
'scan_failed',
|
||||
{ ...baseContext, stage: 'scan' },
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const scanAndroid = async (inputs: Inputs) => {
|
||||
const scanAndroid = async (
|
||||
inputs: Inputs,
|
||||
context: Omit<NFCScanContext, 'stage'>,
|
||||
) => {
|
||||
reset();
|
||||
|
||||
if (!scanDocument) {
|
||||
console.warn(
|
||||
'Android passport scanner is not available - native module failed to load',
|
||||
);
|
||||
logNFCEvent('error', 'module_unavailable', {
|
||||
...context,
|
||||
stage: 'init',
|
||||
} as NFCScanContext);
|
||||
return Promise.reject(new Error('NFC scanning is currently unavailable.'));
|
||||
}
|
||||
|
||||
@@ -71,14 +102,22 @@ const scanAndroid = async (inputs: Inputs) => {
|
||||
dateOfExpiry: inputs.dateOfExpiry,
|
||||
canNumber: inputs.canNumber ?? '',
|
||||
useCan: inputs.useCan ?? false,
|
||||
sessionId: inputs.sessionId,
|
||||
});
|
||||
};
|
||||
|
||||
const scanIOS = async (inputs: Inputs) => {
|
||||
const scanIOS = async (
|
||||
inputs: Inputs,
|
||||
context: Omit<NFCScanContext, 'stage'>,
|
||||
) => {
|
||||
if (!PassportReader?.scanPassport) {
|
||||
console.warn(
|
||||
'iOS passport scanner is not available - native module failed to load',
|
||||
);
|
||||
logNFCEvent('error', 'module_unavailable', {
|
||||
...context,
|
||||
stage: 'init',
|
||||
} as NFCScanContext);
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'NFC scanning is currently unavailable. Please ensure the app is properly installed.',
|
||||
@@ -97,6 +136,7 @@ const scanIOS = async (inputs: Inputs) => {
|
||||
inputs.skipCA ?? false,
|
||||
inputs.extendedMode ?? false,
|
||||
inputs.usePacePolling ?? false,
|
||||
inputs.sessionId,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ type ScanOptions = {
|
||||
skipCA?: boolean;
|
||||
extendedMode?: boolean;
|
||||
usePacePolling?: boolean;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
// Platform-specific PassportReader implementation
|
||||
@@ -55,6 +56,7 @@ if (Platform.OS === 'android') {
|
||||
skipCA = false,
|
||||
extendedMode = false,
|
||||
usePacePolling = true,
|
||||
sessionId = '',
|
||||
} = options;
|
||||
|
||||
const result = await PassportReader.scanPassport(
|
||||
@@ -67,6 +69,7 @@ if (Platform.OS === 'android') {
|
||||
skipCA,
|
||||
extendedMode,
|
||||
usePacePolling,
|
||||
sessionId,
|
||||
);
|
||||
// iOS native returns a JSON string; normalize to object.
|
||||
try {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
108
app/src/utils/proving/statusHandlers.ts
Normal file
108
app/src/utils/proving/statusHandlers.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export interface StatusHandlerResult {
|
||||
shouldDisconnect: boolean;
|
||||
stateUpdate?: {
|
||||
error_code?: string;
|
||||
reason?: string;
|
||||
socketConnection?: null;
|
||||
};
|
||||
actorEvent?: {
|
||||
type: 'PROVE_FAILURE' | 'PROVE_SUCCESS';
|
||||
};
|
||||
analytics?: Array<{
|
||||
event: string;
|
||||
data?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure functions for handling Socket.IO status messages
|
||||
* These can be tested independently without mocking complex dependencies
|
||||
*/
|
||||
export interface StatusMessage {
|
||||
status: number;
|
||||
error_code?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine actions to take based on status code
|
||||
*/
|
||||
export function handleStatusCode(
|
||||
data: StatusMessage,
|
||||
circuitType: string,
|
||||
): StatusHandlerResult {
|
||||
const result: StatusHandlerResult = {
|
||||
shouldDisconnect: false,
|
||||
analytics: [],
|
||||
};
|
||||
|
||||
// Failure statuses (3 or 5)
|
||||
if (data.status === 3 || data.status === 5) {
|
||||
result.shouldDisconnect = true;
|
||||
result.stateUpdate = {
|
||||
error_code: data.error_code,
|
||||
reason: data.reason,
|
||||
socketConnection: null,
|
||||
};
|
||||
result.actorEvent = { type: 'PROVE_FAILURE' };
|
||||
result.analytics = [
|
||||
{
|
||||
event: 'SOCKETIO_PROOF_FAILURE',
|
||||
data: {
|
||||
error_code: data.error_code,
|
||||
reason: data.reason,
|
||||
},
|
||||
},
|
||||
];
|
||||
return result;
|
||||
}
|
||||
|
||||
// Success status (4)
|
||||
if (data.status === 4) {
|
||||
result.shouldDisconnect = true;
|
||||
result.stateUpdate = {
|
||||
socketConnection: null,
|
||||
};
|
||||
result.actorEvent = { type: 'PROVE_SUCCESS' };
|
||||
result.analytics = [
|
||||
{
|
||||
event: 'SOCKETIO_PROOF_SUCCESS',
|
||||
},
|
||||
];
|
||||
|
||||
// Additional tracking for register circuit
|
||||
if (circuitType === 'register') {
|
||||
result.analytics.push({
|
||||
event: 'REGISTER_COMPLETED',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Other statuses - no action needed
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse incoming socket message into structured data
|
||||
*/
|
||||
export function parseStatusMessage(message: unknown): StatusMessage {
|
||||
if (typeof message === 'string') {
|
||||
try {
|
||||
return JSON.parse(message) as StatusMessage;
|
||||
} catch {
|
||||
throw new Error('Invalid JSON message received');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof message === 'object' && message !== null) {
|
||||
return message as StatusMessage;
|
||||
}
|
||||
|
||||
throw new Error('Invalid message format');
|
||||
}
|
||||
Reference in New Issue
Block a user