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/README.md b/README.md
index 1ec3cd3c..8ab03dde 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
@@ -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.
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/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()}
-
-
-
- ))
- )}
-
-
-
-
-
- );
+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
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/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 db787d62..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",
@@ -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/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;
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"