From 2f7ca8434a2f69c6c411825b418a6cc2ba793a85 Mon Sep 17 00:00:00 2001 From: Sishir P Date: Tue, 4 Aug 2026 20:27:08 -0400 Subject: [PATCH] feat(rayverify): identity capture flow on mobile, and fix the body limit Two things, because the second makes the first possible. The identity routes accept a base64 selfie, but the app-wide JSON body cap is 100KB, which no photo can fit. Every capture would have returned 413 before reaching the route: the feature shipped dead on arrival and no test caught it because they all posted tiny fixtures. Identity now gets its own 3MB parser mounted ahead of the global one, so the larger limit applies to that path alone rather than raising the ceiling for the whole app, and the decoded cap drops from 4MB to 2MB, which is far more than face matching needs. There is now a test that posts a realistically sized payload. The mobile flow is consent, then enrol a reference photo, then check against it, reachable from the Me tab. Order is enforced server-side too; the screen just makes it legible. Captures are compressed hard on purpose: matching needs resolution around the face, not a printable image, and a small payload uploads far more reliably from a phone in somebody's car. Oversized captures are caught on the device so a caregiver sees a retake prompt instead of a 413. Two things the screen says out loud rather than burying. A failed match never implies fraud: the likeliest cause is lighting or an angle, and an app is the wrong place to make that call. And because liveness is not built, the screen states that the check does not detect a photo of a screen or a printed picture. Withdrawing consent is one tap from the same screen, and a failed deletion says so rather than claiming the photo is gone. expo-camera runs in Expo Go on SDK 54, so this is testable on a phone without a custom native build. Liveness is what would have needed one. --- package-lock.json | 21 + packages/app/src/app.ts | 12 + .../routes/__tests__/identity-routes.test.ts | 37 ++ packages/app/src/routes/identity-routes.ts | 9 +- packages/mobile/app.json | 51 +- packages/mobile/app/identity.tsx | 2 + packages/mobile/package.json | 1 + .../src/features/identity/IdentityScreen.tsx | 466 ++++++++++++++++++ .../src/features/profile/ProfileScreen.tsx | 7 + packages/mobile/src/lib/identity.test.ts | 116 +++++ packages/mobile/src/lib/identity.ts | 121 +++++ 11 files changed, 831 insertions(+), 12 deletions(-) create mode 100644 packages/mobile/app/identity.tsx create mode 100644 packages/mobile/src/features/identity/IdentityScreen.tsx create mode 100644 packages/mobile/src/lib/identity.test.ts create mode 100644 packages/mobile/src/lib/identity.ts diff --git a/package-lock.json b/package-lock.json index 5160c482..80240a14 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 6ee061f8..c10dc5df 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 a7b84855..30c05cf8 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 e286d952..5a7c8b4c 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 a0ca02e3..5ea39f8e 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 00000000..64d007d5 --- /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 580a5fc1..67eff8a1 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 00000000..6ab7f276 --- /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 9eba4093..18bf081e 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 00000000..c79a72df --- /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; +}