diff --git a/package-lock.json b/package-lock.json index 5160c48..80240a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10672,6 +10672,26 @@ "react-native": "*" } }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -20153,6 +20173,7 @@ "axios": "^1.18.1", "eslint-config-expo": "~10.0.0", "expo": "^54.0.36", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", diff --git a/packages/app/src/app.ts b/packages/app/src/app.ts index 6ee061f..c10dc5d 100644 --- a/packages/app/src/app.ts +++ b/packages/app/src/app.ts @@ -277,6 +277,18 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } = app.use(`${prefix}/billing/webhook`, express.raw({ type: 'application/json' }), billingRoutes); } + // Identity verification carries a base64 selfie, which cannot fit the 100KB + // cap below. Mounted BEFORE the global parser (express.json is a no-op once + // a body is parsed) so the larger limit applies to this path only, rather + // than raising the ceiling for every route in the app. + // + // 3MB accommodates the 2MB decoded image cap the routes enforce plus base64's + // ~33% expansion. The route still validates the decoded size, so this is the + // outer bound, not the real limit. + for (const prefix of ['', '/api']) { + app.use(`${prefix}/identity`, express.json({ limit: '3mb' })); + } + // ---------- Body parsing with explicit size cap ---------- // 100KB is generous for our payload shapes (invite acceptance, agency // config updates, EVV punches) and prevents JSON-bomb DoS. Copilot is diff --git a/packages/app/src/routes/__tests__/identity-routes.test.ts b/packages/app/src/routes/__tests__/identity-routes.test.ts index a7b8485..30c05cf 100644 --- a/packages/app/src/routes/__tests__/identity-routes.test.ts +++ b/packages/app/src/routes/__tests__/identity-routes.test.ts @@ -253,6 +253,43 @@ describe('POST /identity/verify', () => { }); }); +describe('request body limits', () => { + it('accepts a realistically sized selfie', async () => { + // Regression: the app-wide JSON cap is 100KB, which no base64 photo can + // fit. Identity gets its own larger parser mounted ahead of it. Without + // that, every capture 413s before reaching the route and the whole + // feature is dead on arrival. + mockRepo(); + mockStorage(); + mockMatch('matched', 96); + const selfie = Buffer.alloc(600 * 1024, 7).toString('base64'); + + const res = await request(createApp()) + .post('/identity/verify') + .set('Authorization', auth()) + .send({ imageBase64: selfie }); + + expect(res.status).toBe(200); + expect(res.body.outcome).toBe('matched'); + }); + + it('rejects an image past the decoded cap', async () => { + mockRepo(); + const storage = mockStorage(); + mockMatch('matched', 96); + // Over the 2MB decoded ceiling the route enforces. + const huge = Buffer.alloc(2.2 * 1024 * 1024, 7).toString('base64'); + + const res = await request(createApp()) + .post('/identity/enroll') + .set('Authorization', auth()) + .send({ imageBase64: huge }); + + expect(res.status).toBe(400); + expect(storage.uploadDocument).not.toHaveBeenCalled(); + }); +}); + describe('GET /identity/status', () => { it('states plainly that liveness is not supported', async () => { mockRepo(); diff --git a/packages/app/src/routes/identity-routes.ts b/packages/app/src/routes/identity-routes.ts index e286d95..5a7c8b4 100644 --- a/packages/app/src/routes/identity-routes.ts +++ b/packages/app/src/routes/identity-routes.ts @@ -50,8 +50,13 @@ export const CONSENT_TEXT = [ 'stored photograph will be deleted.', ].join(' '); -/** Base64 JPEG, capped so a single request cannot be used to push large blobs. */ -const MAX_IMAGE_BYTES = 4 * 1024 * 1024; +/** + * Decoded image cap. A selfie for face matching does not need to be large: + * Rekognition wants roughly 80px of face width, and a compressed front-camera + * photo lands far under this. Kept in step with the 3MB body limit mounted for + * this path in app.ts, which allows for base64's expansion. + */ +const MAX_IMAGE_BYTES = 2 * 1024 * 1024; const imageSchema = z.object({ imageBase64: z.string().min(100), }); diff --git a/packages/mobile/app.json b/packages/mobile/app.json index a0ca02e..5ea39f8 100644 --- a/packages/mobile/app.json +++ b/packages/mobile/app.json @@ -22,49 +22,71 @@ "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeName", "NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false, - "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] }, { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeEmailAddress", "NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false, - "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] }, { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypePreciseLocation", "NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false, - "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] }, { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeUserID", "NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false, - "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] }, { "NSPrivacyCollectedDataType": "NSPrivacyCollectedDataTypeHealth", "NSPrivacyCollectedDataTypeLinked": true, "NSPrivacyCollectedDataTypeTracking": false, - "NSPrivacyCollectedDataTypePurposes": ["NSPrivacyCollectedDataTypePurposeAppFunctionality"] + "NSPrivacyCollectedDataTypePurposes": [ + "NSPrivacyCollectedDataTypePurposeAppFunctionality" + ] } ], "NSPrivacyAccessedAPITypes": [ { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryDiskSpace", - "NSPrivacyAccessedAPITypeReasons": ["85F4.1", "E174.1"] + "NSPrivacyAccessedAPITypeReasons": [ + "85F4.1", + "E174.1" + ] }, { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp", - "NSPrivacyAccessedAPITypeReasons": ["0A2A.1", "3B52.1", "C617.1"] + "NSPrivacyAccessedAPITypeReasons": [ + "0A2A.1", + "3B52.1", + "C617.1" + ] }, { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime", - "NSPrivacyAccessedAPITypeReasons": ["35F9.1"] + "NSPrivacyAccessedAPITypeReasons": [ + "35F9.1" + ] }, { "NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults", - "NSPrivacyAccessedAPITypeReasons": ["1C8F.1", "CA92.1"] + "NSPrivacyAccessedAPITypeReasons": [ + "1C8F.1", + "CA92.1" + ] } ] } @@ -103,7 +125,9 @@ { "color": "#1a5fa8", "defaultChannel": "shift-alerts-v2", - "sounds": ["./assets/sounds/shift_alarm.wav"] + "sounds": [ + "./assets/sounds/shift_alarm.wav" + ] } ], [ @@ -113,6 +137,13 @@ "isAndroidBackgroundLocationEnabled": false } ], + [ + "expo-camera", + { + "cameraPermission": "RayHealthEVV uses your camera only to take the identity photo you agreed to, so your agency can confirm it is you clocking in.", + "recordAudioAndroid": false + } + ], "expo-secure-store" ], "experiments": { diff --git a/packages/mobile/app/identity.tsx b/packages/mobile/app/identity.tsx new file mode 100644 index 0000000..64d007d --- /dev/null +++ b/packages/mobile/app/identity.tsx @@ -0,0 +1,2 @@ +import IdentityScreen from '../src/features/identity/IdentityScreen'; +export default IdentityScreen; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 580a5fc..67eff8a 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -29,6 +29,7 @@ "axios": "^1.18.1", "eslint-config-expo": "~10.0.0", "expo": "^54.0.36", + "expo-camera": "~17.0.10", "expo-constants": "~18.0.13", "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", diff --git a/packages/mobile/src/features/identity/IdentityScreen.tsx b/packages/mobile/src/features/identity/IdentityScreen.tsx new file mode 100644 index 0000000..6ab7f27 --- /dev/null +++ b/packages/mobile/src/features/identity/IdentityScreen.tsx @@ -0,0 +1,466 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { + ActivityIndicator, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native'; +import { CameraView, useCameraPermissions, type CameraCapturedPicture } from 'expo-camera'; +import { Ionicons } from '@expo/vector-icons'; +import * as Haptics from 'expo-haptics'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { useFocusEffect } from 'expo-router'; +import apiClient from '../../lib/api-client'; +import ScreenHeader from '../common/ScreenHeader'; +import ErrorRetry from '../common/ErrorRetry'; +import { SkeletonList } from '../common/Skeleton'; +import { showAppAlert } from '../common/alerts/appAlert'; +import { alpha, colors, radii, shadow, typography } from '../common/tokens'; +import { + describeOutcome, + isUploadableCapture, + stepFor, + type IdentityOutcome, + type IdentityStatus, +} from '../../lib/identity'; + +/** + * RayVerify identity verification. + * + * Consent, then enroll a reference photo, then check it. The order is enforced + * server-side too; this screen just makes it legible. + * + * The screen is deliberately plain about two things a caregiver deserves to + * know before pointing a camera at their own face: exactly what is being + * stored, and that they can delete it whenever they like. It is also plain + * that a match confirms who is in the photo, not that a person was physically + * present, because liveness is not built yet and implying otherwise would be + * the easiest lie for this screen to tell. + */ + +const TONE_COLOR = { + success: colors.success, + warning: colors.amber, + error: colors.danger, + info: colors.brandBlue, +} as const; + +const TONE_ICON = { + success: 'checkmark-circle', + warning: 'alert-circle', + error: 'close-circle', + info: 'information-circle', +} as const; + +type Mode = 'idle' | 'capturing-enroll' | 'capturing-verify'; + +interface VerifyResult { + outcome: IdentityOutcome; + similarity: number | null; +} + +export default function IdentityScreen() { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [mode, setMode] = useState('idle'); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); + const [permission, requestPermission] = useCameraPermissions(); + const cameraRef = useRef(null); + + const load = useCallback(async () => { + try { + const res = await apiClient.get('/api/identity/status'); + setStatus(res.data); + setError(null); + } catch { + setError('Could not load your identity settings.'); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + void load(); + }, [load]), + ); + + const step = stepFor(status); + + const openCamera = async (next: Exclude) => { + if (!permission?.granted) { + const granted = await requestPermission(); + if (!granted.granted) { + showAppAlert( + 'Camera access is needed', + 'RayHealth needs the camera to take your identity photo. You can turn it on in Settings.', + undefined, + { variant: 'info' }, + ); + return; + } + } + setResult(null); + setMode(next); + }; + + const capture = async () => { + if (!cameraRef.current || busy) return; + setBusy(true); + try { + const photo: CameraCapturedPicture | undefined = await cameraRef.current.takePictureAsync({ + // Compressed hard on purpose: face matching needs resolution around + // the face, not a printable image, and a smaller payload uploads far + // more reliably on a phone in somebody's car. + quality: 0.5, + base64: true, + skipProcessing: false, + }); + + if (!isUploadableCapture(photo?.base64)) { + showAppAlert('That photo did not save properly', 'Please take it again.', undefined, { + variant: 'error', + }); + return; + } + + if (mode === 'capturing-enroll') { + await apiClient.post('/api/identity/enroll', { imageBase64: photo?.base64 }); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + setMode('idle'); + await load(); + return; + } + + const res = await apiClient.post('/api/identity/verify', { + imageBase64: photo?.base64, + }); + setResult(res.data); + setMode('idle'); + void Haptics.notificationAsync( + res.data?.outcome === 'matched' + ? Haptics.NotificationFeedbackType.Success + : Haptics.NotificationFeedbackType.Warning, + ); + } catch (err) { + const code = (err as { response?: { data?: { code?: string; outcome?: string } } })?.response + ?.data; + if (code?.code === 'CONSENT_REQUIRED') { + showAppAlert('Consent needed first', 'Please agree to identity checks before taking a photo.', undefined, { + variant: 'info', + }); + setMode('idle'); + await load(); + return; + } + if (code?.outcome === 'not_enrolled') { + setResult({ outcome: 'not_enrolled', similarity: null }); + setMode('idle'); + return; + } + showAppAlert('Could not send that photo', 'Please check your connection and try again.', undefined, { + variant: 'error', + }); + } finally { + setBusy(false); + } + }; + + const giveConsent = async () => { + if (!status) return; + setBusy(true); + try { + await apiClient.post('/api/identity/consent', { + consentVersion: status.currentConsentVersion, + }); + void Haptics.selectionAsync(); + await load(); + } catch { + showAppAlert('Could not save your agreement', 'Please try again.', undefined, { variant: 'error' }); + } finally { + setBusy(false); + } + }; + + const withdraw = () => { + showAppAlert( + 'Delete your identity photo?', + 'Your stored photo will be deleted and identity checks will stop. You can set it up again any time.', + [ + { text: 'Keep it' }, + { + text: 'Delete', + onPress: () => { + void (async () => { + setBusy(true); + try { + await apiClient.delete('/api/identity/consent'); + setResult(null); + await load(); + } catch { + showAppAlert( + 'Could not delete your photo', + 'Nothing was changed. Please try again, or contact your agency.', + undefined, + { variant: 'error' }, + ); + } finally { + setBusy(false); + } + })(); + }, + }, + ], + { variant: 'warning' }, + ); + }; + + // ── Camera ─────────────────────────────────────────────────────────────── + if (mode !== 'idle') { + return ( + + + + + {mode === 'capturing-enroll' + ? 'Look straight at the camera in good light. This becomes your reference photo.' + : 'Look straight at the camera to check against your reference photo.'} + + + setMode('idle')} + style={styles.cameraCancel} + accessibilityRole="button" + accessibilityLabel="Cancel" + > + Cancel + + void capture()} + disabled={busy} + style={({ pressed }) => [styles.shutter, pressed && { opacity: 0.85 }]} + accessibilityRole="button" + accessibilityLabel="Take photo" + > + {busy ? : } + + + + + + ); + } + + const outcomeCopy = result ? describeOutcome(result.outcome, result.similarity) : null; + + return ( + + + + {loading ? ( + + ) : error || !status ? ( + { setLoading(true); void load(); }} + /> + ) : ( + <> + {!status.configured ? ( + + + + Your agency has not switched identity checks on yet. You can still set yours up + now, and it will start working once they do. + + + ) : null} + + {step === 'consent' ? ( + + Before we start + {status.consentText} + void giveConsent()} + disabled={busy} + style={({ pressed }) => [styles.primaryBtn, pressed && !busy && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="I agree to identity checks" + > + {busy ? ( + + ) : ( + I agree + )} + + + You can withdraw this at any time and your photo will be deleted. + + + ) : null} + + {step === 'enroll' ? ( + + Take your reference photo + + One clear photo of your face. Later photos are compared against this one. + + void openCamera('capturing-enroll')} + style={({ pressed }) => [styles.primaryBtn, pressed && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="Open the camera to take your reference photo" + > + Open camera + + + ) : null} + + {step === 'ready' ? ( + + + + Your reference photo is on file. + + void openCamera('capturing-verify')} + style={({ pressed }) => [styles.primaryBtn, pressed && { opacity: 0.9 }]} + accessibilityRole="button" + accessibilityLabel="Check my identity now" + > + Check it now + + void openCamera('capturing-enroll')} + style={styles.secondaryBtn} + accessibilityRole="button" + accessibilityLabel="Replace my reference photo" + > + Replace my photo + + + ) : null} + + {outcomeCopy ? ( + + + + + {outcomeCopy.title} + + {outcomeCopy.detail} + + + ) : null} + + {/* Stated plainly rather than buried: a match says who is in the + photo, not that somebody was really there. */} + {!status.livenessSupported ? ( + + This check confirms the face in the photo matches your reference photo. It does not + yet detect whether a photo was taken of a screen or a printed picture. + + ) : null} + + {status.consented ? ( + + Withdraw and delete my photo + + ) : null} + + )} + + + ); +} + +const styles = StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.screenBg }, + body: { flex: 1 }, + bodyContent: { padding: 16, paddingBottom: 48, gap: 12 }, + card: { + backgroundColor: colors.cardBg, + borderRadius: radii.lg, + padding: 18, + gap: 12, + ...shadow.card, + }, + cardTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, + cardHint: { ...typography.caption, color: colors.textSecondary, lineHeight: 17 }, + consentText: { fontSize: 14, color: colors.textSecondary, lineHeight: 21 }, + fineprint: { ...typography.caption, color: colors.textMuted, textAlign: 'center' }, + primaryBtn: { + backgroundColor: colors.brandBlue, + borderRadius: radii.md, + paddingVertical: 14, + alignItems: 'center', + }, + primaryBtnText: { color: colors.onGradient, fontWeight: '700', fontSize: 15 }, + secondaryBtn: { alignItems: 'center', paddingVertical: 6 }, + secondaryBtnText: { ...typography.caption, color: colors.brandBlue, fontWeight: '700' }, + enrolledRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + enrolledText: { fontSize: 14, color: colors.textPrimary, fontWeight: '600' }, + notice: { flexDirection: 'row', gap: 10, padding: 14, borderRadius: radii.md }, + noticeText: { ...typography.caption, color: colors.textPrimary, flex: 1, lineHeight: 16 }, + result: { flexDirection: 'row', gap: 12, padding: 16, borderRadius: radii.lg, alignItems: 'flex-start' }, + resultBody: { flex: 1, gap: 3 }, + resultTitle: { fontSize: 15, fontWeight: '700' }, + resultDetail: { ...typography.caption, color: colors.textSecondary, lineHeight: 17 }, + limitationText: { + ...typography.caption, + color: colors.textMuted, + lineHeight: 16, + paddingHorizontal: 4, + }, + withdrawBtn: { alignItems: 'center', paddingVertical: 14 }, + withdrawText: { ...typography.caption, color: colors.danger, fontWeight: '700' }, + cameraScreen: { flex: 1, backgroundColor: '#000' }, + camera: { flex: 1 }, + cameraOverlay: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingBottom: 44, + paddingTop: 18, + paddingHorizontal: 20, + gap: 18, + backgroundColor: 'rgba(0,0,0,0.45)', + }, + cameraHint: { + color: '#fff', + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + }, + cameraActions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + cameraCancel: { width: 70 }, + cameraCancelText: { color: '#fff', fontSize: 15 }, + shutter: { + width: 70, + height: 70, + borderRadius: 35, + borderWidth: 4, + borderColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + }, + shutterInner: { width: 54, height: 54, borderRadius: 27, backgroundColor: '#fff' }, +}); diff --git a/packages/mobile/src/features/profile/ProfileScreen.tsx b/packages/mobile/src/features/profile/ProfileScreen.tsx index 9eba409..18bf081 100644 --- a/packages/mobile/src/features/profile/ProfileScreen.tsx +++ b/packages/mobile/src/features/profile/ProfileScreen.tsx @@ -208,6 +208,13 @@ export default function ProfileScreen() { subtitle="Estimated pay from verified visits" onPress={() => router.push('/earnings')} /> + router.push('/identity')} + /> = {}): IdentityStatus { + return { + configured: true, + consented: false, + enrolled: false, + enrolledAt: null, + consentText: 'text', + currentConsentVersion: 'v1', + livenessSupported: false, + ...overrides, + }; +} + +describe('stepFor', () => { + it('waits while status is unknown', () => { + expect(stepFor(null)).toBe('loading'); + }); + + it('asks for consent before anything else', () => { + expect(stepFor(status())).toBe('consent'); + // Even if somehow already enrolled, consent comes first; the server + // enforces the same order and would 403 a client that skipped ahead. + expect(stepFor(status({ enrolled: true }))).toBe('consent'); + }); + + it('asks to enroll once consent is given', () => { + expect(stepFor(status({ consented: true }))).toBe('enroll'); + }); + + it('is ready once consented and enrolled', () => { + expect(stepFor(status({ consented: true, enrolled: true }))).toBe('ready'); + }); + + it('still walks the flow when the provider is switched off', () => { + // The caregiver can consent and enroll before an agency finishes setup; + // only the check itself needs the provider. + expect(stepFor(status({ configured: false }))).toBe('consent'); + expect(stepFor(status({ configured: false, consented: true }))).toBe('enroll'); + }); +}); + +describe('describeOutcome', () => { + it('reports a match with its similarity', () => { + const copy = describeOutcome('matched', 97); + expect(copy.tone).toBe('success'); + expect(copy.detail).toContain('97%'); + expect(copy.retryable).toBe(false); + }); + + it('handles a match with no similarity figure', () => { + expect(describeOutcome('matched', null).detail).not.toContain('null'); + }); + + it('does not accuse the caregiver when a match fails', () => { + const copy = describeOutcome('not_matched', 40); + const text = `${copy.title} ${copy.detail}`.toLowerCase(); + // The likeliest cause is lighting, not fraud, and the app is the wrong + // place to make that call. + for (const word of ['fraud', 'imposter', 'someone else', 'not you']) { + expect(text).not.toContain(word); + } + expect(copy.retryable).toBe(true); + }); + + it('treats a missing face as a retake, not a failure', () => { + const copy = describeOutcome('no_face', null); + expect(copy.retryable).toBe(true); + expect(copy.tone).toBe('warning'); + }); + + it('never presents an unconfigured provider as a result', () => { + const copy = describeOutcome('not_configured', null); + expect(copy.tone).toBe('info'); + expect(copy.detail).toContain('Nothing was checked'); + expect(copy.retryable).toBe(false); + }); + + it('points an unenrolled caregiver at enrollment rather than retrying', () => { + expect(describeOutcome('not_enrolled', null).retryable).toBe(false); + }); + + it('offers a retry on a transient error', () => { + expect(describeOutcome('error', null).retryable).toBe(true); + }); +}); + +describe('isUploadableCapture', () => { + it('rejects a missing or empty capture', () => { + expect(isUploadableCapture(undefined)).toBe(false); + expect(isUploadableCapture(null)).toBe(false); + expect(isUploadableCapture('')).toBe(false); + }); + + it('accepts a normal-sized photo', () => { + expect(isUploadableCapture('a'.repeat(500 * 1024))).toBe(true); + }); + + it('rejects one past what the server will decode', () => { + // Better a retake prompt here than a confusing 413 from the API. + expect(isUploadableCapture('a'.repeat(MAX_UPLOAD_BASE64_CHARS + 1))).toBe(false); + }); + + it('leaves headroom under the route body limit', () => { + // 3MB parser cap on the server; the base64 ceiling must sit below it. + expect(MAX_UPLOAD_BASE64_CHARS).toBeLessThan(3 * 1024 * 1024); + }); +}); diff --git a/packages/mobile/src/lib/identity.ts b/packages/mobile/src/lib/identity.ts new file mode 100644 index 0000000..c79a72d --- /dev/null +++ b/packages/mobile/src/lib/identity.ts @@ -0,0 +1,121 @@ +/** + * Pure helpers for the RayVerify identity screen: which step to show, and how + * to describe an outcome to a caregiver. No React Native imports, so it is + * unit-testable. + */ + +export type IdentityOutcome = + | 'matched' + | 'not_matched' + | 'no_face' + | 'not_enrolled' + | 'error' + | 'not_configured'; + +export interface IdentityStatus { + /** Whether a real matching provider is wired up server-side. */ + configured: boolean; + consented: boolean; + enrolled: boolean; + enrolledAt: string | null; + consentText: string; + currentConsentVersion: string; + /** False until a liveness provider ships. Never inferred client-side. */ + livenessSupported: boolean; +} + +/** Which panel the screen should show, derived from server state alone. */ +export type IdentityStep = 'loading' | 'consent' | 'enroll' | 'ready'; + +export function stepFor(status: IdentityStatus | null): IdentityStep { + if (!status) return 'loading'; + // Consent first, always. Nothing biometric is captured before it, and the + // server enforces the same order, so a client that skipped ahead would only + // earn a 403. + if (!status.consented) return 'consent'; + if (!status.enrolled) return 'enroll'; + return 'ready'; +} + +export interface OutcomeCopy { + title: string; + detail: string; + tone: 'success' | 'warning' | 'error' | 'info'; + /** Whether retaking the photo is the useful next action. */ + retryable: boolean; +} + +/** + * Plain-language result copy. + * + * Two rules here. A failed match never accuses the caregiver of anything: the + * likeliest cause is lighting or an angle, not fraud, and the app is the wrong + * place to make that call. And `not_configured` is never dressed up as a + * result, because the check did not happen. + */ +export function describeOutcome(outcome: IdentityOutcome, similarity: number | null): OutcomeCopy { + switch (outcome) { + case 'matched': + return { + title: 'Identity confirmed', + detail: + similarity != null + ? `Matched your enrolled photo (${similarity}% similarity).` + : 'Matched your enrolled photo.', + tone: 'success', + retryable: false, + }; + case 'not_matched': + return { + title: 'We could not confirm it is you', + detail: + 'Try again in better light, facing the camera straight on. If it keeps failing, contact your agency.', + tone: 'warning', + retryable: true, + }; + case 'no_face': + return { + title: 'No face detected', + detail: 'Make sure your whole face is in the frame and well lit, then take the photo again.', + tone: 'warning', + retryable: true, + }; + case 'not_enrolled': + return { + title: 'No photo on file yet', + detail: 'Take your enrollment photo first, then you can check it any time.', + tone: 'info', + retryable: false, + }; + case 'not_configured': + return { + title: 'Identity checks are not switched on', + detail: + 'Your agency has not enabled identity verification yet. Nothing was checked and nothing was stored.', + tone: 'info', + retryable: false, + }; + case 'error': + default: + return { + title: 'Something went wrong', + detail: 'We could not run the check just now. Please try again in a moment.', + tone: 'error', + retryable: true, + }; + } +} + +/** + * Guard the payload before it leaves the device. + * + * The server caps the decoded image at 2MB and the route's body parser at 3MB. + * Catching an oversized capture here turns a confusing 413 into a retake + * prompt. + */ +export const MAX_UPLOAD_BASE64_CHARS = Math.floor((2 * 1024 * 1024 * 4) / 3); + +export function isUploadableCapture(base64: string | undefined | null): boolean { + if (!base64) return false; + return base64.length > 0 && base64.length <= MAX_UPLOAD_BASE64_CHARS; +}