From 4d3540f06d03b4104aae48ca1f1e7e935e8fbc26 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Tue, 28 Oct 2025 19:57:47 -0300 Subject: [PATCH 1/4] example: overhaul App.tsx to use hooks-based playground UI Replace the legacy sample with a simplified "Sensitive Info Playground" example that leverages useSecureStorage and useSecurityAvailability. Clean up imports, reduce access-control options to 'open' and 'biometric', wire up save/reveal/delete/clear/refresh handlers, update ActionButton behaviour, and refresh styles/layout to match the streamlined UI. --- example/App.tsx | 1621 +++++++++++++++++------------------------------ 1 file changed, 587 insertions(+), 1034 deletions(-) diff --git a/example/App.tsx b/example/App.tsx index b58b8730..03c8a00a 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -1,1048 +1,601 @@ -import React, { - useCallback, - useEffect, - useMemo, - useState, - type ReactNode, -} from 'react'; +import React, { useCallback, useMemo, useState } from 'react' import { - SafeAreaView, - ScrollView, - View, - Text, - TextInput, - StyleSheet, - Pressable, - Switch, - Platform, - StatusBar, - type StyleProp, - type ViewStyle, -} from 'react-native'; + ActivityIndicator, + FlatList, + Platform, + Pressable, + SafeAreaView, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native' import { - useSecureStorage, - useSecurityAvailability, - type AccessControl, - getItem, -} from 'react-native-sensitive-info'; - -const DEFAULT_SERVICE = 'demo-service'; -const DEFAULT_KEY = 'demo-secret'; -const DEFAULT_VALUE = 'very-secret-value'; - -const ACCESS_CONTROL_OPTIONS: Array<{ - value: AccessControl; - label: string; - description: string; + getItem, + useSecureStorage, + useSecurityAvailability, + type AccessControl, +} from 'react-native-sensitive-info' + +type ModeKey = 'open' | 'biometric' + +const ACCESS_MODES: Array<{ + key: ModeKey + label: string + description: string + accessControl: AccessControl }> = [ - { - value: 'secureEnclaveBiometry', - label: 'Secure Enclave', - description: 'Biometrics with hardware isolation (best effort fallback).', - }, - { - value: 'biometryCurrentSet', - label: 'Biometry (current set)', - description: 'Requires the current biometric enrollment.', - }, - { - value: 'biometryAny', - label: 'Biometry (any)', - description: 'Any enrolled biometric may unlock the value.', - }, - { - value: 'devicePasscode', - label: 'Device credential', - description: 'Falls back to passcode or system credential.', - }, - { - value: 'none', - label: 'None', - description: 'No user presence required. Least secure.', - }, -]; + { + key: 'open', + label: 'No Lock', + description: 'Stores the value without requiring authentication.', + accessControl: 'none', + }, + { + key: 'biometric', + label: 'Biometric Lock', + description: 'Requires the current biometric enrollment to unlock.', + accessControl: 'biometryCurrentSet', + }, +] function formatError(error: unknown): string { - if (error instanceof Error) { - return `${error.name}: ${error.message}`; - } - return `Unexpected error: ${JSON.stringify(error)}`; -} - -interface ActionButtonProps { - label: string; - onPress: () => void | Promise; - disabled?: boolean; - style?: StyleProp; -} - -function ActionButton({ label, onPress, disabled, style }: ActionButtonProps) { - const handlePress = () => { - if (disabled) { - return; - } - - const maybePromise = onPress(); - - if ( - maybePromise && - typeof (maybePromise as Promise).then === 'function' - ) { - void (maybePromise as Promise); - } - }; - - return ( - [ - styles.button, - style, - pressed && !disabled && styles.buttonPressed, - disabled && styles.buttonDisabled, - ]} - > - {label} - - ); -} - -interface SectionProps { - title: string; - subtitle?: string; - actions?: ReactNode; - children: ReactNode; - style?: StyleProp; + if (error instanceof Error) { + return `${error.name}: ${error.message}` + } + return 'Something went wrong. Please try again.' } -function Section({ title, subtitle, actions, children, style }: SectionProps) { - return ( - - - - - {title} - {subtitle ? ( - {subtitle} - ) : null} - - {actions} - - {children} - - - ); +const DEFAULT_SERVICE = 'demo-safe' +const DEFAULT_KEY = 'favorite-color' +const DEFAULT_SECRET = 'ultramarine' + +const App: React.FC = () => { + const [service, setService] = useState(DEFAULT_SERVICE) + const [keyName, setKeyName] = useState(DEFAULT_KEY) + const [secret, setSecret] = useState(DEFAULT_SECRET) + const [mode, setMode] = useState('open') + const [status, setStatus] = useState('Ready to tuck away a secret.') + const [pending, setPending] = useState(false) + + const trimmedService = useMemo(() => { + const next = service.trim() + return next.length > 0 ? next : DEFAULT_SERVICE + }, [service]) + + const selectedMode = useMemo( + () => ACCESS_MODES.find((candidate) => candidate.key === mode) || ACCESS_MODES[0], + [mode] + ) + + const authenticationPrompt = useMemo(() => { + if (selectedMode.key !== 'biometric') { + return undefined + } + return { + title: 'Unlock your secret', + subtitle: 'Biometric authentication is required to continue', + description: 'This demo stores data behind your biometric enrollment.', + cancel: 'Cancel', + } + }, [selectedMode.key]) + + const secureOptions = useMemo( + () => ({ + service: trimmedService, + accessControl: selectedMode.accessControl, + authenticationPrompt, + includeValues: true, + }), + [trimmedService, selectedMode.accessControl, authenticationPrompt] + ) + + const { + items, + isLoading, + error, + saveSecret, + removeSecret, + clearAll, + refreshItems, + } = useSecureStorage(secureOptions) + + const { data: availability } = useSecurityAvailability() + const biometricAvailable = availability?.biometry ?? false + + const handleSave = useCallback(async () => { + const normalizedKey = keyName.trim() + if (normalizedKey.length === 0) { + setStatus('Please provide a key before saving.') + return + } + + setPending(true) + try { + const result = await saveSecret(normalizedKey, secret) + if (result.success) { + setStatus('Secret saved securely.') + } else { + setStatus(result.error?.message ?? 'Unable to save the secret.') + } + } catch (err) { + setStatus(formatError(err)) + } finally { + setPending(false) + } + }, [keyName, saveSecret, secret]) + + const handleReveal = useCallback(async () => { + const normalizedKey = keyName.trim() + if (normalizedKey.length === 0) { + setStatus('Provide the key you would like to reveal.') + return + } + + setPending(true) + try { + const item = await getItem(normalizedKey, { + service: trimmedService, + accessControl: selectedMode.accessControl, + authenticationPrompt, + includeValue: true, + }) + + if (item?.value) { + setStatus(`Secret for "${normalizedKey}" → ${item.value}`) + } else { + setStatus('That key has no stored value yet.') + } + } catch (err) { + setStatus(formatError(err)) + } finally { + setPending(false) + } + }, [authenticationPrompt, keyName, selectedMode.accessControl, trimmedService]) + + const handleRemove = useCallback(async () => { + const normalizedKey = keyName.trim() + if (normalizedKey.length === 0) { + setStatus('Provide the key you would like to forget.') + return + } + + setPending(true) + try { + const result = await removeSecret(normalizedKey) + setStatus(result.success ? 'Secret deleted.' : 'Secret could not be deleted.') + } catch (err) { + setStatus(formatError(err)) + } finally { + setPending(false) + } + }, [keyName, removeSecret]) + + const handleClear = useCallback(async () => { + setPending(true) + try { + const result = await clearAll() + setStatus(result.success ? 'All secrets cleared for this service.' : 'Nothing to clear.') + } catch (err) { + setStatus(formatError(err)) + } finally { + setPending(false) + } + }, [clearAll]) + + const handleRefresh = useCallback(async () => { + setPending(true) + try { + await refreshItems() + setStatus('Inventory refreshed.') + } catch (err) { + setStatus(formatError(err)) + } finally { + setPending(false) + } + }, [refreshItems]) + + return ( + + + + Sensitive Info Playground + + Store a small secret, lock it with biometrics if you like, and review the + inventory below. + + + + + Secret details + + Service name + + + Key + + + Secret value + + + + Guard it your way + + {ACCESS_MODES.map((option) => { + const disabled = option.key === 'biometric' && !biometricAvailable + const active = option.key === selectedMode.key + + return ( + { + if (!disabled) { + setMode(option.key) + } + }} + style={({ pressed }) => [ + styles.modeTile, + active && styles.modeTileActive, + disabled && styles.modeTileDisabled, + pressed && !disabled && styles.modeTilePressed, + ]} + > + + {option.label} + + + {option.description} + + {disabled ? ( + Biometry unavailable + ) : null} + + ) + })} + + {availability ? ( + + Biometry • {availability.biometry ? 'Ready' : 'Unavailable'} · Secure Enclave •{' '} + {availability.secureEnclave ? 'Ready' : 'Unavailable'} + + ) : null} + + + + Actions + + + + + + + + {error ? {error.message} : null} + + {status} + + + + + + Secrets for “{trimmedService}”{' '} + {items.length} + + {isLoading ? ( + + + Fetching secrets… + + ) : items.length === 0 ? ( + Nothing stored yet. Save a secret to see it here. + ) : ( + `${item.service}-${item.key}`} + renderItem={({ item }) => ( + + {item.key} + {item.value ? ( + {item.value} + ) : ( + Locked value + )} + Access · {item.metadata.accessControl} + Stored · {new Date(item.metadata.timestamp * 1000).toLocaleString()} + + )} + ItemSeparatorComponent={() => } + scrollEnabled={false} + /> + )} + + + + ) } -interface FieldProps { - label: string; - helper?: string; - children: ReactNode; -} - -function Field({ label, helper, children }: FieldProps) { - return ( - - {label} - {helper ? {helper} : null} - {children} - - ); -} - -interface ToggleRowProps { - label: string; - helper?: string; - value: boolean; - onValueChange: (next: boolean) => void; -} - -function ToggleRow({ label, helper, value, onValueChange }: ToggleRowProps) { - return ( - - - {label} - {helper ? {helper} : null} - - - - ); +interface ActionButtonProps { + label: string + onPress: () => void | Promise + loading?: boolean + primary?: boolean } -function App(): React.JSX.Element { - // Configuration state - const [service, setService] = useState(DEFAULT_SERVICE); - const [keyName, setKeyName] = useState(DEFAULT_KEY); - const [secret, setSecret] = useState(DEFAULT_VALUE); - const [selectedAccessControl, setSelectedAccessControl] = - useState('secureEnclaveBiometry'); - const [includeValues, setIncludeValues] = useState(true); - const [includeValueOnGet, setIncludeValueOnGet] = useState(true); - const [iosSynchronizable, setIosSynchronizable] = useState(false); - const [usePrompt, setUsePrompt] = useState(true); - const [keychainGroup, setKeychainGroup] = useState(''); - const [lastResult, setLastResult] = useState( - 'Ready to interact with the secure store.', - ); - const [pending, setPending] = useState(false); - - // Use hooks for reactive data management - const { - data: capabilities, - isLoading: capabilitiesLoading, - refetch: refetchCapabilities, - } = useSecurityAvailability(); - - const normalizedService = useMemo(() => { - const trimmed = service.trim(); - return trimmed.length > 0 ? trimmed : DEFAULT_SERVICE; - }, [service]); - - const normalizedKeychainGroup = useMemo(() => { - const trimmed = keychainGroup.trim(); - return trimmed.length > 0 ? trimmed : undefined; - }, [keychainGroup]); - - const baseOptions = useMemo( - () => ({ - service: normalizedService, - accessControl: selectedAccessControl, - iosSynchronizable: iosSynchronizable ? true : undefined, - keychainGroup: normalizedKeychainGroup, - authenticationPrompt: usePrompt - ? { - title: 'Authenticate to continue', - subtitle: 'Demo prompt provided by the sample app', - description: - 'Sensitive data access requires local authentication on secured keys.', - cancel: 'Cancel', - } - : undefined, - }), - [ - iosSynchronizable, - normalizedKeychainGroup, - normalizedService, - selectedAccessControl, - usePrompt, - ], - ); - - const storageOptions = useMemo( - () => ({ - ...baseOptions, - includeValues, - }), - [baseOptions, includeValues], - ); - - const { - items, - isLoading: itemsLoading, - error: storageError, - saveSecret: hookSaveSecret, - removeSecret: hookRemoveSecret, - clearAll: hookClearAll, - refreshItems, - } = useSecureStorage(storageOptions); - - const isOptionAvailable = useCallback( - (value: AccessControl) => { - if (!capabilities) { - return true; - } - - switch (value) { - case 'secureEnclaveBiometry': - return capabilities.secureEnclave || capabilities.strongBox; - case 'biometryCurrentSet': - case 'biometryAny': - return capabilities.biometry; - case 'devicePasscode': - return capabilities.deviceCredential; - case 'none': - default: - return true; - } - }, - [capabilities], - ); - - useEffect(() => { - if (!capabilities) { - return; - } - - if (isOptionAvailable(selectedAccessControl)) { - return; - } - - const fallback = ACCESS_CONTROL_OPTIONS.find(option => - isOptionAvailable(option.value), - ); - - if (fallback) { - setSelectedAccessControl(fallback.value); - } - }, [capabilities, isOptionAvailable, selectedAccessControl]); - - const execute = useCallback( - async (task: () => Promise) => { - if (pending) { - return; - } - setPending(true); - try { - await task(); - } finally { - setPending(false); - } - }, - [pending], - ); - - const handleSetItem = useCallback(async () => { - await execute(async () => { - try { - const { success, error } = await hookSaveSecret(keyName, secret); - if (success) { - setLastResult( - `Saved secret with access control policy: ${selectedAccessControl}`, - ); - await refreshItems(); - } else { - setLastResult(`Error: ${error?.message || 'Failed to save'}`); - } - } catch (error) { - setLastResult(formatError(error)); - } - }); - }, [ - keyName, - secret, - selectedAccessControl, - hookSaveSecret, - refreshItems, - execute, - ]); - - const handleGetItem = useCallback(async () => { - await execute(async () => { - try { - const item = await getItem(keyName, { - ...baseOptions, - includeValue: includeValueOnGet, - }); - if (item) { - setLastResult(`Fetched item:\n${JSON.stringify(item, null, 2)}`); - } else { - setLastResult('No entry found for the provided key.'); - } - } catch (error) { - setLastResult(formatError(error)); - } - }); - }, [keyName, baseOptions, includeValueOnGet, execute]); - - const handleDeleteItem = useCallback(async () => { - await execute(async () => { - try { - const { success } = await hookRemoveSecret(keyName); - if (success) { - setLastResult('Secret deleted.'); - await refreshItems(); - } else { - setLastResult('Nothing deleted (key was absent).'); - } - } catch (error) { - setLastResult(formatError(error)); - } - }); - }, [keyName, hookRemoveSecret, refreshItems, execute]); - - const handleClearService = useCallback(async () => { - await execute(async () => { - try { - const { success } = await hookClearAll(); - if (success) { - setLastResult(`Cleared service "${baseOptions.service}"`); - await refreshItems(); - } - } catch (error) { - setLastResult(formatError(error)); - } - }); - }, [baseOptions.service, hookClearAll, refreshItems, execute]); - - const handleRefresh = useCallback(async () => { - await execute(async () => { - await refetchCapabilities(); - await refreshItems(); - }); - }, [execute, refetchCapabilities, refreshItems]); - - return ( - - - - - Sensitive Info Playground - - Explore secure storage flows, test authentication policies, and - inspect metadata in a refined light experience. - - - - Light theme - - - Biometric ready - - - - - - Tip for hardware testing - - Simulators rarely expose Secure Enclave, StrongBox, or full - biometric flows. Validate critical journeys on a physical device to - mirror production behaviour. - - - -
- } - > - {capabilitiesLoading ? ( - Detecting capabilities... - ) : capabilities ? ( - - - Secure Enclave - - {capabilities.secureEnclave ? 'Available' : 'Unavailable'} - - - - StrongBox - - {capabilities.strongBox ? 'Available' : 'Unavailable'} - - - - Biometry - - {capabilities.biometry ? 'Available' : 'Unavailable'} - - - - Device credential - - {capabilities.deviceCredential ? 'Available' : 'Unavailable'} - - - - ) : ( - - Tap refresh to fetch the security profile for this device. - - )} -
- -
- - - - - - - - - -
- -
- - The native layer automatically upgrades to the strongest guard this - device supports. Options shown in grey are unavailable on the - current hardware. - - - {ACCESS_CONTROL_OPTIONS.map(option => { - const selected = option.value === selectedAccessControl; - const available = isOptionAvailable(option.value); - const disabled = !available; - return ( - { - if (!available) { - return; - } - setSelectedAccessControl(option.value); - }} - style={({ pressed }) => [ - styles.accessOption, - selected && styles.accessOptionSelected, - pressed && !disabled && styles.accessOptionPressed, - disabled && styles.accessOptionDisabled, - ]} - > - - {option.label} - - - {option.description} - - {disabled ? ( - - Unavailable on this device - - ) : null} - - ); - })} - - - - - - - - - - -
- -
- - - - - - - -
- -
- {items.length === 0 ? ( - - Nothing stored yet. Save a secret to see it appear here. - - ) : ( - items.map(item => ( - - {item.key} - Service · {item.service} - {includeValues && item.value != null ? ( - {item.value} - ) : null} - - - Security level · {item.metadata.securityLevel} - - - Access control · {item.metadata.accessControl} - - - Backend · {item.metadata.backend} - - - Stored at ·{' '} - {new Date(item.metadata.timestamp * 1000).toLocaleString()} - - - - )) - )} -
- -
- - {lastResult} - -
-
-
- ); +function ActionButton({ label, onPress, loading, primary }: ActionButtonProps) { + const [busy, setBusy] = useState(false) + + const handlePress = useCallback(() => { + if (busy || loading) { + return + } + + const result = onPress() + if (result && typeof (result as Promise).then === 'function') { + setBusy(true) + void (result as Promise).finally(() => setBusy(false)) + } + }, [busy, loading, onPress]) + + const disabled = busy || loading + + return ( + [ + styles.actionButton, + primary && styles.actionButtonPrimary, + pressed && !disabled && styles.actionButtonPressed, + disabled && styles.actionButtonDisabled, + ]} + > + + {label} + + + ) } const styles = StyleSheet.create({ - safeArea: { - flex: 1, - backgroundColor: '#f6f7fb', - }, - scrollContent: { - paddingHorizontal: 24, - paddingVertical: 24, - paddingBottom: 48, - }, - header: { - marginBottom: 24, - }, - title: { - color: '#111827', - fontSize: 28, - fontWeight: '700', - }, - subtitle: { - color: '#4b5563', - fontSize: 16, - lineHeight: 24, - marginTop: 8, - }, - badgeRow: { - flexDirection: 'row', - flexWrap: 'wrap', - marginTop: 16, - }, - badge: { - backgroundColor: '#ede9fe', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 999, - marginRight: 8, - marginBottom: 8, - }, - badgeText: { - color: '#5b21b6', - fontWeight: '600', - fontSize: 12, - letterSpacing: 0.3, - }, - banner: { - backgroundColor: '#e0f2fe', - borderRadius: 18, - borderWidth: 1, - borderColor: '#c7e0f5', - padding: 18, - marginBottom: 24, - }, - bannerTitle: { - color: '#0f172a', - fontWeight: '700', - fontSize: 15, - }, - bannerText: { - color: '#1e3a8a', - fontSize: 14, - lineHeight: 20, - marginTop: 6, - }, - sectionContainer: { - marginTop: 24, - }, - section: { - backgroundColor: '#ffffff', - borderRadius: 20, - padding: 20, - borderWidth: 1, - borderColor: '#e6ecf5', - shadowColor: '#0f172a', - shadowOpacity: 0.05, - shadowRadius: 14, - shadowOffset: { width: 0, height: 6 }, - elevation: 2, - }, - sectionHeading: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - sectionHeadingText: { - flex: 1, - paddingRight: 12, - }, - sectionTitle: { - color: '#111827', - fontSize: 18, - fontWeight: '700', - }, - sectionSubtitle: { - color: '#6b7280', - fontSize: 14, - lineHeight: 20, - marginTop: 6, - }, - sectionBody: { - marginTop: 20, - }, - sectionActionButton: { - alignSelf: 'flex-start', - paddingHorizontal: 16, - paddingVertical: 8, - marginTop: -4, - }, - bodyText: { - color: '#4b5563', - fontSize: 15, - }, - infoNote: { - color: '#1e3a8a', - fontSize: 13, - lineHeight: 18, - backgroundColor: '#e0f2fe', - borderRadius: 14, - borderWidth: 1, - borderColor: '#bfdbfe', - paddingHorizontal: 16, - paddingVertical: 12, - marginBottom: 16, - }, - field: { - marginBottom: 20, - }, - fieldLabel: { - color: '#1f2937', - fontSize: 15, - fontWeight: '600', - }, - fieldHelper: { - color: '#6b7280', - fontSize: 13, - lineHeight: 18, - marginTop: 4, - }, - fieldControl: { - marginTop: 10, - }, - input: { - backgroundColor: '#f9fafb', - color: '#111827', - borderWidth: 1, - borderColor: '#dbe2f1', - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: Platform.select({ ios: 12, default: 10 }), - fontSize: 15, - }, - multiLineInput: { - minHeight: 72, - textAlignVertical: 'top', - }, - accessOptionsContainer: { - marginBottom: 12, - }, - accessOption: { - borderWidth: 1, - borderColor: '#e5e7ff', - borderRadius: 16, - padding: 16, - backgroundColor: '#f8faff', - marginBottom: 12, - }, - accessOptionSelected: { - borderColor: '#2563eb', - backgroundColor: '#eef2ff', - }, - accessOptionPressed: { - opacity: 0.9, - }, - accessOptionDisabled: { - borderColor: '#e5e7eb', - backgroundColor: '#f1f5f9', - }, - accessOptionLabel: { - color: '#1f2937', - fontSize: 15, - fontWeight: '600', - }, - accessOptionLabelSelected: { - color: '#1d4ed8', - }, - accessOptionLabelDisabled: { - color: '#9ca3af', - }, - accessOptionDescription: { - color: '#6b7280', - fontSize: 13, - lineHeight: 19, - marginTop: 6, - }, - accessOptionDescriptionDisabled: { - color: '#9ca3af', - }, - accessOptionUnavailable: { - color: '#ef4444', - fontSize: 12, - fontWeight: '600', - marginTop: 8, - letterSpacing: 0.2, - }, - toggleCard: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderRadius: 14, - borderWidth: 1, - borderColor: '#e5e7eb', - backgroundColor: '#fdfefe', - paddingHorizontal: 16, - paddingVertical: 14, - marginBottom: 12, - }, - toggleTextBlock: { - flex: 1, - paddingRight: 16, - }, - toggleLabel: { - color: '#1f2937', - fontSize: 15, - fontWeight: '600', - }, - toggleHelper: { - color: '#6b7280', - fontSize: 13, - lineHeight: 18, - marginTop: 4, - }, - metricsGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - marginHorizontal: -8, - }, - metricCard: { - minWidth: 140, - flexGrow: 1, - borderRadius: 16, - borderWidth: 1, - borderColor: '#e5e7eb', - backgroundColor: '#f9fafc', - padding: 16, - margin: 8, - }, - metricLabel: { - color: '#4b5563', - fontSize: 13, - fontWeight: '600', - textTransform: 'uppercase', - letterSpacing: 0.6, - }, - metricValue: { - color: '#111827', - fontSize: 16, - fontWeight: '700', - marginTop: 8, - }, - buttonGrid: { - flexDirection: 'row', - flexWrap: 'wrap', - marginHorizontal: -8, - }, - button: { - backgroundColor: '#2563eb', - paddingVertical: 12, - paddingHorizontal: 20, - borderRadius: 999, - marginHorizontal: 8, - marginBottom: 16, - }, - buttonPressed: { - backgroundColor: '#1d4ed8', - }, - buttonDisabled: { - backgroundColor: '#93c5fd', - }, - buttonLabel: { - color: '#ffffff', - fontWeight: '600', - fontSize: 15, - }, - itemCard: { - backgroundColor: '#f9fafb', - borderRadius: 16, - borderWidth: 1, - borderColor: '#e5e7eb', - padding: 18, - marginBottom: 16, - }, - itemTitle: { - color: '#111827', - fontSize: 16, - fontWeight: '700', - }, - itemMeta: { - color: '#6b7280', - fontSize: 13, - marginTop: 4, - }, - itemValue: { - color: '#111827', - fontSize: 15, - fontWeight: '500', - marginTop: 10, - }, - itemRowGroup: { - marginTop: 12, - }, - itemRow: { - color: '#4b5563', - fontSize: 13, - marginTop: 4, - }, - emptyState: { - color: '#6b7280', - fontSize: 14, - }, - logSection: { - marginBottom: 12, - }, - logContainer: { - backgroundColor: '#11182708', - borderRadius: 16, - borderWidth: 1, - borderColor: '#dbe2f1', - padding: 16, - }, - logText: { - color: '#1f2937', - fontFamily: Platform.select({ - ios: 'Menlo', - android: 'monospace', - default: 'Courier', - }), - fontSize: 13, - lineHeight: 18, - }, -}); - -export default App; + safeArea: { + flex: 1, + backgroundColor: '#f6f8fb', + }, + scrollContent: { + padding: 20, + paddingBottom: 32, + }, + header: { + marginBottom: 16, + }, + title: { + fontSize: 26, + fontWeight: '700', + color: '#111827', + }, + subtitle: { + marginTop: 6, + fontSize: 15, + lineHeight: 22, + color: '#4b5563', + }, + card: { + backgroundColor: '#ffffff', + borderRadius: 18, + padding: 18, + marginBottom: 18, + borderWidth: 1, + borderColor: '#e5e7eb', + shadowColor: '#0f172a', + shadowOpacity: 0.04, + shadowRadius: 12, + shadowOffset: { width: 0, height: 4 }, + elevation: 2, + }, + cardTitle: { + fontSize: 18, + fontWeight: '600', + color: '#0f172a', + marginBottom: 12, + }, + input: { + backgroundColor: '#f9fafb', + borderWidth: 1, + borderColor: '#d1d5db', + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: Platform.select({ ios: 12, default: 10 }), + fontSize: 15, + color: '#111827', + }, + secretInput: { + minHeight: 72, + textAlignVertical: 'top', + }, + inputLabel: { + fontSize: 12, + color: '#6b7280', + marginTop: 6, + marginBottom: 12, + textTransform: 'uppercase', + letterSpacing: 0.7, + }, + modeRow: { + flexDirection: 'column', + gap: 12, + }, + modeTile: { + padding: 16, + borderRadius: 16, + borderWidth: 1, + borderColor: '#dbeafe', + backgroundColor: '#f8fbff', + }, + modeTileActive: { + borderColor: '#2563eb', + backgroundColor: '#eff6ff', + }, + modeTileDisabled: { + borderColor: '#e5e7eb', + backgroundColor: '#f3f4f6', + }, + modeTilePressed: { + opacity: 0.9, + }, + modeLabel: { + fontSize: 15, + fontWeight: '600', + color: '#1f2937', + }, + modeLabelActive: { + color: '#1d4ed8', + }, + modeLabelDisabled: { + color: '#9ca3af', + }, + modeDescription: { + marginTop: 6, + fontSize: 13, + lineHeight: 19, + color: '#4b5563', + }, + modeBadge: { + marginTop: 10, + fontSize: 12, + fontWeight: '600', + color: '#ef4444', + }, + availability: { + marginTop: 14, + fontSize: 12, + letterSpacing: 0.6, + color: '#475569', + textTransform: 'uppercase', + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + actionButton: { + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 999, + backgroundColor: '#e2e8f0', + }, + actionButtonPrimary: { + backgroundColor: '#2563eb', + }, + actionButtonPressed: { + opacity: 0.85, + }, + actionButtonDisabled: { + backgroundColor: '#cbd5f5', + }, + actionButtonLabel: { + fontSize: 15, + fontWeight: '600', + color: '#1f2937', + }, + actionButtonLabelPrimary: { + color: '#ffffff', + }, + errorText: { + marginTop: 12, + color: '#dc2626', + fontSize: 13, + }, + statusBubble: { + marginTop: 14, + backgroundColor: '#0f172a0d', + borderRadius: 14, + padding: 12, + }, + statusText: { + fontSize: 14, + color: '#0f172a', + }, + loadingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + loadingText: { + fontSize: 14, + color: '#475569', + }, + emptyState: { + fontSize: 14, + color: '#6b7280', + }, + secretRow: { + paddingVertical: 12, + }, + secretKey: { + fontSize: 15, + fontWeight: '600', + color: '#1f2937', + }, + secretValue: { + marginTop: 4, + fontSize: 15, + color: '#0f172a', + }, + secretValueMuted: { + marginTop: 4, + fontSize: 15, + color: '#6b7280', + }, + secretMeta: { + marginTop: 4, + fontSize: 12, + color: '#94a3b8', + }, + separator: { + height: 1, + backgroundColor: '#e2e8f0', + }, + countBadge: { + fontSize: 16, + color: '#2563eb', + fontWeight: '700', + }, +}) + +export default App From 0c6bd68db87ed426fb1055aebb80794910725065 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Mon, 3 Nov 2025 11:35:01 -0300 Subject: [PATCH 2/4] chore: bump dev/example deps and normalize README version notation - Update dev deps: @eslint/compat -> 1.4.1, @eslint/js -> 9.39.0, eslint -> 9.39.0, globals -> 16.5.0, nitrogen -> 0.31.4 - Update runtime/example deps: react-native-nitro-modules -> 0.31.4, react-native-safe-area-context -> ^5.6.2 - Update yarn.lock to match dependency bumps - Docs: change README references from "5.6.0" to "5.6.x" for consistent versioning --- README.md | 6 +-- example/package.json | 4 +- package.json | 12 ++--- yarn.lock | 112 +++++++++++++++++++++---------------------- 4 files changed, 67 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 1ec3cd3c..5682a24e 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ Modern secure storage for React Native, powered by Nitro Modules. Version 6 ship > This README tracks the in-progress v6 work on `master`. For the stable legacy release, switch to the `v5.x` branch. > [!NOTE] -> **Choosing between 5.6.0 and 6.x** +> **Choosing between 5.6.x and 6.x** > -> - **Need bridge stability?** `5.6.0` is the last pre-Nitro release with the latest biometric fixes, docs, and Android namespace cleanups. It’s drop-in for any `5.5.x` app already running on React Native’s Fabric architecture, but you keep the legacy JS bridge overhead—Paper is no longer supported. +> - **Need bridge stability?** `5.6.x` is the last pre-Nitro release with the latest biometric fixes, docs, and Android namespace cleanups. It’s drop-in for any `5.5.x` app already running on React Native’s Fabric architecture, but you keep the legacy JS bridge overhead—Paper is no longer supported. > - **Ready for Nitro speed?** `6.x` swaps in the Nitro hybrid core, auto-enforces Class 3/StrongBox biometrics, and ships the refreshed sample app plus richer metadata. Upgrade when you can adopt the Nitro toolchain (RN 0.76+, Node 18+, `react-native-nitro-modules`). -> - **Staying back on 5.5.x?** You remain on the legacy (Paper) architecture and miss the Android 13 prompt fixes, the manual credential fallback restoration, and the new docs—migrate to `5.6.0` at minimum before planning the Nitro jump. +> - **Staying back on 5.5.x?** You remain on the legacy (Paper) architecture and miss the Android 13 prompt fixes, the manual credential fallback restoration, and the new docs—migrate to `5.6.x` at minimum before planning the Nitro jump. ## Table of contents diff --git a/example/package.json b/example/package.json index 51178e41..3a6960de 100644 --- a/example/package.json +++ b/example/package.json @@ -14,8 +14,8 @@ "@react-native/new-app-screen": "0.82.1", "react": "19.1.1", "react-native": "0.82.1", - "react-native-nitro-modules": "0.31.2", - "react-native-safe-area-context": "^5.6.1" + "react-native-nitro-modules": "0.31.4", + "react-native-safe-area-context": "^5.6.2" }, "devDependencies": { "@babel/core": "^7.28.5", diff --git a/package.json b/package.json index db787d62..b6cf11c0 100644 --- a/package.json +++ b/package.json @@ -54,8 +54,8 @@ "registry": "https://registry.npmjs.org/" }, "devDependencies": { - "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.38.0", + "@eslint/compat": "^1.4.1", + "@eslint/js": "^9.39.0", "@jamesacarr/eslint-formatter-github-actions": "^0.2.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", @@ -65,7 +65,7 @@ "@types/react": "19.2.x", "babel-plugin-react-compiler": "^1.0.0", "conventional-changelog-conventionalcommits": "^9.1.0", - "eslint": "^9.38.0", + "eslint": "^9.39.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", @@ -75,17 +75,17 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", - "globals": "^16.4.0", + "globals": "^16.5.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", "jiti": "^2.6.1", - "nitrogen": "0.31.2", + "nitrogen": "0.31.4", "prettier": "^3.6.2", "react": "19.1.1", "react-dom": "19.1.1", "react-native": "0.82", "react-native-builder-bob": "^0.40.14", - "react-native-nitro-modules": "0.31.2", + "react-native-nitro-modules": "0.31.4", "semantic-release": "^25.0.1", "ts-jest": "^29.4.5", "ts-node": "^10.9.2", diff --git a/yarn.lock b/yarn.lock index b6014f40..3e05dbca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1798,17 +1798,17 @@ __metadata: languageName: node linkType: hard -"@eslint/compat@npm:^1.4.0": - version: 1.4.0 - resolution: "@eslint/compat@npm:1.4.0" +"@eslint/compat@npm:^1.4.1": + version: 1.4.1 + resolution: "@eslint/compat@npm:1.4.1" dependencies: - "@eslint/core": "npm:^0.16.0" + "@eslint/core": "npm:^0.17.0" peerDependencies: eslint: ^8.40 || 9 peerDependenciesMeta: eslint: optional: true - checksum: 10/204f80bfde839f13bf1febe1a2de101e88ec5fdb29d9539239ccfc12b25b4edd81c2109fe642551e9ca3b8869f259d5ee08a67bbc6350ab4fde91c7231aad85b + checksum: 10/2345ba0991aaf57f79feed0417eac61fd0e09fb1d2f5bc3f723d5790a4f0881cca16b7a48c82555ab907a3469dce7d3cb43cc5e5100c22e2a369a561f4b421cd languageName: node linkType: hard @@ -1823,21 +1823,21 @@ __metadata: languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.4.1": - version: 0.4.1 - resolution: "@eslint/config-helpers@npm:0.4.1" +"@eslint/config-helpers@npm:^0.4.2": + version: 0.4.2 + resolution: "@eslint/config-helpers@npm:0.4.2" dependencies: - "@eslint/core": "npm:^0.16.0" - checksum: 10/e3e6ea4cd19f5a9b803b2d0b3f174d53fcd27415587e49943144994104a42845cf300ed6ffdbd149d958482a49de99c326f9ae4c18c9467727ec60ad36cb5ef9 + "@eslint/core": "npm:^0.17.0" + checksum: 10/3f2b4712d8e391c36ec98bc200f7dea423dfe518e42956569666831b89ede83b33120c761dfd3ab6347d8e8894a6d4af47254a18d464a71c6046fd88065f6daf languageName: node linkType: hard -"@eslint/core@npm:^0.16.0": - version: 0.16.0 - resolution: "@eslint/core@npm:0.16.0" +"@eslint/core@npm:^0.17.0": + version: 0.17.0 + resolution: "@eslint/core@npm:0.17.0" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10/3cea45971b2d0114267b6101b673270b5d8047448cc7a8cbfdca0b0245e9d5e081cb25f13551dc7d55a090f98c13b33f0c4999f8ee8ab058537e6037629a0f71 + checksum: 10/f9a428cc651ec15fb60d7d60c2a7bacad4666e12508320eafa98258e976fafaa77d7be7be91519e75f801f15f830105420b14a458d4aab121a2b0a59bc43517b languageName: node linkType: hard @@ -1858,10 +1858,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.38.0, @eslint/js@npm:^9.38.0": - version: 9.38.0 - resolution: "@eslint/js@npm:9.38.0" - checksum: 10/08ba53e3e631e2815ff33e0f48dccf87daf3841eb5605fa5980d18b88cd6dd4cd63b5829ac015e97eeb85807bf91efe7d4e1d4eaf6beb586bc01549b7660c4a2 +"@eslint/js@npm:9.39.0, @eslint/js@npm:^9.39.0": + version: 9.39.0 + resolution: "@eslint/js@npm:9.39.0" + checksum: 10/5858c2468f68e9204ec0a3a07cbb22352e8de89eb51bc83ac9754e2365b9c2d2aa0e0a3da46b98ea5d98a484c77111537f2a565b867bbdfe0448a0222404ef6b languageName: node linkType: hard @@ -1872,13 +1872,13 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.4.0": - version: 0.4.0 - resolution: "@eslint/plugin-kit@npm:0.4.0" +"@eslint/plugin-kit@npm:^0.4.1": + version: 0.4.1 + resolution: "@eslint/plugin-kit@npm:0.4.1" dependencies: - "@eslint/core": "npm:^0.16.0" + "@eslint/core": "npm:^0.17.0" levn: "npm:^0.4.1" - checksum: 10/2c37ca00e352447215aeadcaff5765faead39695f1cb91cd3079a43261b234887caf38edc462811bb3401acf8c156c04882f87740df936838290c705351483be + checksum: 10/c5947d0ffeddca77d996ac1b886a66060c1a15ed1d5e425d0c7e7d7044a4bd3813fc968892d03950a7831c9b89368a2f7b281e45dd3c74a048962b74bf3a1cb4 languageName: node linkType: hard @@ -6912,18 +6912,18 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.38.0": - version: 9.38.0 - resolution: "eslint@npm:9.38.0" +"eslint@npm:^9.39.0": + version: 9.39.0 + resolution: "eslint@npm:9.39.0" dependencies: "@eslint-community/eslint-utils": "npm:^4.8.0" "@eslint-community/regexpp": "npm:^4.12.1" "@eslint/config-array": "npm:^0.21.1" - "@eslint/config-helpers": "npm:^0.4.1" - "@eslint/core": "npm:^0.16.0" + "@eslint/config-helpers": "npm:^0.4.2" + "@eslint/core": "npm:^0.17.0" "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.38.0" - "@eslint/plugin-kit": "npm:^0.4.0" + "@eslint/js": "npm:9.39.0" + "@eslint/plugin-kit": "npm:^0.4.1" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" @@ -6957,7 +6957,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10/fb8971572dfedd1fd67a35a746d2ab399bef320a7f131fdccaec6416f4b4a028e762663c32ccf1a88f715aec6d1c5da066fdb11e20219a0156f1f3fc1a726713 + checksum: 10/628c8c7ddd9ed9e0384ccfb7f880e4a1ac76885aa2310a4057ebbb5c0877540fcebf88537a15b321ccc3097bec7b6f812d9a4887d1cc5a89166c379ed2574432 languageName: node linkType: hard @@ -7753,10 +7753,10 @@ __metadata: languageName: node linkType: hard -"globals@npm:^16.4.0": - version: 16.4.0 - resolution: "globals@npm:16.4.0" - checksum: 10/1627a9f42fb4c82d7af6a0c8b6cd616e00110908304d5f1ddcdf325998f3aed45a4b29d8a1e47870f328817805263e31e4f1673f00022b9c2b210552767921cf +"globals@npm:^16.5.0": + version: 16.5.0 + resolution: "globals@npm:16.5.0" + checksum: 10/f9e8a2a13f50222c127030a619e283e7bbfe32966316bdde0715af1d15a7e40cb9c24ff52cad59671f97762ed8b515353c2f8674f560c63d9385f19ee26735a6 languageName: node linkType: hard @@ -10734,18 +10734,18 @@ __metadata: languageName: node linkType: hard -"nitrogen@npm:0.31.2": - version: 0.31.2 - resolution: "nitrogen@npm:0.31.2" +"nitrogen@npm:0.31.4": + version: 0.31.4 + resolution: "nitrogen@npm:0.31.4" dependencies: chalk: "npm:^5.3.0" - react-native-nitro-modules: "npm:^0.31.2" + react-native-nitro-modules: "npm:^0.31.4" ts-morph: "npm:^27.0.0" yargs: "npm:^18.0.0" zod: "npm:^4.0.5" bin: nitrogen: lib/index.js - checksum: 10/257c9424a45f892cffdf0718692d980d10338ba8ada7b20de8537870dad176ec28fd511928164deaa4c065dfaa7b614107a176c44774b560cd8fe940fce9f832 + checksum: 10/9efd15a939ad64fe10f1a70c6d5b1e34a293ef134a755bb59fda2105591bd2720245e0fa2b00ca055bf8e47f363e60da8401ee47da15407d0cec60fb439dd487 languageName: node linkType: hard @@ -12038,23 +12038,23 @@ __metadata: languageName: node linkType: hard -"react-native-nitro-modules@npm:0.31.2, react-native-nitro-modules@npm:^0.31.2": - version: 0.31.2 - resolution: "react-native-nitro-modules@npm:0.31.2" +"react-native-nitro-modules@npm:0.31.4, react-native-nitro-modules@npm:^0.31.4": + version: 0.31.4 + resolution: "react-native-nitro-modules@npm:0.31.4" peerDependencies: react: "*" react-native: "*" - checksum: 10/6c44eb074ee51b6b40bd62e657e4aab3c667af1c33dcfbe8a0b88c027db54c4148903a273fc5f008213d0add32b5508bce99362746d3eb6e5586d1a805296567 + checksum: 10/be908aa8aec76261c12b3fe8788ad9e69d3bf9c568f1b77ff5f6bcae4c064bf0f7d73f3ac9dc24e1e113b49eaa92d1833dcc2a899f1364caaa475c8fbe8b036b languageName: node linkType: hard -"react-native-safe-area-context@npm:^5.6.1": - version: 5.6.1 - resolution: "react-native-safe-area-context@npm:5.6.1" +"react-native-safe-area-context@npm:^5.6.2": + version: 5.6.2 + resolution: "react-native-safe-area-context@npm:5.6.2" peerDependencies: react: "*" react-native: "*" - checksum: 10/2fc93cf46a6cbad28e5850bef009905c6db44066fb7e6f7bbce52c2ae4b0467c6718e4f572a42f8387c6b37f6d61ebe79980d0c2b5899e23dc19482a7db8417b + checksum: 10/880d87ee60119321b366eef2c151ecefe14f5bc0d39cf5cfbfb167684e571d3dae2600ee19b9bc8521f5726eb285abecaa7aafb1a3b213529dafbac24703d302 languageName: node linkType: hard @@ -12077,8 +12077,8 @@ __metadata: babel-plugin-module-resolver: "npm:^5.0.2" react: "npm:19.1.1" react-native: "npm:0.82.1" - react-native-nitro-modules: "npm:0.31.2" - react-native-safe-area-context: "npm:^5.6.1" + react-native-nitro-modules: "npm:0.31.4" + react-native-safe-area-context: "npm:^5.6.2" languageName: unknown linkType: soft @@ -12086,8 +12086,8 @@ __metadata: version: 0.0.0-use.local resolution: "react-native-sensitive-info@workspace:." dependencies: - "@eslint/compat": "npm:^1.4.0" - "@eslint/js": "npm:^9.38.0" + "@eslint/compat": "npm:^1.4.1" + "@eslint/js": "npm:^9.39.0" "@jamesacarr/eslint-formatter-github-actions": "npm:^0.2.0" "@semantic-release/changelog": "npm:^6.0.3" "@semantic-release/git": "npm:^10.0.1" @@ -12097,7 +12097,7 @@ __metadata: "@types/react": "npm:19.2.x" babel-plugin-react-compiler: "npm:^1.0.0" conventional-changelog-conventionalcommits: "npm:^9.1.0" - eslint: "npm:^9.38.0" + eslint: "npm:^9.39.0" eslint-config-airbnb: "npm:^19.0.4" eslint-config-prettier: "npm:^10.1.8" eslint-import-resolver-typescript: "npm:^4.4.4" @@ -12107,17 +12107,17 @@ __metadata: eslint-plugin-prettier: "npm:^5.5.4" eslint-plugin-react: "npm:^7.37.5" eslint-plugin-react-hooks: "npm:^7.0.1" - globals: "npm:^16.4.0" + globals: "npm:^16.5.0" jest: "npm:^30.2.0" jest-environment-jsdom: "npm:^30.2.0" jiti: "npm:^2.6.1" - nitrogen: "npm:0.31.2" + nitrogen: "npm:0.31.4" prettier: "npm:^3.6.2" react: "npm:19.1.1" react-dom: "npm:19.1.1" react-native: "npm:0.82" react-native-builder-bob: "npm:^0.40.14" - react-native-nitro-modules: "npm:0.31.2" + react-native-nitro-modules: "npm:0.31.4" semantic-release: "npm:^25.0.1" ts-jest: "npm:^29.4.5" ts-node: "npm:^10.9.2" From 44548839c4755d1067c3246c1dab5e049ad44963 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Mon, 3 Nov 2025 11:56:32 -0300 Subject: [PATCH 3/4] fix(auth): treat authentication cancellations as soft-failures and map native cancel codes - Add CODE_OF_CONDUCT - Bump LICENSE copyright range to 2016-2025 - Android: - Introduce SensitiveInfoException.AuthenticationCanceled and throw/resume with it when user cancels biometric/device-credential prompts - Simplify device credential flow to always return cipher after prompt - iOS: - Map relevant OSStatus values to an E_AUTH_CANCELED runtime error for friendly messaging - Internal errors: - Add AUTH_CANCELED marker and helper hasErrorMarker/isAuthenticationCanceledError - Centralize detection of auth-cancelled errors - Hooks & utilities: - Export and use isAuthenticationCanceledError in error-utils - Create user-friendly hook error message for canceled auths and export detector - Update hooks (useSecretItem, useHasSecret, useSecureOperation, useSecureStorage, useSecurityAvailability) to treat auth cancellations as non-fatal: preserve/clear state appropriately and avoid surfacing HookError when user dismisses prompts - Add applyError helper in useSecureStorage to centralize error handling - Update hook types and exports - Nitro/native layers & types: - Type and formatting fixes across sensitive-info.nitro.ts, internal/native, options, core/storage and index exports - Tests & tooling: - Apply consistent code style (semicolons, trailing commas) across tests and configs - Update many test files to match changes and ensure behavior for canceled auth flows - Misc: - Update package.json description - ESLint config formatting fixes This change makes authentication prompt cancellations explicit (E_AUTH_CANCELED) and prevents noisy error states in hooks when users dismiss biometric / device credential prompts. --- CODE_OF_CONDUCT.md | 133 ++++++++++ LICENSE | 2 +- .../internal/auth/BiometricAuthenticator.kt | 18 +- .../auth/DeviceCredentialPromptFragment.kt | 3 +- .../internal/util/SensitiveInfoExceptions.kt | 5 + eslint.config.mts | 32 +-- ios/HybridSensitiveInfo.swift | 12 + package.json | 2 +- .../__mocks__/react-native-nitro-modules.ts | 18 +- src/__tests__/__mocks__/react-native.ts | 4 +- src/__tests__/core.storage.test.ts | 152 +++++------ src/__tests__/hooks.error-utils.test.ts | 18 +- src/__tests__/hooks.types.test.ts | 30 +-- .../hooks.useAsyncLifecycle.test.tsx | 36 +-- src/__tests__/hooks.useHasSecret.test.tsx | 76 +++--- src/__tests__/hooks.useSecret.test.tsx | 126 ++++----- src/__tests__/hooks.useSecretItem.test.tsx | 78 +++--- .../hooks.useSecureOperation.test.tsx | 42 +-- src/__tests__/hooks.useSecureStorage.test.tsx | 245 +++++++++--------- .../hooks.useSecurityAvailability.test.tsx | 66 ++--- src/__tests__/hooks.useStableOptions.test.tsx | 36 +-- src/__tests__/index.test.ts | 34 +-- src/__tests__/internal.errors.test.ts | 36 +-- src/__tests__/internal.native.test.ts | 38 +-- src/__tests__/internal.options.test.ts | 18 +- src/__tests__/storage.test.ts | 60 ++--- src/core/storage.ts | 68 ++--- src/hooks/error-utils.ts | 25 +- src/hooks/index.ts | 14 +- src/hooks/types.ts | 50 ++-- src/hooks/useAsyncLifecycle.ts | 6 +- src/hooks/useHasSecret.ts | 35 ++- src/hooks/useSecret.ts | 3 + src/hooks/useSecretItem.ts | 35 ++- src/hooks/useSecureOperation.ts | 29 ++- src/hooks/useSecureStorage.ts | 59 +++-- src/hooks/useSecurityAvailability.ts | 58 ++--- src/hooks/useStableOptions.ts | 24 +- src/index.ts | 8 +- src/internal/errors.ts | 29 ++- src/internal/native.ts | 14 +- src/internal/options.ts | 12 +- src/sensitive-info.nitro.ts | 82 +++--- 43 files changed, 1054 insertions(+), 817 deletions(-) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..09f11ed5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[INSERT CONTACT METHOD]. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations \ No newline at end of file diff --git a/LICENSE b/LICENSE index 5acfdb0b..02d60c59 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Mateus Andrade +Copyright (c) 2016-2025 Mateus Andrade Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/android/src/main/java/com/sensitiveinfo/internal/auth/BiometricAuthenticator.kt b/android/src/main/java/com/sensitiveinfo/internal/auth/BiometricAuthenticator.kt index 6dd47aa5..64400ddb 100644 --- a/android/src/main/java/com/sensitiveinfo/internal/auth/BiometricAuthenticator.kt +++ b/android/src/main/java/com/sensitiveinfo/internal/auth/BiometricAuthenticator.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import com.sensitiveinfo.internal.util.ReactContextHolder +import com.sensitiveinfo.internal.util.SensitiveInfoException import javax.crypto.Cipher import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume @@ -42,11 +43,8 @@ internal class BiometricAuthenticator { return withContext(Dispatchers.Main) { if (cipher == null && allowLegacyDeviceCredential && !canUseBiometric()) { - if (DeviceCredentialPromptFragment.authenticate(activity, effectivePrompt)) { - cipher - } else { - throw IllegalStateException("Device credential authentication canceled.") - } + DeviceCredentialPromptFragment.authenticate(activity, effectivePrompt) + cipher } else { try { authenticateWithBiometricPrompt( @@ -59,9 +57,8 @@ internal class BiometricAuthenticator { } catch (error: Throwable) { if (error is CancellationException) throw error if (allowLegacyDeviceCredential) { - if (DeviceCredentialPromptFragment.authenticate(activity, effectivePrompt)) { - return@withContext cipher - } + DeviceCredentialPromptFragment.authenticate(activity, effectivePrompt) + return@withContext cipher } throw error } @@ -86,11 +83,12 @@ internal class BiometricAuthenticator { } override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { - if (errorCode == BiometricPrompt.ERROR_CANCELED || + if ( + errorCode == BiometricPrompt.ERROR_CANCELED || errorCode == BiometricPrompt.ERROR_USER_CANCELED || errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ) { - continuation.cancel() + continuation.resumeWithException(SensitiveInfoException.AuthenticationCanceled()) } else { continuation.resumeWithException(IllegalStateException(errString.toString())) } diff --git a/android/src/main/java/com/sensitiveinfo/internal/auth/DeviceCredentialPromptFragment.kt b/android/src/main/java/com/sensitiveinfo/internal/auth/DeviceCredentialPromptFragment.kt index 45083f46..b3f07563 100644 --- a/android/src/main/java/com/sensitiveinfo/internal/auth/DeviceCredentialPromptFragment.kt +++ b/android/src/main/java/com/sensitiveinfo/internal/auth/DeviceCredentialPromptFragment.kt @@ -8,6 +8,7 @@ import android.os.Build import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.margelo.nitro.sensitiveinfo.AuthenticationPrompt +import com.sensitiveinfo.internal.util.SensitiveInfoException import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlinx.coroutines.CancellableContinuation @@ -58,7 +59,7 @@ internal class DeviceCredentialPromptFragment : Fragment() { if (resultCode == Activity.RESULT_OK) { cont.resume(true) } else { - cont.cancel() + cont.resumeWithException(SensitiveInfoException.AuthenticationCanceled()) } cleanup() } diff --git a/android/src/main/java/com/sensitiveinfo/internal/util/SensitiveInfoExceptions.kt b/android/src/main/java/com/sensitiveinfo/internal/util/SensitiveInfoExceptions.kt index 3d14709b..c666be2e 100644 --- a/android/src/main/java/com/sensitiveinfo/internal/util/SensitiveInfoExceptions.kt +++ b/android/src/main/java/com/sensitiveinfo/internal/util/SensitiveInfoExceptions.kt @@ -12,5 +12,10 @@ sealed class SensitiveInfoException( code = "E_NOT_FOUND", message = "[E_NOT_FOUND] No secret found for key \"$key\" in service \"$service\"." ) + + class AuthenticationCanceled : SensitiveInfoException( + code = "E_AUTH_CANCELED", + message = "[E_AUTH_CANCELED] Authentication prompt canceled by the user." + ) } diff --git a/eslint.config.mts b/eslint.config.mts index fcf1da2d..cb31a2f9 100644 --- a/eslint.config.mts +++ b/eslint.config.mts @@ -1,24 +1,24 @@ -import { fixupPluginRules } from '@eslint/compat' -import { FlatCompat } from '@eslint/eslintrc' -import js from '@eslint/js' -import typescriptEslint from '@typescript-eslint/eslint-plugin' -import tsParser from '@typescript-eslint/parser' -import importHelpers from 'eslint-plugin-import-helpers' -import prettier from 'eslint-plugin-prettier' -import react from 'eslint-plugin-react' -import reactHooks from 'eslint-plugin-react-hooks' -import globals from 'globals' -import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fixupPluginRules } from '@eslint/compat'; +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import typescriptEslint from '@typescript-eslint/eslint-plugin'; +import tsParser from '@typescript-eslint/parser'; +import importHelpers from 'eslint-plugin-import-helpers'; +import prettier from 'eslint-plugin-prettier'; +import react from 'eslint-plugin-react'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); const compat = new FlatCompat({ baseDirectory: __dirname, recommendedConfig: js.configs.recommended, allConfig: js.configs.all, -}) +}); export default [ ...compat.extends( @@ -66,4 +66,4 @@ export default [ 'import/extensions': 'off', }, }, -] +]; diff --git a/ios/HybridSensitiveInfo.swift b/ios/HybridSensitiveInfo.swift index ea3669e6..83acca52 100755 --- a/ios/HybridSensitiveInfo.swift +++ b/ios/HybridSensitiveInfo.swift @@ -378,7 +378,19 @@ final class HybridSensitiveInfo: HybridSensitiveInfoSpec { } private func runtimeError(for status: OSStatus, operation: String) -> RuntimeError { + if isAuthenticationCanceled(status: status) { + return RuntimeError.error(withMessage: "[E_AUTH_CANCELED] Authentication prompt canceled by the user.") + } let message = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus(\(status))" return RuntimeError.error(withMessage: "Keychain \(operation) failed: \(message)") } + + private func isAuthenticationCanceled(status: OSStatus) -> Bool { + switch status { + case errSecUserCanceled, errSecInteractionNotAllowed: + return true + default: + return false + } + } } diff --git a/package.json b/package.json index b6cf11c0..b21552f0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-native-sensitive-info", "version": "6.0.0-rc.8", - "description": "react-native-sensitive-info is a react native package built with Nitro", + "description": "🔐 React Native secure storage, rebuilt with Nitro Modules ⚡️ Biometric-ready, StrongBox-aware, and metadata-rich for modern mobile apps", "main": "./lib/commonjs/index.js", "module": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", diff --git a/src/__tests__/__mocks__/react-native-nitro-modules.ts b/src/__tests__/__mocks__/react-native-nitro-modules.ts index e6034b20..a67151a4 100644 --- a/src/__tests__/__mocks__/react-native-nitro-modules.ts +++ b/src/__tests__/__mocks__/react-native-nitro-modules.ts @@ -1,23 +1,23 @@ export class MockHybridObject { - static instances: MockHybridObject[] = [] + static instances: MockHybridObject[] = []; constructor() { - MockHybridObject.instances.push(this) + MockHybridObject.instances.push(this); } } export const getHybridObjectConstructor = jest .fn(() => MockHybridObject) - .mockName('getHybridObjectConstructor') + .mockName('getHybridObjectConstructor'); export const __resetMocks = () => { - MockHybridObject.instances = [] - getHybridObjectConstructor.mockReset() - getHybridObjectConstructor.mockReturnValue(MockHybridObject) -} + MockHybridObject.instances = []; + getHybridObjectConstructor.mockReset(); + getHybridObjectConstructor.mockReturnValue(MockHybridObject); +}; -__resetMocks() +__resetMocks(); export default { getHybridObjectConstructor, -} +}; diff --git a/src/__tests__/__mocks__/react-native.ts b/src/__tests__/__mocks__/react-native.ts index 8d3107f3..e10558f5 100644 --- a/src/__tests__/__mocks__/react-native.ts +++ b/src/__tests__/__mocks__/react-native.ts @@ -1,5 +1,5 @@ -export const NativeModules = {} +export const NativeModules = {}; export default { NativeModules, -} +}; diff --git a/src/__tests__/core.storage.test.ts b/src/__tests__/core.storage.test.ts index d1ecfaa4..cdd85990 100644 --- a/src/__tests__/core.storage.test.ts +++ b/src/__tests__/core.storage.test.ts @@ -5,7 +5,7 @@ import type { SensitiveInfoHasRequest, SensitiveInfoOptions, SensitiveInfoSetRequest, -} from '../sensitive-info.nitro' +} from '../sensitive-info.nitro'; describe('core/storage', () => { const nativeHandle = { @@ -16,7 +16,7 @@ describe('core/storage', () => { getAllItems: jest.fn(), clearService: jest.fn(), getSupportedSecurityLevels: jest.fn(), - } + }; const normalizeOptions = jest .fn< @@ -26,181 +26,181 @@ describe('core/storage', () => { .mockReturnValue({ service: 'normalized', accessControl: 'secureEnclaveBiometry', - }) + }); - const isNotFoundError = jest.fn() + const isNotFoundError = jest.fn(); const loadModule = async () => { - jest.resetModules() + jest.resetModules(); jest.doMock('../internal/native', () => ({ __esModule: true, default: jest.fn(() => nativeHandle), - })) + })); jest.doMock('../internal/options', () => ({ normalizeOptions, - })) + })); jest.doMock('../internal/errors', () => ({ isNotFoundError, - })) + })); - return import('../core/storage') - } + return import('../core/storage'); + }; beforeEach(() => { - jest.clearAllMocks() + jest.clearAllMocks(); Object.values(nativeHandle).forEach((value) => { if (typeof value === 'function') { - value.mockReset() + value.mockReset(); } - }) - normalizeOptions.mockClear() + }); + normalizeOptions.mockClear(); normalizeOptions.mockReturnValue({ service: 'normalized', accessControl: 'secureEnclaveBiometry', - }) - isNotFoundError.mockReset() - }) + }); + isNotFoundError.mockReset(); + }); it('delegates setItem to the native layer', async () => { - const { setItem } = await loadModule() + const { setItem } = await loadModule(); - nativeHandle.setItem.mockResolvedValue({ metadata: {} }) + nativeHandle.setItem.mockResolvedValue({ metadata: {} }); - await setItem('token', 'secret', { service: 'service' }) + await setItem('token', 'secret', { service: 'service' }); - expect(normalizeOptions).toHaveBeenCalledWith({ service: 'service' }) + expect(normalizeOptions).toHaveBeenCalledWith({ service: 'service' }); expect(nativeHandle.setItem).toHaveBeenCalledWith({ key: 'token', value: 'secret', service: 'normalized', accessControl: 'secureEnclaveBiometry', - } as SensitiveInfoSetRequest) - }) + } as SensitiveInfoSetRequest); + }); it('returns null when a key is missing', async () => { - const { getItem } = await loadModule() + const { getItem } = await loadModule(); - const error = new Error('Missing [E_NOT_FOUND] key') - nativeHandle.getItem.mockRejectedValueOnce(error) - isNotFoundError.mockReturnValueOnce(true) + const error = new Error('Missing [E_NOT_FOUND] key'); + nativeHandle.getItem.mockRejectedValueOnce(error); + isNotFoundError.mockReturnValueOnce(true); - const result = await getItem('token', { service: 'service' }) + const result = await getItem('token', { service: 'service' }); - expect(result).toBeNull() - expect(normalizeOptions).toHaveBeenCalled() - }) + expect(result).toBeNull(); + expect(normalizeOptions).toHaveBeenCalled(); + }); it('rethrows unexpected errors during getItem', async () => { - const { getItem } = await loadModule() + const { getItem } = await loadModule(); - const error = new Error('Boom') - nativeHandle.getItem.mockRejectedValueOnce(error) - isNotFoundError.mockReturnValueOnce(false) + const error = new Error('Boom'); + nativeHandle.getItem.mockRejectedValueOnce(error); + isNotFoundError.mockReturnValueOnce(false); - await expect(getItem('token')).rejects.toBe(error) - }) + await expect(getItem('token')).rejects.toBe(error); + }); it('passes includeValue defaults to getItem', async () => { - const { getItem } = await loadModule() + const { getItem } = await loadModule(); - nativeHandle.getItem.mockResolvedValueOnce({ key: 'token' }) + nativeHandle.getItem.mockResolvedValueOnce({ key: 'token' }); - await getItem('token') + await getItem('token'); expect(nativeHandle.getItem).toHaveBeenCalledWith({ key: 'token', includeValue: true, service: 'normalized', accessControl: 'secureEnclaveBiometry', - } as SensitiveInfoGetRequest) - }) + } as SensitiveInfoGetRequest); + }); it('delegates hasItem to the native layer', async () => { - const { hasItem } = await loadModule() + const { hasItem } = await loadModule(); - nativeHandle.hasItem.mockResolvedValueOnce(true) + nativeHandle.hasItem.mockResolvedValueOnce(true); - const result = await hasItem('token', { service: 'service' }) + const result = await hasItem('token', { service: 'service' }); - expect(result).toBe(true) + expect(result).toBe(true); expect(nativeHandle.hasItem).toHaveBeenCalledWith({ key: 'token', service: 'normalized', accessControl: 'secureEnclaveBiometry', - } as SensitiveInfoHasRequest) - }) + } as SensitiveInfoHasRequest); + }); it('delegates deleteItem to the native layer', async () => { - const { deleteItem } = await loadModule() + const { deleteItem } = await loadModule(); - nativeHandle.deleteItem.mockResolvedValueOnce(true) + nativeHandle.deleteItem.mockResolvedValueOnce(true); - const result = await deleteItem('token', { service: 'service' }) + const result = await deleteItem('token', { service: 'service' }); - expect(result).toBe(true) + expect(result).toBe(true); expect(nativeHandle.deleteItem).toHaveBeenCalledWith({ key: 'token', service: 'normalized', accessControl: 'secureEnclaveBiometry', - } as SensitiveInfoDeleteRequest) - }) + } as SensitiveInfoDeleteRequest); + }); it('returns entries using getAllItems with includeValues default', async () => { - const { getAllItems } = await loadModule() + const { getAllItems } = await loadModule(); - nativeHandle.getAllItems.mockResolvedValueOnce([]) + nativeHandle.getAllItems.mockResolvedValueOnce([]); - await getAllItems({ includeValues: true }) + await getAllItems({ includeValues: true }); expect(nativeHandle.getAllItems).toHaveBeenCalledWith({ includeValues: true, service: 'normalized', accessControl: 'secureEnclaveBiometry', - } as SensitiveInfoEnumerateRequest) - }) + } as SensitiveInfoEnumerateRequest); + }); it('clears a service via native call', async () => { - const { clearService } = await loadModule() + const { clearService } = await loadModule(); - nativeHandle.clearService.mockResolvedValueOnce(undefined) + nativeHandle.clearService.mockResolvedValueOnce(undefined); - await clearService({ service: 'auth' }) + await clearService({ service: 'auth' }); expect(nativeHandle.clearService).toHaveBeenCalledWith({ service: 'normalized', accessControl: 'secureEnclaveBiometry', - }) - }) + }); + }); it('forwards getSupportedSecurityLevels', async () => { - const { getSupportedSecurityLevels } = await loadModule() + const { getSupportedSecurityLevels } = await loadModule(); nativeHandle.getSupportedSecurityLevels.mockResolvedValueOnce({ secureEnclave: true, strongBox: true, biometry: true, deviceCredential: false, - }) + }); - const result = await getSupportedSecurityLevels() + const result = await getSupportedSecurityLevels(); expect(result).toEqual({ secureEnclave: true, strongBox: true, biometry: true, deviceCredential: false, - }) - expect(nativeHandle.getSupportedSecurityLevels).toHaveBeenCalled() - }) + }); + expect(nativeHandle.getSupportedSecurityLevels).toHaveBeenCalled(); + }); it('exposes a namespace mirroring the helpers', async () => { - const module = await loadModule() + const module = await loadModule(); - expect(module.SensitiveInfo.setItem).toBe(module.setItem) - expect(module.SensitiveInfo.getItem).toBe(module.getItem) - expect(module.SensitiveInfo.clearService).toBe(module.clearService) - }) -}) + expect(module.SensitiveInfo.setItem).toBe(module.setItem); + expect(module.SensitiveInfo.getItem).toBe(module.getItem); + expect(module.SensitiveInfo.clearService).toBe(module.clearService); + }); +}); diff --git a/src/__tests__/hooks.error-utils.test.ts b/src/__tests__/hooks.error-utils.test.ts index 15a27943..8ebe1fd9 100644 --- a/src/__tests__/hooks.error-utils.test.ts +++ b/src/__tests__/hooks.error-utils.test.ts @@ -1,17 +1,17 @@ -import createHookError from '../hooks/error-utils' +import createHookError from '../hooks/error-utils'; describe('hooks/error-utils', () => { it('wraps errors with helpful context', () => { - const cause = new Error('Access denied') + const cause = new Error('Access denied'); const error = createHookError( 'useSecureStorage.fetchItems', cause, 'Provide a valid service name.' - ) + ); - expect(error.name).toBe('HookError') - expect(error.message).toContain('useSecureStorage.fetchItems') - expect(error.cause).toBe(cause) - expect(error.hint).toBe('Provide a valid service name.') - }) -}) + expect(error.name).toBe('HookError'); + expect(error.message).toContain('useSecureStorage.fetchItems'); + expect(error.cause).toBe(cause); + expect(error.hint).toBe('Provide a valid service name.'); + }); +}); diff --git a/src/__tests__/hooks.types.test.ts b/src/__tests__/hooks.types.test.ts index 978c9945..2c3f0e86 100644 --- a/src/__tests__/hooks.types.test.ts +++ b/src/__tests__/hooks.types.test.ts @@ -2,39 +2,39 @@ import { HookError, createInitialAsyncState, createInitialVoidState, -} from '../hooks/types' +} from '../hooks/types'; describe('hooks/types', () => { it('constructs HookError with metadata', () => { - const cause = new Error('native failure') + const cause = new Error('native failure'); const error = new HookError('Wrapper message', { cause, operation: 'useSecret.save', hint: 'Check the key.', - }) + }); - expect(error).toBeInstanceOf(Error) - expect(error.cause).toBe(cause) - expect(error.operation).toBe('useSecret.save') - expect(error.hint).toBe('Check the key.') - }) + expect(error).toBeInstanceOf(Error); + expect(error.cause).toBe(cause); + expect(error.operation).toBe('useSecret.save'); + expect(error.hint).toBe('Check the key.'); + }); it('creates the initial async state', () => { - const state = createInitialAsyncState() + const state = createInitialAsyncState(); expect(state).toEqual({ data: null, error: null, isLoading: true, isPending: false, - }) - }) + }); + }); it('creates the initial void async state', () => { - const state = createInitialVoidState() + const state = createInitialVoidState(); expect(state).toEqual({ error: null, isLoading: false, isPending: false, - }) - }) -}) + }); + }); +}); diff --git a/src/__tests__/hooks.useAsyncLifecycle.test.tsx b/src/__tests__/hooks.useAsyncLifecycle.test.tsx index 3ae756b3..84f8827c 100644 --- a/src/__tests__/hooks.useAsyncLifecycle.test.tsx +++ b/src/__tests__/hooks.useAsyncLifecycle.test.tsx @@ -1,28 +1,28 @@ -import { renderHook } from '@testing-library/react' -import useAsyncLifecycle from '../hooks/useAsyncLifecycle' +import { renderHook } from '@testing-library/react'; +import useAsyncLifecycle from '../hooks/useAsyncLifecycle'; describe('useAsyncLifecycle', () => { it('aborts previous controllers when begin is called again', () => { - const { result } = renderHook(() => useAsyncLifecycle()) + const { result } = renderHook(() => useAsyncLifecycle()); - const first = result.current.begin() - expect(first.signal.aborted).toBe(false) + const first = result.current.begin(); + expect(first.signal.aborted).toBe(false); - const second = result.current.begin() - expect(first.signal.aborted).toBe(true) - expect(second.signal.aborted).toBe(false) - expect(result.current.controllerRef.current).toBe(second) - }) + const second = result.current.begin(); + expect(first.signal.aborted).toBe(true); + expect(second.signal.aborted).toBe(false); + expect(result.current.controllerRef.current).toBe(second); + }); it('marks the hook as unmounted and aborts on cleanup', () => { - const { result, unmount } = renderHook(() => useAsyncLifecycle()) + const { result, unmount } = renderHook(() => useAsyncLifecycle()); - const controller = result.current.begin() - expect(result.current.mountedRef.current).toBe(true) + const controller = result.current.begin(); + expect(result.current.mountedRef.current).toBe(true); - unmount() + unmount(); - expect(result.current.mountedRef.current).toBe(false) - expect(controller.signal.aborted).toBe(true) - }) -}) + expect(result.current.mountedRef.current).toBe(false); + expect(controller.signal.aborted).toBe(true); + }); +}); diff --git a/src/__tests__/hooks.useHasSecret.test.tsx b/src/__tests__/hooks.useHasSecret.test.tsx index c99ba138..710924b9 100644 --- a/src/__tests__/hooks.useHasSecret.test.tsx +++ b/src/__tests__/hooks.useHasSecret.test.tsx @@ -1,23 +1,23 @@ -import { act, renderHook } from '@testing-library/react' -import { waitFor } from '@testing-library/dom' -import { HookError } from '../hooks/types' -import { useHasSecret } from '../hooks/useHasSecret' -import { hasItem } from '../core/storage' +import { act, renderHook } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; +import { HookError } from '../hooks/types'; +import { useHasSecret } from '../hooks/useHasSecret'; +import { hasItem } from '../core/storage'; jest.mock('../core/storage', () => ({ ...jest.requireActual('../core/storage'), hasItem: jest.fn(), -})) +})); -const mockedHasItem = hasItem as jest.MockedFunction +const mockedHasItem = hasItem as jest.MockedFunction; describe('useHasSecret', () => { beforeEach(() => { - mockedHasItem.mockReset() - }) + mockedHasItem.mockReset(); + }); it('returns the existence flag', async () => { - mockedHasItem.mockResolvedValueOnce(true) + mockedHasItem.mockResolvedValueOnce(true); const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => @@ -25,14 +25,14 @@ describe('useHasSecret', () => { { initialProps: { opts: { service: 'auth' } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBe(true) - expect(result.current.error).toBeNull() - expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' }) - }) + expect(result.current.data).toBe(true); + expect(result.current.error).toBeNull(); + expect(mockedHasItem).toHaveBeenCalledWith('token', { service: 'auth' }); + }); it('skips querying when requested', async () => { const { result } = renderHook( @@ -41,16 +41,16 @@ describe('useHasSecret', () => { { initialProps: { opts: { skip: true } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBeNull() - expect(mockedHasItem).not.toHaveBeenCalled() - }) + expect(result.current.data).toBeNull(); + expect(mockedHasItem).not.toHaveBeenCalled(); + }); it('wraps errors as HookError', async () => { - mockedHasItem.mockRejectedValueOnce(new Error('Native failure')) + mockedHasItem.mockRejectedValueOnce(new Error('Native failure')); const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => @@ -58,17 +58,17 @@ describe('useHasSecret', () => { { initialProps: { opts: { service: 'auth' } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBeNull() - expect(result.current.error).toBeInstanceOf(HookError) - expect(result.current.error?.message).toContain('useHasSecret.evaluate') - }) + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeInstanceOf(HookError); + expect(result.current.error?.message).toContain('useHasSecret.evaluate'); + }); it('supports manual refetching', async () => { - mockedHasItem.mockResolvedValueOnce(false).mockResolvedValueOnce(true) + mockedHasItem.mockResolvedValueOnce(false).mockResolvedValueOnce(true); const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => @@ -76,16 +76,16 @@ describe('useHasSecret', () => { { initialProps: { opts: { service: 'auth' } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) - expect(result.current.data).toBe(false) + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBe(false); await act(async () => { - await result.current.refetch() - }) + await result.current.refetch(); + }); - await waitFor(() => expect(result.current.data).toBe(true)) - expect(mockedHasItem).toHaveBeenCalledTimes(2) - }) -}) + await waitFor(() => expect(result.current.data).toBe(true)); + expect(mockedHasItem).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/hooks.useSecret.test.tsx b/src/__tests__/hooks.useSecret.test.tsx index 65b9a524..c37f3713 100644 --- a/src/__tests__/hooks.useSecret.test.tsx +++ b/src/__tests__/hooks.useSecret.test.tsx @@ -1,22 +1,22 @@ -import { act, renderHook } from '@testing-library/react' -import { deleteItem, setItem } from '../core/storage' -import { HookError } from '../hooks/types' -import { useSecret } from '../hooks/useSecret' -import { useSecretItem } from '../hooks/useSecretItem' +import { act, renderHook } from '@testing-library/react'; +import { deleteItem, setItem } from '../core/storage'; +import { HookError } from '../hooks/types'; +import { useSecret } from '../hooks/useSecret'; +import { useSecretItem } from '../hooks/useSecretItem'; -jest.mock('../hooks/useSecretItem') +jest.mock('../hooks/useSecretItem'); jest.mock('../core/storage', () => ({ ...jest.requireActual('../core/storage'), setItem: jest.fn(), deleteItem: jest.fn(), -})) +})); const mockedUseSecretItem = useSecretItem as jest.MockedFunction< typeof useSecretItem -> -const mockedSetItem = setItem as jest.MockedFunction -const mockedDeleteItem = deleteItem as jest.MockedFunction +>; +const mockedSetItem = setItem as jest.MockedFunction; +const mockedDeleteItem = deleteItem as jest.MockedFunction; describe('useSecret', () => { const baseResult = { @@ -24,34 +24,34 @@ describe('useSecret', () => { error: null, isLoading: false, isPending: false, - } + }; beforeEach(() => { - mockedSetItem.mockReset() - mockedDeleteItem.mockReset() - mockedUseSecretItem.mockReset() - }) + mockedSetItem.mockReset(); + mockedDeleteItem.mockReset(); + mockedUseSecretItem.mockReset(); + }); it('proxys data from useSecretItem', () => { - const refetch = jest.fn().mockResolvedValue(undefined) + const refetch = jest.fn().mockResolvedValue(undefined); mockedUseSecretItem.mockReturnValueOnce({ ...baseResult, data: { key: 'token', service: 'auth', metadata: {} as any }, refetch, - }) + }); const { result } = renderHook(() => useSecret('token', { service: 'auth', includeValue: true }) - ) + ); - expect(result.current.data?.key).toBe('token') - expect(result.current.refetch).toBe(refetch) - }) + expect(result.current.data?.key).toBe('token'); + expect(result.current.refetch).toBe(refetch); + }); it('saves secrets and triggers refetch', async () => { - const refetch = jest.fn().mockResolvedValue(undefined) - mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }) - mockedSetItem.mockResolvedValue({ metadata: {} as any }) + const refetch = jest.fn().mockResolvedValue(undefined); + mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }); + mockedSetItem.mockResolvedValue({ metadata: {} as any }); const { result } = renderHook(() => useSecret('token', { @@ -59,71 +59,73 @@ describe('useSecret', () => { includeValue: true, skip: true, }) - ) + ); await act(async () => { - const outcome = await result.current.saveSecret('secret') - expect(outcome).toEqual({ success: true }) - }) + const outcome = await result.current.saveSecret('secret'); + expect(outcome).toEqual({ success: true }); + }); expect(mockedSetItem).toHaveBeenCalledWith('token', 'secret', { service: 'auth', - }) - expect(refetch).toHaveBeenCalledTimes(1) - }) + }); + expect(refetch).toHaveBeenCalledTimes(1); + }); it('wraps save errors as HookError', async () => { - const refetch = jest.fn() - mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }) - mockedSetItem.mockRejectedValueOnce(new Error('save failed')) + const refetch = jest.fn(); + mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }); + mockedSetItem.mockRejectedValueOnce(new Error('save failed')); const { result } = renderHook(() => useSecret('token', { service: 'auth', includeValue: true }) - ) + ); - let response: { success: boolean; error?: HookError } | undefined + let response: { success: boolean; error?: HookError } | undefined; await act(async () => { - response = await result.current.saveSecret('secret') - }) + response = await result.current.saveSecret('secret'); + }); - expect(response).toEqual({ success: false, error: expect.any(HookError) }) - expect(refetch).not.toHaveBeenCalled() - }) + expect(response).toEqual({ success: false, error: expect.any(HookError) }); + expect(refetch).not.toHaveBeenCalled(); + }); it('deletes secrets and triggers refetch', async () => { - const refetch = jest.fn().mockResolvedValue(undefined) - mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }) - mockedDeleteItem.mockResolvedValue(true) + const refetch = jest.fn().mockResolvedValue(undefined); + mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }); + mockedDeleteItem.mockResolvedValue(true); const { result } = renderHook(() => useSecret('token', { service: 'auth', includeValue: true, }) - ) + ); await act(async () => { - const outcome = await result.current.deleteSecret() - expect(outcome).toEqual({ success: true }) - }) + const outcome = await result.current.deleteSecret(); + expect(outcome).toEqual({ success: true }); + }); - expect(mockedDeleteItem).toHaveBeenCalledWith('token', { service: 'auth' }) - expect(refetch).toHaveBeenCalledTimes(1) - }) + expect(mockedDeleteItem).toHaveBeenCalledWith('token', { service: 'auth' }); + expect(refetch).toHaveBeenCalledTimes(1); + }); it('wraps delete errors as HookError', async () => { - const refetch = jest.fn() - mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }) - mockedDeleteItem.mockRejectedValueOnce(new Error('delete failed')) + const refetch = jest.fn(); + mockedUseSecretItem.mockReturnValue({ ...baseResult, refetch }); + mockedDeleteItem.mockRejectedValueOnce(new Error('delete failed')); - const { result } = renderHook(() => useSecret('token', { service: 'auth' })) + const { result } = renderHook(() => + useSecret('token', { service: 'auth' }) + ); - let response: { success: boolean; error?: HookError } | undefined + let response: { success: boolean; error?: HookError } | undefined; await act(async () => { - response = await result.current.deleteSecret() - }) + response = await result.current.deleteSecret(); + }); - expect(response).toEqual({ success: false, error: expect.any(HookError) }) - expect(refetch).not.toHaveBeenCalled() - }) -}) + expect(response).toEqual({ success: false, error: expect.any(HookError) }); + expect(refetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/hooks.useSecretItem.test.tsx b/src/__tests__/hooks.useSecretItem.test.tsx index 56885740..debd4ec4 100644 --- a/src/__tests__/hooks.useSecretItem.test.tsx +++ b/src/__tests__/hooks.useSecretItem.test.tsx @@ -1,20 +1,20 @@ -import { act, renderHook } from '@testing-library/react' -import { waitFor } from '@testing-library/dom' -import { HookError } from '../hooks/types' -import { useSecretItem } from '../hooks/useSecretItem' -import { getItem } from '../core/storage' +import { act, renderHook } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; +import { HookError } from '../hooks/types'; +import { useSecretItem } from '../hooks/useSecretItem'; +import { getItem } from '../core/storage'; jest.mock('../core/storage', () => ({ ...jest.requireActual('../core/storage'), getItem: jest.fn(), -})) +})); -const mockedGetItem = getItem as jest.MockedFunction +const mockedGetItem = getItem as jest.MockedFunction; describe('useSecretItem', () => { beforeEach(() => { - mockedGetItem.mockReset() - }) + mockedGetItem.mockReset(); + }); it('returns the fetched item', async () => { mockedGetItem.mockResolvedValueOnce({ @@ -27,7 +27,7 @@ describe('useSecretItem', () => { accessControl: 'secureEnclaveBiometry', timestamp: 1, }, - }) + }); const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => @@ -35,17 +35,17 @@ describe('useSecretItem', () => { { initialProps: { opts: { service: 'auth' } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data?.value).toBe('value') - expect(result.current.error).toBeNull() + expect(result.current.data?.value).toBe('value'); + expect(result.current.error).toBeNull(); expect(mockedGetItem).toHaveBeenCalledWith('token', { service: 'auth', includeValue: true, - }) - }) + }); + }); it('skips fetching when requested', async () => { const { result } = renderHook( @@ -54,17 +54,17 @@ describe('useSecretItem', () => { { initialProps: { opts: { skip: true } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBeNull() - expect(result.current.error).toBeNull() - expect(mockedGetItem).not.toHaveBeenCalled() - }) + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + expect(mockedGetItem).not.toHaveBeenCalled(); + }); it('wraps failures in HookError', async () => { - mockedGetItem.mockRejectedValueOnce(new Error('Native failure')) + mockedGetItem.mockRejectedValueOnce(new Error('Native failure')); const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => @@ -72,14 +72,14 @@ describe('useSecretItem', () => { { initialProps: { opts: { service: 'auth' } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBeNull() - expect(result.current.error).toBeInstanceOf(HookError) - expect(result.current.error?.message).toContain('useSecretItem.fetch') - }) + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeInstanceOf(HookError); + expect(result.current.error?.message).toContain('useSecretItem.fetch'); + }); it('allows manual refetching', async () => { mockedGetItem.mockResolvedValueOnce(null).mockResolvedValueOnce({ @@ -91,7 +91,7 @@ describe('useSecretItem', () => { accessControl: 'secureEnclaveBiometry', timestamp: 2, }, - }) + }); const { result } = renderHook( ({ opts }: { opts: Parameters[1] }) => @@ -99,16 +99,16 @@ describe('useSecretItem', () => { { initialProps: { opts: { service: 'auth', includeValue: false } }, } - ) + ); - await waitFor(() => expect(result.current.isLoading).toBe(false)) - expect(result.current.data).toBeNull() + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toBeNull(); await act(async () => { - await result.current.refetch() - }) + await result.current.refetch(); + }); - await waitFor(() => expect(result.current.data).not.toBeNull()) - expect(mockedGetItem).toHaveBeenCalledTimes(2) - }) -}) + await waitFor(() => expect(result.current.data).not.toBeNull()); + expect(mockedGetItem).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/hooks.useSecureOperation.test.tsx b/src/__tests__/hooks.useSecureOperation.test.tsx index 09ad4491..96255306 100644 --- a/src/__tests__/hooks.useSecureOperation.test.tsx +++ b/src/__tests__/hooks.useSecureOperation.test.tsx @@ -1,36 +1,36 @@ -import { act, renderHook } from '@testing-library/react' -import { HookError } from '../hooks/types' -import { useSecureOperation } from '../hooks/useSecureOperation' +import { act, renderHook } from '@testing-library/react'; +import { HookError } from '../hooks/types'; +import { useSecureOperation } from '../hooks/useSecureOperation'; describe('useSecureOperation', () => { it('reports success when the callback resolves', async () => { - const { result } = renderHook(() => useSecureOperation()) + const { result } = renderHook(() => useSecureOperation()); await act(async () => { await result.current.execute(async () => { - await Promise.resolve() - }) - }) + await Promise.resolve(); + }); + }); - expect(result.current.error).toBeNull() - expect(result.current.isLoading).toBe(false) - expect(result.current.isPending).toBe(false) - }) + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.isPending).toBe(false); + }); it('wraps thrown errors in HookError', async () => { - const { result } = renderHook(() => useSecureOperation()) + const { result } = renderHook(() => useSecureOperation()); await act(async () => { await result.current.execute(async () => { - throw new Error('boom') - }) - }) + throw new Error('boom'); + }); + }); - expect(result.current.error).toBeInstanceOf(HookError) - expect(result.current.isLoading).toBe(false) - expect(result.current.isPending).toBe(false) + expect(result.current.error).toBeInstanceOf(HookError); + expect(result.current.isLoading).toBe(false); + expect(result.current.isPending).toBe(false); expect(result.current.error?.message).toContain( 'useSecureOperation.execute' - ) - }) -}) + ); + }); +}); diff --git a/src/__tests__/hooks.useSecureStorage.test.tsx b/src/__tests__/hooks.useSecureStorage.test.tsx index c014b249..e76012ee 100644 --- a/src/__tests__/hooks.useSecureStorage.test.tsx +++ b/src/__tests__/hooks.useSecureStorage.test.tsx @@ -1,11 +1,16 @@ -import { act, renderHook } from '@testing-library/react' -import { waitFor } from '@testing-library/dom' -import { clearService, deleteItem, getAllItems, setItem } from '../core/storage' -import { HookError } from '../hooks/types' +import { act, renderHook } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; +import { + clearService, + deleteItem, + getAllItems, + setItem, +} from '../core/storage'; +import { HookError } from '../hooks/types'; import { useSecureStorage, type UseSecureStorageOptions, -} from '../hooks/useSecureStorage' +} from '../hooks/useSecureStorage'; jest.mock('../core/storage', () => ({ ...jest.requireActual('../core/storage'), @@ -13,14 +18,16 @@ jest.mock('../core/storage', () => ({ setItem: jest.fn(), deleteItem: jest.fn(), clearService: jest.fn(), -})) +})); -const mockedGetAllItems = getAllItems as jest.MockedFunction -const mockedSetItem = setItem as jest.MockedFunction -const mockedDeleteItem = deleteItem as jest.MockedFunction +const mockedGetAllItems = getAllItems as jest.MockedFunction< + typeof getAllItems +>; +const mockedSetItem = setItem as jest.MockedFunction; +const mockedDeleteItem = deleteItem as jest.MockedFunction; const mockedClearService = clearService as jest.MockedFunction< typeof clearService -> +>; type MetadataOverrides = { securityLevel?: @@ -28,16 +35,16 @@ type MetadataOverrides = { | 'strongBox' | 'biometry' | 'deviceCredential' - | 'software' - backend?: 'keychain' | 'androidKeystore' | 'encryptedSharedPreferences' + | 'software'; + backend?: 'keychain' | 'androidKeystore' | 'encryptedSharedPreferences'; accessControl?: | 'secureEnclaveBiometry' | 'biometryCurrentSet' | 'biometryAny' | 'devicePasscode' - | 'none' - timestamp?: number -} + | 'none'; + timestamp?: number; +}; function buildMetadata(overrides: MetadataOverrides = {}) { return { @@ -45,29 +52,29 @@ function buildMetadata(overrides: MetadataOverrides = {}) { backend: overrides.backend ?? 'keychain', accessControl: overrides.accessControl ?? 'secureEnclaveBiometry', timestamp: overrides.timestamp ?? Date.now(), - } + }; } const buildItem = ( overrides: MetadataOverrides & { - key?: string - service?: string - value?: string + key?: string; + service?: string; + value?: string; } = {} ) => ({ key: overrides.key ?? 'token', service: overrides.service ?? 'auth', value: overrides.value, metadata: buildMetadata(overrides), -}) +}); describe('useSecureStorage', () => { beforeEach(() => { - mockedGetAllItems.mockReset() - mockedSetItem.mockReset() - mockedDeleteItem.mockReset() - mockedClearService.mockReset() - }) + mockedGetAllItems.mockReset(); + mockedSetItem.mockReset(); + mockedDeleteItem.mockReset(); + mockedClearService.mockReset(); + }); const renderStorage = (options?: UseSecureStorageOptions) => renderHook( @@ -76,170 +83,170 @@ describe('useSecureStorage', () => { { initialProps: { opts: options }, } - ) + ); it('loads items on mount', async () => { - mockedGetAllItems.mockResolvedValueOnce([buildItem({ value: 'secret' })]) + mockedGetAllItems.mockResolvedValueOnce([buildItem({ value: 'secret' })]); - const { result } = renderStorage({ service: 'auth', includeValues: true }) + const { result } = renderStorage({ service: 'auth', includeValues: true }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.items).toHaveLength(1) - expect(result.current.items[0]?.value).toBe('secret') - expect(result.current.error).toBeNull() + expect(result.current.items).toHaveLength(1); + expect(result.current.items[0]?.value).toBe('secret'); + expect(result.current.error).toBeNull(); expect(mockedGetAllItems).toHaveBeenCalledWith({ service: 'auth', includeValues: true, - }) - }) + }); + }); it('skips fetching when instructed', async () => { - const { result } = renderStorage({ skip: true }) + const { result } = renderStorage({ skip: true }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.items).toEqual([]) - expect(result.current.error).toBeNull() - expect(mockedGetAllItems).not.toHaveBeenCalled() - }) + expect(result.current.items).toEqual([]); + expect(result.current.error).toBeNull(); + expect(mockedGetAllItems).not.toHaveBeenCalled(); + }); it('stores HookError when fetching fails', async () => { - mockedGetAllItems.mockRejectedValueOnce(new Error('native failure')) + mockedGetAllItems.mockRejectedValueOnce(new Error('native failure')); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.items).toEqual([]) - expect(result.current.error).toBeInstanceOf(HookError) - }) + expect(result.current.items).toEqual([]); + expect(result.current.error).toBeInstanceOf(HookError); + }); it('exposes a refresh helper', async () => { mockedGetAllItems .mockResolvedValueOnce([]) - .mockResolvedValueOnce([buildItem({ key: 'next' })]) + .mockResolvedValueOnce([buildItem({ key: 'next' })]); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) - expect(result.current.items).toEqual([]) + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.items).toEqual([]); await act(async () => { - await result.current.refreshItems() - }) + await result.current.refreshItems(); + }); - await waitFor(() => expect(result.current.items).toHaveLength(1)) - expect(mockedGetAllItems).toHaveBeenCalledTimes(2) - }) + await waitFor(() => expect(result.current.items).toHaveLength(1)); + expect(mockedGetAllItems).toHaveBeenCalledTimes(2); + }); it('saves items and refreshes the list', async () => { - mockedGetAllItems.mockResolvedValue([]) - mockedSetItem.mockResolvedValueOnce({ metadata: buildMetadata() }) + mockedGetAllItems.mockResolvedValue([]); + mockedSetItem.mockResolvedValueOnce({ metadata: buildMetadata() }); - const { result } = renderStorage({ service: 'auth', includeValues: true }) + const { result } = renderStorage({ service: 'auth', includeValues: true }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); await act(async () => { - const outcome = await result.current.saveSecret('token', 'secret') - expect(outcome).toEqual({ success: true }) - }) + const outcome = await result.current.saveSecret('token', 'secret'); + expect(outcome).toEqual({ success: true }); + }); expect(mockedSetItem).toHaveBeenCalledWith('token', 'secret', { service: 'auth', - }) - expect(mockedGetAllItems).toHaveBeenCalledTimes(2) - }) + }); + expect(mockedGetAllItems).toHaveBeenCalledTimes(2); + }); it('surfaces errors from saveSecret', async () => { - mockedGetAllItems.mockResolvedValue([]) - mockedSetItem.mockRejectedValueOnce(new Error('set failed')) + mockedGetAllItems.mockResolvedValue([]); + mockedSetItem.mockRejectedValueOnce(new Error('set failed')); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); await act(async () => { - const outcome = await result.current.saveSecret('token', 'secret') - expect(outcome.success).toBe(false) - expect(outcome.error).toBeInstanceOf(HookError) - }) + const outcome = await result.current.saveSecret('token', 'secret'); + expect(outcome.success).toBe(false); + expect(outcome.error).toBeInstanceOf(HookError); + }); - expect(result.current.error).toBeInstanceOf(HookError) - }) + expect(result.current.error).toBeInstanceOf(HookError); + }); it('removes items locally when delete succeeds', async () => { mockedGetAllItems.mockResolvedValueOnce([ buildItem({ key: 'token', value: 'secret' }), - ]) - mockedDeleteItem.mockResolvedValueOnce(true) + ]); + mockedDeleteItem.mockResolvedValueOnce(true); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) - expect(result.current.items).toHaveLength(1) + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.items).toHaveLength(1); await act(async () => { - const outcome = await result.current.removeSecret('token') - expect(outcome).toEqual({ success: true }) - }) + const outcome = await result.current.removeSecret('token'); + expect(outcome).toEqual({ success: true }); + }); - expect(result.current.items).toEqual([]) - expect(mockedDeleteItem).toHaveBeenCalledWith('token', { service: 'auth' }) - }) + expect(result.current.items).toEqual([]); + expect(mockedDeleteItem).toHaveBeenCalledWith('token', { service: 'auth' }); + }); it('gracefully handles delete failures', async () => { - mockedGetAllItems.mockResolvedValueOnce([]) - mockedDeleteItem.mockRejectedValueOnce(new Error('delete failed')) + mockedGetAllItems.mockResolvedValueOnce([]); + mockedDeleteItem.mockRejectedValueOnce(new Error('delete failed')); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); await act(async () => { - const outcome = await result.current.removeSecret('token') - expect(outcome.success).toBe(false) - expect(outcome.error).toBeInstanceOf(HookError) - }) + const outcome = await result.current.removeSecret('token'); + expect(outcome.success).toBe(false); + expect(outcome.error).toBeInstanceOf(HookError); + }); - expect(result.current.error).toBeInstanceOf(HookError) - }) + expect(result.current.error).toBeInstanceOf(HookError); + }); it('clears the service and resets local state', async () => { mockedGetAllItems.mockResolvedValueOnce([ buildItem({ key: 'token', value: 'secret' }), - ]) - mockedClearService.mockResolvedValueOnce() + ]); + mockedClearService.mockResolvedValueOnce(); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); await act(async () => { - const outcome = await result.current.clearAll() - expect(outcome).toEqual({ success: true }) - }) + const outcome = await result.current.clearAll(); + expect(outcome).toEqual({ success: true }); + }); - expect(result.current.items).toEqual([]) - expect(result.current.error).toBeNull() - expect(mockedClearService).toHaveBeenCalledWith({ service: 'auth' }) - }) + expect(result.current.items).toEqual([]); + expect(result.current.error).toBeNull(); + expect(mockedClearService).toHaveBeenCalledWith({ service: 'auth' }); + }); it('records errors from clearAll', async () => { - mockedGetAllItems.mockResolvedValueOnce([]) - mockedClearService.mockRejectedValueOnce(new Error('clear failed')) + mockedGetAllItems.mockResolvedValueOnce([]); + mockedClearService.mockRejectedValueOnce(new Error('clear failed')); - const { result } = renderStorage({ service: 'auth' }) + const { result } = renderStorage({ service: 'auth' }); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); await act(async () => { - const outcome = await result.current.clearAll() - expect(outcome.success).toBe(false) - expect(outcome.error).toBeInstanceOf(HookError) - }) - - expect(result.current.error).toBeInstanceOf(HookError) - }) -}) + const outcome = await result.current.clearAll(); + expect(outcome.success).toBe(false); + expect(outcome.error).toBeInstanceOf(HookError); + }); + + expect(result.current.error).toBeInstanceOf(HookError); + }); +}); diff --git a/src/__tests__/hooks.useSecurityAvailability.test.tsx b/src/__tests__/hooks.useSecurityAvailability.test.tsx index 4155d19b..b6e60270 100644 --- a/src/__tests__/hooks.useSecurityAvailability.test.tsx +++ b/src/__tests__/hooks.useSecurityAvailability.test.tsx @@ -1,23 +1,23 @@ -import { act, renderHook } from '@testing-library/react' -import { waitFor } from '@testing-library/dom' -import { getSupportedSecurityLevels } from '../core/storage' -import { useSecurityAvailability } from '../hooks/useSecurityAvailability' -import { HookError } from '../hooks/types' +import { act, renderHook } from '@testing-library/react'; +import { waitFor } from '@testing-library/dom'; +import { getSupportedSecurityLevels } from '../core/storage'; +import { useSecurityAvailability } from '../hooks/useSecurityAvailability'; +import { HookError } from '../hooks/types'; jest.mock('../core/storage', () => ({ ...jest.requireActual('../core/storage'), getSupportedSecurityLevels: jest.fn(), -})) +})); const mockedGetSupportedSecurityLevels = getSupportedSecurityLevels as jest.MockedFunction< typeof getSupportedSecurityLevels - > + >; describe('useSecurityAvailability', () => { beforeEach(() => { - mockedGetSupportedSecurityLevels.mockReset() - }) + mockedGetSupportedSecurityLevels.mockReset(); + }); it('loads and caches the security capabilities', async () => { mockedGetSupportedSecurityLevels.mockResolvedValueOnce({ @@ -25,37 +25,37 @@ describe('useSecurityAvailability', () => { strongBox: false, biometry: true, deviceCredential: true, - }) + }); - const { result } = renderHook(() => useSecurityAvailability()) + const { result } = renderHook(() => useSecurityAvailability()); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); expect(result.current.data).toEqual({ secureEnclave: true, strongBox: false, biometry: true, deviceCredential: true, - }) - expect(result.current.error).toBeNull() - expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(1) - }) + }); + expect(result.current.error).toBeNull(); + expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(1); + }); it('wraps native errors as HookError', async () => { mockedGetSupportedSecurityLevels.mockRejectedValueOnce( new Error('native failure') - ) + ); - const { result } = renderHook(() => useSecurityAvailability()) + const { result } = renderHook(() => useSecurityAvailability()); - await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(result.current.isLoading).toBe(false)); - expect(result.current.data).toBeNull() - expect(result.current.error).toBeInstanceOf(HookError) + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeInstanceOf(HookError); expect(result.current.error?.message).toContain( 'useSecurityAvailability.fetch' - ) - }) + ); + }); it('refetch forces a fresh request even when cached', async () => { mockedGetSupportedSecurityLevels @@ -70,18 +70,18 @@ describe('useSecurityAvailability', () => { strongBox: true, biometry: true, deviceCredential: true, - }) + }); - const { result } = renderHook(() => useSecurityAvailability()) + const { result } = renderHook(() => useSecurityAvailability()); - await waitFor(() => expect(result.current.isLoading).toBe(false)) - expect(result.current.data?.strongBox).toBe(false) + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data?.strongBox).toBe(false); await act(async () => { - await result.current.refetch() - }) + await result.current.refetch(); + }); - await waitFor(() => expect(result.current.data?.strongBox).toBe(true)) - expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(2) - }) -}) + await waitFor(() => expect(result.current.data?.strongBox).toBe(true)); + expect(mockedGetSupportedSecurityLevels).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/__tests__/hooks.useStableOptions.test.tsx b/src/__tests__/hooks.useStableOptions.test.tsx index 1f491fb4..69e5f7e4 100644 --- a/src/__tests__/hooks.useStableOptions.test.tsx +++ b/src/__tests__/hooks.useStableOptions.test.tsx @@ -1,8 +1,8 @@ -import { renderHook } from '@testing-library/react' -import useStableOptions from '../hooks/useStableOptions' +import { renderHook } from '@testing-library/react'; +import useStableOptions from '../hooks/useStableOptions'; describe('useStableOptions', () => { - const defaults = { service: 'default', includeValues: false } + const defaults = { service: 'default', includeValues: false }; it('merges defaults with provided options', () => { const { result } = renderHook( @@ -13,13 +13,13 @@ describe('useStableOptions', () => { options: { includeValues: true, service: 'custom' }, }, } - ) + ); expect(result.current).toEqual({ service: 'custom', includeValues: true, - }) - }) + }); + }); it('reuses the cached object while options remain stable', () => { const { result, rerender } = renderHook( @@ -28,13 +28,13 @@ describe('useStableOptions', () => { { initialProps: { options: { includeValues: true } }, } - ) + ); - const first = result.current - rerender({ options: { includeValues: true } }) + const first = result.current; + rerender({ options: { includeValues: true } }); - expect(result.current).toBe(first) - }) + expect(result.current).toBe(first); + }); it('emits a new object when options change', () => { const { result, rerender } = renderHook( @@ -43,15 +43,15 @@ describe('useStableOptions', () => { { initialProps: { options: { includeValues: true } }, } - ) + ); - const first = result.current - rerender({ options: { includeValues: false } }) + const first = result.current; + rerender({ options: { includeValues: false } }); - expect(result.current).not.toBe(first) + expect(result.current).not.toBe(first); expect(result.current).toEqual({ service: 'default', includeValues: false, - }) - }) -}) + }); + }); +}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 099d7395..2ba4fede 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -12,25 +12,25 @@ import defaultExport, { useSecureOperation, useSecureStorage, useSecurityAvailability, -} from '../index' +} from '../index'; describe('package entrypoint', () => { it('re-exports the storage helpers', () => { - expect(defaultExport).toBe(SensitiveInfo) - expect(typeof setItem).toBe('function') - expect(typeof getItem).toBe('function') - expect(typeof getAllItems).toBe('function') - expect(typeof clearService).toBe('function') - expect(typeof getSupportedSecurityLevels).toBe('function') - expect(typeof hasItem).toBe('function') - }) + expect(defaultExport).toBe(SensitiveInfo); + expect(typeof setItem).toBe('function'); + expect(typeof getItem).toBe('function'); + expect(typeof getAllItems).toBe('function'); + expect(typeof clearService).toBe('function'); + expect(typeof getSupportedSecurityLevels).toBe('function'); + expect(typeof hasItem).toBe('function'); + }); it('exposes the hook surface area', () => { - expect(typeof useSecretItem).toBe('function') - expect(typeof useHasSecret).toBe('function') - expect(typeof useSecret).toBe('function') - expect(typeof useSecureStorage).toBe('function') - expect(typeof useSecureOperation).toBe('function') - expect(typeof useSecurityAvailability).toBe('function') - }) -}) + expect(typeof useSecretItem).toBe('function'); + expect(typeof useHasSecret).toBe('function'); + expect(typeof useSecret).toBe('function'); + expect(typeof useSecureStorage).toBe('function'); + expect(typeof useSecureOperation).toBe('function'); + expect(typeof useSecurityAvailability).toBe('function'); + }); +}); diff --git a/src/__tests__/internal.errors.test.ts b/src/__tests__/internal.errors.test.ts index 370b9622..5a2c544f 100644 --- a/src/__tests__/internal.errors.test.ts +++ b/src/__tests__/internal.errors.test.ts @@ -1,37 +1,37 @@ -import { getErrorMessage, isNotFoundError } from '../internal/errors' +import { getErrorMessage, isNotFoundError } from '../internal/errors'; describe('internal/errors', () => { describe('isNotFoundError', () => { it('detects tagged Error instances', () => { expect(isNotFoundError(new Error('Failure [E_NOT_FOUND] happened'))).toBe( true - ) - }) + ); + }); it('detects tagged strings', () => { - expect(isNotFoundError('Oops [E_NOT_FOUND] missing')).toBe(true) - }) + expect(isNotFoundError('Oops [E_NOT_FOUND] missing')).toBe(true); + }); it('returns false for unrelated payloads', () => { - expect(isNotFoundError(new Error('No tag here'))).toBe(false) - expect(isNotFoundError('All good')).toBe(false) - expect(isNotFoundError({})).toBe(false) - }) - }) + expect(isNotFoundError(new Error('No tag here'))).toBe(false); + expect(isNotFoundError('All good')).toBe(false); + expect(isNotFoundError({})).toBe(false); + }); + }); describe('getErrorMessage', () => { it('returns messages for Error instances', () => { expect(getErrorMessage(new Error('native failure'))).toBe( 'native failure' - ) - }) + ); + }); it('returns the string payload as-is', () => { - expect(getErrorMessage('[E_NATIVE] fatal')).toBe('[E_NATIVE] fatal') - }) + expect(getErrorMessage('[E_NATIVE] fatal')).toBe('[E_NATIVE] fatal'); + }); it('falls back to a generic message', () => { - expect(getErrorMessage(undefined)).toBe('An unknown error occurred') - }) - }) -}) + expect(getErrorMessage(undefined)).toBe('An unknown error occurred'); + }); + }); +}); diff --git a/src/__tests__/internal.native.test.ts b/src/__tests__/internal.native.test.ts index 7d01fd3e..13d0c568 100644 --- a/src/__tests__/internal.native.test.ts +++ b/src/__tests__/internal.native.test.ts @@ -1,35 +1,35 @@ -jest.mock('react-native-nitro-modules') +jest.mock('react-native-nitro-modules'); const { __resetMocks, getHybridObjectConstructor, MockHybridObject, -} = require('react-native-nitro-modules') +} = require('react-native-nitro-modules'); describe('internal/native', () => { beforeEach(() => { - jest.resetModules() - __resetMocks() - }) + jest.resetModules(); + __resetMocks(); + }); it('memoises the native instance', () => { - const { default: getNativeInstance } = require('../internal/native') - const first = getNativeInstance() - const second = getNativeInstance() + const { default: getNativeInstance } = require('../internal/native'); + const first = getNativeInstance(); + const second = getNativeInstance(); - expect(first).toBe(second) - }) + expect(first).toBe(second); + }); it('creates a fresh instance after module reset', () => { - const { default: loadA } = require('../internal/native') - const first = loadA() + const { default: loadA } = require('../internal/native'); + const first = loadA(); - jest.resetModules() - __resetMocks() + jest.resetModules(); + __resetMocks(); - const { default: loadB } = require('../internal/native') - const second = loadB() + const { default: loadB } = require('../internal/native'); + const second = loadB(); - expect(second).not.toBe(first) - }) -}) + expect(second).not.toBe(first); + }); +}); diff --git a/src/__tests__/internal.options.test.ts b/src/__tests__/internal.options.test.ts index 75f57e52..052300b4 100644 --- a/src/__tests__/internal.options.test.ts +++ b/src/__tests__/internal.options.test.ts @@ -2,15 +2,15 @@ import { DEFAULT_ACCESS_CONTROL, DEFAULT_SERVICE, normalizeOptions, -} from '../internal/options' +} from '../internal/options'; describe('internal/options', () => { it('returns defaults when no options are provided', () => { expect(normalizeOptions()).toEqual({ service: DEFAULT_SERVICE, accessControl: DEFAULT_ACCESS_CONTROL, - }) - }) + }); + }); it('applies defaults while preserving provided values', () => { expect( @@ -22,15 +22,15 @@ describe('internal/options', () => { service: 'custom', accessControl: DEFAULT_ACCESS_CONTROL, iosSynchronizable: true, - }) - }) + }); + }); it('propagates optional fields verbatim', () => { const prompt = { title: 'Authenticate', description: 'Custom prompt', cancel: 'Abort', - } + }; expect( normalizeOptions({ accessControl: 'biometryAny', @@ -42,6 +42,6 @@ describe('internal/options', () => { accessControl: 'biometryAny', keychainGroup: 'group.shared', authenticationPrompt: prompt, - }) - }) -}) + }); + }); +}); diff --git a/src/__tests__/storage.test.ts b/src/__tests__/storage.test.ts index 60efda31..c5e8a4cc 100644 --- a/src/__tests__/storage.test.ts +++ b/src/__tests__/storage.test.ts @@ -6,15 +6,15 @@ import { getSupportedSecurityLevels, hasItem, setItem, -} from '../core/storage' +} from '../core/storage'; -const mockSetItem = jest.fn().mockResolvedValue({ success: true }) -const mockGetItem = jest.fn().mockResolvedValue(null) -const mockHasItem = jest.fn().mockResolvedValue(true) -const mockDeleteItem = jest.fn().mockResolvedValue(true) -const mockGetAllItems = jest.fn().mockResolvedValue([]) -const mockClearService = jest.fn().mockResolvedValue(undefined) -const mockGetSupportedSecurityLevels = jest.fn().mockResolvedValue({}) +const mockSetItem = jest.fn().mockResolvedValue({ success: true }); +const mockGetItem = jest.fn().mockResolvedValue(null); +const mockHasItem = jest.fn().mockResolvedValue(true); +const mockDeleteItem = jest.fn().mockResolvedValue(true); +const mockGetAllItems = jest.fn().mockResolvedValue([]); +const mockClearService = jest.fn().mockResolvedValue(undefined); +const mockGetSupportedSecurityLevels = jest.fn().mockResolvedValue({}); jest.mock('../internal/native', () => jest.fn(() => ({ @@ -26,40 +26,40 @@ jest.mock('../internal/native', () => clearService: mockClearService, getSupportedSecurityLevels: mockGetSupportedSecurityLevels, })) -) +); describe('storage', () => { it('setItem calls native', async () => { - const result = await setItem('key', 'value', { service: 'test' }) - expect(result).toBeDefined() - }) + const result = await setItem('key', 'value', { service: 'test' }); + expect(result).toBeDefined(); + }); it('getItem calls native', async () => { - const result = await getItem('key', { service: 'test' }) - expect(result).toBeDefined() - }) + const result = await getItem('key', { service: 'test' }); + expect(result).toBeDefined(); + }); it('hasItem calls native', async () => { - const result = await hasItem('key', { service: 'test' }) - expect(typeof result).toBe('boolean') - }) + const result = await hasItem('key', { service: 'test' }); + expect(typeof result).toBe('boolean'); + }); it('deleteItem calls native', async () => { - const result = await deleteItem('key', { service: 'test' }) - expect(typeof result).toBe('boolean') - }) + const result = await deleteItem('key', { service: 'test' }); + expect(typeof result).toBe('boolean'); + }); it('getAllItems calls native', async () => { - const result = await getAllItems({ service: 'test' }) - expect(Array.isArray(result)).toBe(true) - }) + const result = await getAllItems({ service: 'test' }); + expect(Array.isArray(result)).toBe(true); + }); it('clearService calls native', async () => { - await clearService({ service: 'test' }) - }) + await clearService({ service: 'test' }); + }); it('getSupportedSecurityLevels calls native', async () => { - const result = await getSupportedSecurityLevels() - expect(result).toBeDefined() - }) -}) + const result = await getSupportedSecurityLevels(); + expect(result).toBeDefined(); + }); +}); diff --git a/src/core/storage.ts b/src/core/storage.ts index cc07711a..2368078d 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -8,23 +8,23 @@ import type { SensitiveInfoItem, SensitiveInfoOptions, SensitiveInfoSetRequest, -} from '../sensitive-info.nitro' -import getNativeInstance from '../internal/native' -import { normalizeOptions } from '../internal/options' -import { isNotFoundError } from '../internal/errors' +} from '../sensitive-info.nitro'; +import getNativeInstance from '../internal/native'; +import { normalizeOptions } from '../internal/options'; +import { isNotFoundError } from '../internal/errors'; /** * Strongly typed façade around the underlying Nitro native object. * Each function handles payload normalization before delegating to native code. */ export interface SensitiveInfoApi { - readonly setItem: typeof setItem - readonly getItem: typeof getItem - readonly hasItem: typeof hasItem - readonly deleteItem: typeof deleteItem - readonly getAllItems: typeof getAllItems - readonly clearService: typeof clearService - readonly getSupportedSecurityLevels: typeof getSupportedSecurityLevels + readonly setItem: typeof setItem; + readonly getItem: typeof getItem; + readonly hasItem: typeof hasItem; + readonly deleteItem: typeof deleteItem; + readonly getAllItems: typeof getAllItems; + readonly clearService: typeof clearService; + readonly getSupportedSecurityLevels: typeof getSupportedSecurityLevels; } /** @@ -36,13 +36,13 @@ export async function setItem( value: string, options?: SensitiveInfoOptions ): Promise { - const native = getNativeInstance() + const native = getNativeInstance(); const payload: SensitiveInfoSetRequest = { key, value, ...normalizeOptions(options), - } - return native.setItem(payload) + }; + return native.setItem(payload); } /** @@ -57,20 +57,20 @@ export async function getItem( key: string, options?: SensitiveInfoOptions & { includeValue?: boolean } ): Promise { - const native = getNativeInstance() + const native = getNativeInstance(); const payload: SensitiveInfoGetRequest = { key, includeValue: options?.includeValue ?? true, ...normalizeOptions(options), - } + }; try { - return await native.getItem(payload) + return await native.getItem(payload); } catch (error) { if (isNotFoundError(error)) { - return null + return null; } - throw error + throw error; } } @@ -86,12 +86,12 @@ export async function hasItem( key: string, options?: SensitiveInfoOptions ): Promise { - const native = getNativeInstance() + const native = getNativeInstance(); const payload: SensitiveInfoHasRequest = { key, ...normalizeOptions(options), - } - return native.hasItem(payload) + }; + return native.hasItem(payload); } /** @@ -106,12 +106,12 @@ export async function deleteItem( key: string, options?: SensitiveInfoOptions ): Promise { - const native = getNativeInstance() + const native = getNativeInstance(); const payload: SensitiveInfoDeleteRequest = { key, ...normalizeOptions(options), - } - return native.deleteItem(payload) + }; + return native.deleteItem(payload); } /** @@ -125,12 +125,12 @@ export async function deleteItem( export async function getAllItems( options?: SensitiveInfoEnumerateRequest ): Promise { - const native = getNativeInstance() + const native = getNativeInstance(); const payload: SensitiveInfoEnumerateRequest = { includeValues: options?.includeValues ?? false, ...normalizeOptions(options), - } - return native.getAllItems(payload) + }; + return native.getAllItems(payload); } /** @@ -144,8 +144,8 @@ export async function getAllItems( export async function clearService( options?: SensitiveInfoOptions ): Promise { - const native = getNativeInstance() - return native.clearService(normalizeOptions(options)) + const native = getNativeInstance(); + return native.clearService(normalizeOptions(options)); } /** @@ -157,8 +157,8 @@ export async function clearService( * ``` */ export function getSupportedSecurityLevels(): Promise { - const native = getNativeInstance() - return native.getSupportedSecurityLevels() + const native = getNativeInstance(); + return native.getSupportedSecurityLevels(); } /** @@ -173,6 +173,6 @@ export const SensitiveInfo: SensitiveInfoApi = { getAllItems, clearService, getSupportedSecurityLevels, -} +}; -export default SensitiveInfo +export default SensitiveInfo; diff --git a/src/hooks/error-utils.ts b/src/hooks/error-utils.ts index b0b91505..3ac25ff4 100644 --- a/src/hooks/error-utils.ts +++ b/src/hooks/error-utils.ts @@ -1,4 +1,7 @@ -import { getErrorMessage } from '../internal/errors' +import { + getErrorMessage, + isAuthenticationCanceledError as internalIsAuthenticationCanceledError, +} from '../internal/errors' import { HookError } from './types' /** @@ -9,10 +12,20 @@ const createHookError = ( error: unknown, hint?: string ): HookError => - new HookError(`${operation}: ${getErrorMessage(error)}`, { - cause: error, - operation, - hint, - }) + new HookError( + `${operation}: ${ + internalIsAuthenticationCanceledError(error) + ? 'Authentication prompt canceled by the user.' + : getErrorMessage(error) + }`, + { + cause: error, + operation, + hint, + } + ) + +export const isAuthenticationCanceledError = + internalIsAuthenticationCanceledError export default createHookError diff --git a/src/hooks/index.ts b/src/hooks/index.ts index b48beb07..93cafebb 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,32 +8,32 @@ export { type HookFailureResult, createHookSuccessResult, createHookFailureResult, -} from './types' +} from './types'; export { useSecretItem, type UseSecretItemOptions, type UseSecretItemResult, -} from './useSecretItem' +} from './useSecretItem'; export { useHasSecret, type UseHasSecretOptions, type UseHasSecretResult, -} from './useHasSecret' +} from './useHasSecret'; export { useSecureStorage, type UseSecureStorageOptions, type UseSecureStorageResult, -} from './useSecureStorage' +} from './useSecureStorage'; export { useSecurityAvailability, type UseSecurityAvailabilityResult, -} from './useSecurityAvailability' +} from './useSecurityAvailability'; export { useSecret, type UseSecretOptions, type UseSecretResult, -} from './useSecret' +} from './useSecret'; export { useSecureOperation, type UseSecureOperationResult, -} from './useSecureOperation' +} from './useSecureOperation'; diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 9ca17beb..2b096b5e 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -1,10 +1,10 @@ export interface HookErrorOptions { /** Root cause object forwarded from the underlying API. */ - readonly cause?: unknown + readonly cause?: unknown; /** Identifier describing the hook operation that failed (for example, `useSecretItem.fetch`). */ - readonly operation?: string + readonly operation?: string; /** Human-friendly hint rendered alongside the message. */ - readonly hint?: string + readonly hint?: string; } /** @@ -12,18 +12,18 @@ export interface HookErrorOptions { * Carries additional metadata to help debug issues in VS Code tooltips. */ export class HookError extends Error { - readonly operation?: string + readonly operation?: string; - readonly hint?: string + readonly hint?: string; constructor( message: string, { cause, operation, hint }: HookErrorOptions = {} ) { - super(message, { cause }) - this.name = 'HookError' - this.operation = operation - this.hint = hint + super(message, { cause }); + this.name = 'HookError'; + this.operation = operation; + this.hint = hint; } } @@ -31,41 +31,41 @@ export class HookError extends Error { * Canonical async state contract returned by most hooks. */ export interface AsyncState { - readonly data: T | null - readonly error: HookError | null - readonly isLoading: boolean - readonly isPending: boolean + readonly data: T | null; + readonly error: HookError | null; + readonly isLoading: boolean; + readonly isPending: boolean; } /** * Async state contract used by operations that do not emit data. */ export interface VoidAsyncState { - readonly error: HookError | null - readonly isLoading: boolean - readonly isPending: boolean + readonly error: HookError | null; + readonly isLoading: boolean; + readonly isPending: boolean; } /** * Successful outcome produced by hook mutation helpers. */ export interface HookSuccessResult { - readonly success: true - readonly error?: undefined + readonly success: true; + readonly error?: undefined; } /** * Failure outcome produced by hook mutation helpers. */ export interface HookFailureResult { - readonly success: false - readonly error: HookError + readonly success: false; + readonly error: HookError; } /** * Combined type returned by hook mutation helpers (`saveSecret`, `clearAll`, ...). */ -export type HookMutationResult = HookSuccessResult | HookFailureResult +export type HookMutationResult = HookSuccessResult | HookFailureResult; /** * Factory used to initialise {@link AsyncState} values. @@ -76,7 +76,7 @@ export function createInitialAsyncState(): AsyncState { error: null, isLoading: true, isPending: false, - } + }; } /** @@ -87,19 +87,19 @@ export function createInitialVoidState(): VoidAsyncState { error: null, isLoading: false, isPending: false, - } + }; } /** * Helper used to return a canonical success result from mutation helpers. */ export function createHookSuccessResult(): HookSuccessResult { - return { success: true } + return { success: true }; } /** * Helper used to return a canonical failure result from mutation helpers. */ export function createHookFailureResult(error: HookError): HookFailureResult { - return { success: false, error } + return { success: false, error }; } diff --git a/src/hooks/useAsyncLifecycle.ts b/src/hooks/useAsyncLifecycle.ts index 94630f68..3f80b8a1 100644 --- a/src/hooks/useAsyncLifecycle.ts +++ b/src/hooks/useAsyncLifecycle.ts @@ -1,15 +1,15 @@ import { useCallback, useEffect, useRef } from 'react' -import type { MutableRefObject } from 'react' +import type { RefObject } from 'react' export interface AsyncLifecycleControls { /** * Indicates whether the component that owns the hook is still mounted. Helpful when dispatching asynchronous state updates. */ - readonly mountedRef: MutableRefObject + readonly mountedRef: RefObject /** * Stores the last {@link AbortController} created by {@link begin}. Exposed for advanced scenarios such as manual cancellation. */ - readonly controllerRef: MutableRefObject + readonly controllerRef: RefObject /** * Aborts the previous async job (if any) and returns a fresh {@link AbortController} tied to the current execution flow. */ diff --git a/src/hooks/useHasSecret.ts b/src/hooks/useHasSecret.ts index b0b6cda2..557162cc 100644 --- a/src/hooks/useHasSecret.ts +++ b/src/hooks/useHasSecret.ts @@ -5,7 +5,7 @@ import { createInitialAsyncState } from './types' import type { AsyncState } from './types' import useAsyncLifecycle from './useAsyncLifecycle' import useStableOptions from './useStableOptions' -import createHookError from './error-utils' +import createHookError, { isAuthenticationCanceledError } from './error-utils' /** * Options accepted by {@link useHasSecret}. @@ -73,19 +73,28 @@ export function useHasSecret( isPending: false, }) } - } catch (error) { + } catch (errorLike) { if (mountedRef.current && !controller.signal.aborted) { - const hookError = createHookError( - 'useHasSecret.evaluate', - error, - 'Most commonly triggered by an invalid key/service combination.' - ) - setState({ - data: null, - error: hookError, - isLoading: false, - isPending: false, - }) + if (isAuthenticationCanceledError(errorLike)) { + setState((prev) => ({ + data: prev.data, + error: null, + isLoading: false, + isPending: false, + })) + } else { + const hookError = createHookError( + 'useHasSecret.evaluate', + errorLike, + 'Most commonly triggered by an invalid key/service combination.' + ) + setState({ + data: null, + error: hookError, + isLoading: false, + isPending: false, + }) + } } } }, [begin, key, mountedRef, stableOptions]) diff --git a/src/hooks/useSecret.ts b/src/hooks/useSecret.ts index 04d0cb2d..407d9383 100644 --- a/src/hooks/useSecret.ts +++ b/src/hooks/useSecret.ts @@ -23,8 +23,11 @@ export type UseSecretOptions = UseSecretItemOptions * Result bag returned by {@link useSecret}. */ export interface UseSecretResult extends AsyncState { + /** Persist a new value for the tracked secret and refresh the cache. */ readonly saveSecret: (value: string) => Promise + /** Delete the tracked secret from secure storage. */ readonly deleteSecret: () => Promise + /** Re-run the underlying fetch even if `skip` is enabled. */ readonly refetch: () => Promise } diff --git a/src/hooks/useSecretItem.ts b/src/hooks/useSecretItem.ts index f9f02060..1975b7c9 100644 --- a/src/hooks/useSecretItem.ts +++ b/src/hooks/useSecretItem.ts @@ -8,7 +8,7 @@ import { createInitialAsyncState } from './types' import type { AsyncState } from './types' import useAsyncLifecycle from './useAsyncLifecycle' import useStableOptions from './useStableOptions' -import createHookError from './error-utils' +import createHookError, { isAuthenticationCanceledError } from './error-utils' /** * Configuration accepted by {@link useSecretItem}. @@ -88,19 +88,28 @@ export function useSecretItem( isPending: false, }) } - } catch (error) { + } catch (errorLike) { if (mountedRef.current && !controller.signal.aborted) { - const hookError = createHookError( - 'useSecretItem.fetch', - error, - 'Verify that the key/service pair exists and that includeValue is allowed for the caller.' - ) - setState({ - data: null, - error: hookError, - isLoading: false, - isPending: false, - }) + if (isAuthenticationCanceledError(errorLike)) { + setState((prev) => ({ + data: prev.data, + error: null, + isLoading: false, + isPending: false, + })) + } else { + const hookError = createHookError( + 'useSecretItem.fetch', + errorLike, + 'Verify that the key/service pair exists and that includeValue is allowed for the caller.' + ) + setState({ + data: null, + error: hookError, + isLoading: false, + isPending: false, + }) + } } } }, [begin, key, mountedRef, stableOptions]) diff --git a/src/hooks/useSecureOperation.ts b/src/hooks/useSecureOperation.ts index 54f95b3e..78c1e6c3 100644 --- a/src/hooks/useSecureOperation.ts +++ b/src/hooks/useSecureOperation.ts @@ -2,12 +2,13 @@ import { useCallback, useState } from 'react' import { createInitialVoidState } from './types' import type { VoidAsyncState } from './types' import useAsyncLifecycle from './useAsyncLifecycle' -import createHookError from './error-utils' +import createHookError, { isAuthenticationCanceledError } from './error-utils' /** * Result returned by {@link useSecureOperation}. */ export interface UseSecureOperationResult extends VoidAsyncState { + /** Executes the secured procedure while tracking loading and error state. */ readonly execute: (operation: () => Promise) => Promise } @@ -47,15 +48,23 @@ export function useSecureOperation(): UseSecureOperationResult { } } catch (errorLike) { if (mountedRef.current && !controller.signal.aborted) { - setState({ - error: createHookError( - 'useSecureOperation.execute', - errorLike, - 'Review the async callback passed to execute() for thrown errors.' - ), - isLoading: false, - isPending: false, - }) + if (isAuthenticationCanceledError(errorLike)) { + setState({ + error: null, + isLoading: false, + isPending: false, + }) + } else { + setState({ + error: createHookError( + 'useSecureOperation.execute', + errorLike, + 'Review the async callback passed to execute() for thrown errors.' + ), + isLoading: false, + isPending: false, + }) + } } } }, diff --git a/src/hooks/useSecureStorage.ts b/src/hooks/useSecureStorage.ts index 47188647..be8d6825 100644 --- a/src/hooks/useSecureStorage.ts +++ b/src/hooks/useSecureStorage.ts @@ -12,7 +12,7 @@ import { } from './types' import useAsyncLifecycle from './useAsyncLifecycle' import useStableOptions from './useStableOptions' -import createHookError from './error-utils' +import createHookError, { isAuthenticationCanceledError } from './error-utils' /** * Options accepted by {@link useSecureStorage}. @@ -45,15 +45,22 @@ const extractCoreOptions = ( * Structure returned by {@link useSecureStorage}. */ export interface UseSecureStorageResult { + /** Latest snapshot of secrets returned by the underlying secure storage. */ readonly items: SensitiveInfoItem[] + /** Indicates whether initial or subsequent fetches are running. */ readonly isLoading: boolean + /** Hook-level error describing the last failure, if any. */ readonly error: HookError | null + /** Persist or replace a secret and refresh the cached list. */ readonly saveSecret: ( key: string, value: string ) => Promise + /** Delete a secret from secure storage and update the local cache. */ readonly removeSecret: (key: string) => Promise + /** Remove every secret associated with the configured service. */ readonly clearAll: () => Promise + /** Manually refresh the secure storage contents without mutating data. */ readonly refreshItems: () => Promise } @@ -83,6 +90,25 @@ export function useSecureStorage( options ) + const applyError = useCallback( + (operation: string, errorLike: unknown, hint: string): HookError => { + const hookError = createHookError(operation, errorLike, hint) + + if (isAuthenticationCanceledError(errorLike)) { + if (mountedRef.current) { + setError(null) + } + return hookError + } + + if (mountedRef.current) { + setError(hookError) + } + return hookError + }, + [mountedRef] + ) + const fetchItems = useCallback(async () => { const { skip, ...requestOptions } = stableOptions @@ -105,13 +131,17 @@ export function useSecureStorage( } } catch (errorLike) { if (mountedRef.current && !controller.signal.aborted) { - const hookError = createHookError( + const canceled = isAuthenticationCanceledError(errorLike) + + applyError( 'useSecureStorage.fetchItems', errorLike, 'Ensure the service name matches the one used when storing the items.' ) - setError(hookError) - setItems([]) + + if (!canceled) { + setItems([]) + } } } finally { if (mountedRef.current && !controller.signal.aborted) { @@ -137,18 +167,15 @@ export function useSecureStorage( } return createHookSuccessResult() } catch (errorLike) { - const hookError = createHookError( + const hookError = applyError( 'useSecureStorage.saveSecret', errorLike, 'Check for duplicate keys or permission prompts that might have been dismissed.' ) - if (mountedRef.current) { - setError(hookError) - } return createHookFailureResult(hookError) } }, - [fetchItems, mountedRef, stableOptions] + [applyError, fetchItems, mountedRef, stableOptions] ) const removeSecret = useCallback( @@ -160,18 +187,15 @@ export function useSecureStorage( } return createHookSuccessResult() } catch (errorLike) { - const hookError = createHookError( + const hookError = applyError( 'useSecureStorage.removeSecret', errorLike, 'Confirm the item still exists or that the user completed biometric prompts.' ) - if (mountedRef.current) { - setError(hookError) - } return createHookFailureResult(hookError) } }, - [mountedRef, stableOptions] + [applyError, mountedRef, stableOptions] ) const clearAll = useCallback(async () => { @@ -183,17 +207,14 @@ export function useSecureStorage( } return createHookSuccessResult() } catch (errorLike) { - const hookError = createHookError( + const hookError = applyError( 'useSecureStorage.clearAll', errorLike, 'Inspect whether another process holds a lock on the secure storage.' ) - if (mountedRef.current) { - setError(hookError) - } return createHookFailureResult(hookError) } - }, [mountedRef, stableOptions]) + }, [applyError, mountedRef, stableOptions]) return { items, diff --git a/src/hooks/useSecurityAvailability.ts b/src/hooks/useSecurityAvailability.ts index 78e382e5..2b833a42 100644 --- a/src/hooks/useSecurityAvailability.ts +++ b/src/hooks/useSecurityAvailability.ts @@ -1,17 +1,17 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import type { SecurityAvailability } from '../sensitive-info.nitro' -import { getSupportedSecurityLevels } from '../core/storage' -import { createInitialAsyncState } from './types' -import type { AsyncState } from './types' -import useAsyncLifecycle from './useAsyncLifecycle' -import createHookError from './error-utils' +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { SecurityAvailability } from '../sensitive-info.nitro'; +import { getSupportedSecurityLevels } from '../core/storage'; +import { createInitialAsyncState } from './types'; +import type { AsyncState } from './types'; +import useAsyncLifecycle from './useAsyncLifecycle'; +import createHookError from './error-utils'; /** * Result returned by {@link useSecurityAvailability}. */ export interface UseSecurityAvailabilityResult extends AsyncState { - refetch: () => Promise + refetch: () => Promise; } /** @@ -28,15 +28,15 @@ export interface UseSecurityAvailabilityResult export function useSecurityAvailability(): UseSecurityAvailabilityResult { const [state, setState] = useState>( createInitialAsyncState() - ) + ); - const cacheRef = useRef(null) - const dataRef = useRef(state.data) - const { begin, mountedRef } = useAsyncLifecycle() + const cacheRef = useRef(null); + const dataRef = useRef(state.data); + const { begin, mountedRef } = useAsyncLifecycle(); useEffect(() => { - dataRef.current = state.data - }, [state.data]) + dataRef.current = state.data; + }, [state.data]); const fetchAvailability = useCallback( async (force = false) => { @@ -46,24 +46,24 @@ export function useSecurityAvailability(): UseSecurityAvailabilityResult { error: null, isLoading: false, isPending: false, - }) - return + }); + return; } - const controller = begin() - setState((prev) => ({ ...prev, isLoading: true, isPending: true })) + const controller = begin(); + setState((prev) => ({ ...prev, isLoading: true, isPending: true })); try { - const capabilities = await getSupportedSecurityLevels() + const capabilities = await getSupportedSecurityLevels(); if (mountedRef.current && !controller.signal.aborted) { - cacheRef.current = capabilities + cacheRef.current = capabilities; setState({ data: capabilities, error: null, isLoading: false, isPending: false, - }) + }); } } catch (error) { if (mountedRef.current && !controller.signal.aborted) { @@ -71,29 +71,29 @@ export function useSecurityAvailability(): UseSecurityAvailabilityResult { 'useSecurityAvailability.fetch', error, 'Try calling SensitiveInfo.getSupportedSecurityLevels() directly to inspect the native error.' - ) + ); setState({ data: null, error: hookError, isLoading: false, isPending: false, - }) + }); } } }, [begin, mountedRef] - ) + ); useEffect(() => { - fetchAvailability().catch(() => {}) - }, [fetchAvailability]) + fetchAvailability().catch(() => {}); + }, [fetchAvailability]); const refetch = useCallback(async () => { - await fetchAvailability(true) - }, [fetchAvailability]) + await fetchAvailability(true); + }, [fetchAvailability]); return { ...state, refetch, - } + }; } diff --git a/src/hooks/useStableOptions.ts b/src/hooks/useStableOptions.ts index 0cdc3d84..50f5e534 100644 --- a/src/hooks/useStableOptions.ts +++ b/src/hooks/useStableOptions.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react' +import { useMemo, useRef } from 'react'; /** * Ensures that option objects remain referentially stable between renders without sacrificing readability. @@ -16,20 +16,20 @@ const useStableOptions = ( defaults: Partial, options?: Partial | null ): T => { - const cacheKeyRef = useRef('') - const valueRef = useRef(null) + const cacheKeyRef = useRef(''); + const valueRef = useRef(null); return useMemo(() => { - const serialized = JSON.stringify(options ?? null) + const serialized = JSON.stringify(options ?? null); if (serialized === cacheKeyRef.current && valueRef.current != null) { - return valueRef.current + return valueRef.current; } - const merged = { ...defaults, ...(options ?? {}) } as T - cacheKeyRef.current = serialized - valueRef.current = merged - return merged - }, [options, defaults]) -} + const merged = { ...defaults, ...(options ?? {}) } as T; + cacheKeyRef.current = serialized; + valueRef.current = merged; + return merged; + }, [options, defaults]); +}; -export default useStableOptions +export default useStableOptions; diff --git a/src/index.ts b/src/index.ts index 7d668c46..df3d28aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ export type { SensitiveInfoSetRequest, StorageBackend, StorageMetadata, -} from './sensitive-info.nitro' +} from './sensitive-info.nitro'; /** * Core storage helpers that mirror the native Nitro surface. @@ -32,9 +32,9 @@ export { hasItem, setItem, type SensitiveInfoApi, -} from './core/storage' +} from './core/storage'; -export { default } from './core/storage' +export { default } from './core/storage'; /** * React hooks and utility types to integrate the secure store with React components. @@ -64,4 +64,4 @@ export { type UseSecurityAvailabilityResult, type AsyncState, type VoidAsyncState, -} from './hooks' +} from './hooks'; diff --git a/src/internal/errors.ts b/src/internal/errors.ts index d5e8f1cf..a0dbc483 100644 --- a/src/internal/errors.ts +++ b/src/internal/errors.ts @@ -1,14 +1,29 @@ /** * Shared error helpers used across infrastructure layers and hooks. */ -export function isNotFoundError(error: unknown): boolean { + +const NOT_FOUND_MARKER = '[E_NOT_FOUND]'; +const AUTH_CANCELED_MARKER = '[E_AUTH_CANCELED]'; + +const hasErrorMarker = (error: unknown, marker: string): boolean => { if (error instanceof Error) { - return error.message.includes('[E_NOT_FOUND]') + return error.message.includes(marker); } if (typeof error === 'string') { - return error.includes('[E_NOT_FOUND]') + return error.includes(marker); } - return false + return false; +}; + +export function isNotFoundError(error: unknown): boolean { + return hasErrorMarker(error, NOT_FOUND_MARKER); +} + +/** + * Determines whether an error value represents a cancelled authentication prompt. + */ +export function isAuthenticationCanceledError(error: unknown): boolean { + return hasErrorMarker(error, AUTH_CANCELED_MARKER); } /** @@ -17,10 +32,10 @@ export function isNotFoundError(error: unknown): boolean { */ export function getErrorMessage(error: unknown): string { if (error instanceof Error) { - return error.message + return error.message; } if (typeof error === 'string') { - return error + return error; } - return 'An unknown error occurred' + return 'An unknown error occurred'; } diff --git a/src/internal/native.ts b/src/internal/native.ts index 7fc150e3..c6ee8045 100644 --- a/src/internal/native.ts +++ b/src/internal/native.ts @@ -1,19 +1,19 @@ -import { getHybridObjectConstructor } from 'react-native-nitro-modules' -import type { SensitiveInfo as NativeHandle } from '../sensitive-info.nitro' +import { getHybridObjectConstructor } from 'react-native-nitro-modules'; +import type { SensitiveInfo as NativeHandle } from '../sensitive-info.nitro'; -type NativeCtor = new () => NativeHandle +type NativeCtor = new () => NativeHandle; const SensitiveInfoCtor: NativeCtor = - getHybridObjectConstructor('SensitiveInfo') + getHybridObjectConstructor('SensitiveInfo'); -let cachedInstance: NativeHandle | null = null +let cachedInstance: NativeHandle | null = null; /** * Lazily instantiates and memoises the Nitro hybrid object. */ export default function getNativeInstance(): NativeHandle { if (cachedInstance == null) { - cachedInstance = new SensitiveInfoCtor() + cachedInstance = new SensitiveInfoCtor(); } - return cachedInstance + return cachedInstance; } diff --git a/src/internal/options.ts b/src/internal/options.ts index ef48a704..3f5c043c 100644 --- a/src/internal/options.ts +++ b/src/internal/options.ts @@ -1,10 +1,10 @@ import type { AccessControl, SensitiveInfoOptions, -} from '../sensitive-info.nitro' +} from '../sensitive-info.nitro'; -export const DEFAULT_SERVICE = 'default' -export const DEFAULT_ACCESS_CONTROL: AccessControl = 'secureEnclaveBiometry' +export const DEFAULT_SERVICE = 'default'; +export const DEFAULT_ACCESS_CONTROL: AccessControl = 'secureEnclaveBiometry'; /** * Normalises user supplied options by applying defaults and pruning `undefined` fields. @@ -16,7 +16,7 @@ export function normalizeOptions( return { service: DEFAULT_SERVICE, accessControl: DEFAULT_ACCESS_CONTROL, - } + }; } const { @@ -25,7 +25,7 @@ export function normalizeOptions( iosSynchronizable, keychainGroup, authenticationPrompt, - } = options + } = options; return { service, @@ -33,5 +33,5 @@ export function normalizeOptions( iosSynchronizable, keychainGroup, authenticationPrompt, - } + }; } diff --git a/src/sensitive-info.nitro.ts b/src/sensitive-info.nitro.ts index 10fee92d..a2dece30 100644 --- a/src/sensitive-info.nitro.ts +++ b/src/sensitive-info.nitro.ts @@ -1,4 +1,4 @@ -import type { HybridObject } from 'react-native-nitro-modules' +import type { HybridObject } from 'react-native-nitro-modules'; /** * Captures how strong the effective protection was when a value got persisted. @@ -12,7 +12,7 @@ export type SecurityLevel = | 'strongBox' | 'biometry' | 'deviceCredential' - | 'software' + | 'software'; /** * Enumerates which native database held the encrypted record. This is useful for auditing mixed @@ -21,7 +21,7 @@ export type SecurityLevel = export type StorageBackend = | 'keychain' | 'androidKeystore' - | 'encryptedSharedPreferences' + | 'encryptedSharedPreferences'; /** @see SensitiveInfoOptions.accessControl */ export type AccessControl = @@ -29,7 +29,7 @@ export type AccessControl = | 'biometryCurrentSet' | 'biometryAny' | 'devicePasscode' - | 'none' + | 'none'; /** * Human-friendly strings that will be rendered on biometric/device credential prompts. @@ -45,10 +45,10 @@ export type AccessControl = * ``` */ export interface AuthenticationPrompt { - readonly title: string - readonly subtitle?: string - readonly description?: string - readonly cancel?: string + readonly title: string; + readonly subtitle?: string; + readonly description?: string; + readonly cancel?: string; } /** @@ -61,49 +61,49 @@ export interface AuthenticationPrompt { */ export interface SensitiveInfoOptions { /** Namespaces the stored entry. Defaults to the bundle identifier (when available) or `default`. */ - readonly service?: string + readonly service?: string; /** Apple platforms: Enables Keychain sync through iCloud. */ - readonly iosSynchronizable?: boolean + readonly iosSynchronizable?: boolean; /** Apple platforms: Custom Keychain access group. */ - readonly keychainGroup?: string + readonly keychainGroup?: string; /** * Desired access-control policy. The native implementation automatically downgrades to the * strongest supported strategy (Secure Enclave ➝ Biometry ➝ Device Credential ➝ None). */ - readonly accessControl?: AccessControl + readonly accessControl?: AccessControl; /** Optional prompt strings displayed when user presence is required to open the key. */ - readonly authenticationPrompt?: AuthenticationPrompt + readonly authenticationPrompt?: AuthenticationPrompt; } export interface SensitiveInfoSetRequest extends SensitiveInfoOptions { - readonly key: string - readonly value: string + readonly key: string; + readonly value: string; } export interface SensitiveInfoGetRequest extends SensitiveInfoOptions { - readonly key: string + readonly key: string; /** Include the encrypted value when available. Defaults to true. */ - readonly includeValue?: boolean + readonly includeValue?: boolean; } export interface SensitiveInfoDeleteRequest extends SensitiveInfoOptions { - readonly key: string + readonly key: string; } export interface SensitiveInfoHasRequest extends SensitiveInfoOptions { - readonly key: string + readonly key: string; } export interface SensitiveInfoEnumerateRequest extends SensitiveInfoOptions { /** When true, the stored value is returned for each item. Defaults to false. */ - readonly includeValues?: boolean + readonly includeValues?: boolean; } export interface StorageMetadata { - readonly securityLevel: SecurityLevel - readonly backend: StorageBackend - readonly accessControl: AccessControl - readonly timestamp: number + readonly securityLevel: SecurityLevel; + readonly backend: StorageBackend; + readonly accessControl: AccessControl; + readonly timestamp: number; } /** @@ -111,10 +111,10 @@ export interface StorageMetadata { * decryption or when the key is still hardware-gated (for example, prior to biometric verification). */ export interface SensitiveInfoItem { - readonly key: string - readonly service: string - readonly value?: string - readonly metadata: StorageMetadata + readonly key: string; + readonly service: string; + readonly value?: string; + readonly metadata: StorageMetadata; } /** @@ -122,7 +122,7 @@ export interface SensitiveInfoItem { * protecting the freshly written entry. */ export interface MutationResult { - readonly metadata: StorageMetadata + readonly metadata: StorageMetadata; } /** @@ -131,23 +131,23 @@ export interface MutationResult { * StrongBox support. This mirrors the format returned by `getSupportedSecurityLevels()`. */ export interface SecurityAvailability { - readonly secureEnclave: boolean - readonly strongBox: boolean - readonly biometry: boolean - readonly deviceCredential: boolean + readonly secureEnclave: boolean; + readonly strongBox: boolean; + readonly biometry: boolean; + readonly deviceCredential: boolean; } export interface SensitiveInfo extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { - setItem(request: SensitiveInfoSetRequest): Promise - getItem(request: SensitiveInfoGetRequest): Promise - deleteItem(request: SensitiveInfoDeleteRequest): Promise - hasItem(request: SensitiveInfoHasRequest): Promise + setItem(request: SensitiveInfoSetRequest): Promise; + getItem(request: SensitiveInfoGetRequest): Promise; + deleteItem(request: SensitiveInfoDeleteRequest): Promise; + hasItem(request: SensitiveInfoHasRequest): Promise; getAllItems( request?: SensitiveInfoEnumerateRequest - ): Promise - clearService(request?: SensitiveInfoOptions): Promise - getSupportedSecurityLevels(): Promise + ): Promise; + clearService(request?: SensitiveInfoOptions): Promise; + getSupportedSecurityLevels(): Promise; } -export type SensitiveInfoSpec = SensitiveInfo +export type SensitiveInfoSpec = SensitiveInfo; From 69876acc1277114e674899b1e49b3ce0faea45e3 Mon Sep 17 00:00:00 2001 From: Mateus Andrade Date: Mon, 3 Nov 2025 12:02:45 -0300 Subject: [PATCH 4/4] docs: add error-handling section to README and introduce SECURITY.md --- README.md | 42 ++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index 5682a24e..8ab03dde 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Modern secure storage for React Native, powered by Nitro Modules. Version 6 ship - [⚡️ Quick start](#-quick-start) - [📚 API reference](#-api-reference) - [🔐 Access control & metadata](#-access-control--metadata) +- [❗ Error handling](#-error-handling) - [🧪 Simulators and emulators](#-simulators-and-emulators) - [📈 Performance benchmarks](#-performance-benchmarks) - [🎮 Example application](#-example-application) @@ -219,6 +220,47 @@ function YourComponent() { For comprehensive examples and advanced patterns, see [`HOOKS.md`](./HOOKS.md). +## ❗ Error handling + +Every public hook returns failures as `HookError` instances. Besides `message`, each error carries: + +- `operation` – the hook action that failed (for example, `useSecureStorage.saveSecret`). +- `cause` – the original native error for additional diagnostics. +- `hint` – a short suggestion shown in the example app and useful for toast copy. + +Biometric or device-credential prompts cancelled by the user now surface as a friendly message (`Authentication prompt canceled by the user.`) and *do not* poison hook state. Imperative calls still reject with the raw error so you can decide how to react. + +```tsx +import { Text } from 'react-native' +import { useSecureStorage } from 'react-native-sensitive-info' + +function SecretsList() { + const { items, error } = useSecureStorage({ service: 'auth', includeValues: true }) + + if (error) { + if (error.message.includes('Authentication prompt canceled')) { + return The user dismissed biometric authentication. + } + + return ( + + {error.message} + {'\n'}Hint: {error.hint ?? 'Check your secure storage configuration.'} + + ) + } + + return items.length === 0 ? ( + No secrets stored yet. + ) : ( + {items.map((item) => item.key).join(', ')} + ) +} +``` + +> [!TIP] +> When using the imperative API, look for the `[E_AUTH_CANCELED]` marker in the thrown error message to detect cancellations. + ## Imperative API ```tsx diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..90bd65be --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| --- | --- | +| 6.x | ✅ Supported +| 5.6.x | ✅ Supported +| < 5.6.0 | ❌ Not supported + +We ship security fixes for the current v6 line and the latest v5 maintenance branch (≥ 5.6.0). Releases prior to 5.6.0 no longer receive patches—upgrade as soon as possible to stay protected. + +## Reporting a Vulnerability + +1. **Contact**: Email security reports to . +2. **Disclosure Window**: We aim to acknowledge reports within 3 business days and provide a remediation plan within 10 business days. +3. **Coordinated Disclosure**: Please refrain from publicly disclosing the issue until a fix is available or 30 days have passed since acknowledgement. + +## Patch Process + +- Critical fixes ship in a point release for the supported branches (6.x and ≥ 5.6.0). +- Vulnerability advisories are published on the GitHub release page and npm once patches are available. +- We credit reporters who follow coordinated disclosure and wish to be acknowledged. + +## Hardening Recommendations + +- Stay on the latest minor release within your major version to receive defense-in-depth updates. +- Review the [Access control & metadata](README.md#-access-control--metadata) section for guidance on choosing the strongest policies. +- Test secure storage flows on physical hardware before shipping; emulators often omit secure elements. + +Thank you for helping us keep `react-native-sensitive-info` secure.