From c54d7d3c647b5f46bf56b4dac75f5287778465d8 Mon Sep 17 00:00:00 2001 From: alexprigi Date: Fri, 16 Oct 2026 13:35:57 +0200 Subject: [PATCH 1/4] feat(storage-plugin): add react-native-keychain adapter --- .changeset/storage-plugin-keychain-adapter.md | 5 + apps/playground/package.json | 1 + .../src/app/screens/StoragePluginScreen.tsx | 31 +++- .../src/app/storage-plugin-adapters.ts | 5 + packages/storage-plugin/README.md | 8 +- packages/storage-plugin/react-native.ts | 17 ++ .../__tests__/keychain-adapter.test.ts | 161 ++++++++++++++++++ .../src/react-native/adapters/index.ts | 3 + .../src/react-native/adapters/keychain.ts | 123 +++++++++++++ pnpm-lock.yaml | 9 + .../docs/docs/official-plugins/storage.mdx | 26 ++- 11 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 .changeset/storage-plugin-keychain-adapter.md create mode 100644 packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts create mode 100644 packages/storage-plugin/src/react-native/adapters/keychain.ts diff --git a/.changeset/storage-plugin-keychain-adapter.md b/.changeset/storage-plugin-keychain-adapter.md new file mode 100644 index 000000000..8f2c5f428 --- /dev/null +++ b/.changeset/storage-plugin-keychain-adapter.md @@ -0,0 +1,5 @@ +--- +'@rozenite/storage-plugin': minor +--- + +Add `createKeychainStorageAdapter` to inspect and edit react-native-keychain (iOS Keychain and Android Keystore) entries in the Storage panel. diff --git a/apps/playground/package.json b/apps/playground/package.json index f41e25f06..77a0f04f3 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -69,6 +69,7 @@ "react-native-gesture-handler": "~2.32.0", "react-native-get-random-values": "^1.11.0", "react-native-harness": "^1.0.0-alpha.24", + "react-native-keychain": "^10.0.0", "react-native-mmkv": "^4.3.2", "react-native-nitro-fetch": "^1.5.1", "react-native-nitro-modules": "^0.36.1", diff --git a/apps/playground/src/app/screens/StoragePluginScreen.tsx b/apps/playground/src/app/screens/StoragePluginScreen.tsx index 298413ed8..9d1567493 100644 --- a/apps/playground/src/app/screens/StoragePluginScreen.tsx +++ b/apps/playground/src/app/screens/StoragePluginScreen.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Alert, FlatList, Text } from 'react-native'; +import * as Keychain from 'react-native-keychain'; import * as SecureStore from 'expo-secure-store'; import { MMKV } from 'react-native-mmkv'; import { @@ -23,7 +24,7 @@ import { } from '../storage-plugin-adapters'; import { useTheme } from '../theme/useTheme'; -type AdapterTab = 'mmkv' | 'async' | 'secure'; +type AdapterTab = 'mmkv' | 'async' | 'secure' | 'keychain'; type AsyncStorageMode = 'v2-default' | 'v3-auth' | 'v3-cache'; type EntryType = 'string' | 'number' | 'boolean' | 'buffer'; type AsyncStorageLike = { @@ -125,6 +126,22 @@ export const StoragePluginScreen = () => { return; } + if (tab === 'keychain') { + const services = await Keychain.getAllGenericPasswordServices(); + const credentials = await Promise.all( + services.map(async (service) => ({ + service, + credentials: await Keychain.getGenericPassword({ service }), + })), + ); + setEntries( + credentials.flatMap(({ service, credentials: item }) => + item ? [{ key: service, type: 'string' as const, value: item.password }] : [], + ), + ); + return; + } + const keys = getKnownSecureStoreKeys(); const values = await Promise.all( keys.map(async (entryKey) => ({ @@ -178,6 +195,8 @@ export const StoragePluginScreen = () => { } } else if (tab === 'async') { await selectedAsyncStorage.setItem(key, value); + } else if (tab === 'keychain') { + await Keychain.setGenericPassword('playground', value, { service: key }); } else { rememberSecureStoreKey(key); await SecureStore.setItemAsync(key, value); @@ -207,6 +226,8 @@ export const StoragePluginScreen = () => { } } else if (tab === 'async') { await selectedAsyncStorage.removeItem(key); + } else if (tab === 'keychain') { + await Keychain.resetGenericPassword({ service: key }); } else { forgetSecureStoreKey(key); await SecureStore.deleteItemAsync(key); @@ -224,7 +245,10 @@ export const StoragePluginScreen = () => { return ( - + { { value: 'mmkv', label: 'MMKV' }, { value: 'async', label: 'Async' }, { value: 'secure', label: 'Secure' }, + { value: 'keychain', label: 'Keychain' }, ]} /> @@ -297,7 +322,7 @@ export const StoragePluginScreen = () => { label="Value" helperText={ !supportsTypedValues - ? 'AsyncStorage and SecureStore are string-only in this demo.' + ? 'AsyncStorage, SecureStore, and Keychain are string-only in this demo.' : undefined } > diff --git a/apps/playground/src/app/storage-plugin-adapters.ts b/apps/playground/src/app/storage-plugin-adapters.ts index d2f59ec33..69a021f30 100644 --- a/apps/playground/src/app/storage-plugin-adapters.ts +++ b/apps/playground/src/app/storage-plugin-adapters.ts @@ -1,8 +1,10 @@ import AsyncStorage, { createAsyncStorage } from '@react-native-async-storage/async-storage'; +import * as Keychain from 'react-native-keychain'; import * as SecureStore from 'expo-secure-store'; import { createAsyncStorageAdapter, createExpoSecureStorageAdapter, + createKeychainStorageAdapter, createMMKVStorageAdapter, } from '@rozenite/storage-plugin'; import { mmkvStorages } from './mmkv-storages'; @@ -64,4 +66,7 @@ export const storagePluginAdapters = [ storageId: 'secure-default', storageName: 'Default SecureStore', }), + createKeychainStorageAdapter({ + keychain: Keychain, + }), ]; diff --git a/packages/storage-plugin/README.md b/packages/storage-plugin/README.md index b78bda55a..71df920ca 100644 --- a/packages/storage-plugin/README.md +++ b/packages/storage-plugin/README.md @@ -2,7 +2,7 @@ ### A Rozenite plugin for inspecting multiple storage backends in React Native DevTools. -The Storage Plugin provides a single inspector for sync and async storages, including MMKV, AsyncStorage and Expo SecureStore via adapters. +The Storage Plugin provides a single inspector for sync and async storages, including MMKV, AsyncStorage, Expo SecureStore and react-native-keychain via adapters. ## Installation @@ -13,7 +13,7 @@ npm install @rozenite/storage-plugin Optional peers depending on adapters you use: ```bash -npm install react-native-mmkv @react-native-async-storage/async-storage expo-secure-store +npm install react-native-mmkv @react-native-async-storage/async-storage expo-secure-store react-native-keychain ``` ## Usage @@ -23,6 +23,7 @@ import { createAsyncStorageAdapter, createMMKVStorageAdapter, createExpoSecureStorageAdapter, + createKeychainStorageAdapter, useRozeniteStoragePlugin, } from '@rozenite/storage-plugin'; @@ -41,6 +42,9 @@ const storages = [ storage: SecureStore, keys: ['token', 'session'], }), + createKeychainStorageAdapter({ + keychain: Keychain, + }), ]; useRozeniteStoragePlugin({ storages }); diff --git a/packages/storage-plugin/react-native.ts b/packages/storage-plugin/react-native.ts index 5a46ae521..87e754c52 100644 --- a/packages/storage-plugin/react-native.ts +++ b/packages/storage-plugin/react-native.ts @@ -4,7 +4,9 @@ export type { CreateAsyncStorageAdapterOptions, CreateExpoAsyncStorageAdapterOptions, CreateExpoSecureStorageAdapterOptions, + CreateKeychainStorageAdapterOptions, CreateMMKVStorageAdapterOptions, + KeychainLike, SecureStorageLike, } from './src/react-native/adapters'; @@ -22,6 +24,7 @@ export type { export let createAsyncStorageAdapter: typeof import('./src/react-native/adapters').createAsyncStorageAdapter; export let createExpoAsyncStorageAdapter: typeof import('./src/react-native/adapters').createExpoAsyncStorageAdapter; export let createExpoSecureStorageAdapter: typeof import('./src/react-native/adapters').createExpoSecureStorageAdapter; +export let createKeychainStorageAdapter: typeof import('./src/react-native/adapters').createKeychainStorageAdapter; export let createMMKVStorageAdapter: typeof import('./src/react-native/adapters').createMMKVStorageAdapter; export let useRozeniteStoragePlugin: typeof import('./src/react-native/useRozeniteStoragePlugin').useRozeniteStoragePlugin; @@ -60,6 +63,12 @@ if (!isDev || isServer) { 'expo-secure-store', 'Expo SecureStore', )) as typeof createExpoSecureStorageAdapter; + createKeychainStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => + createNoopStorageAdapter( + options, + 'react-native-keychain', + 'React Native Keychain', + )) as typeof createKeychainStorageAdapter; createMMKVStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter(options, 'mmkv', 'MMKV')) as typeof createMMKVStorageAdapter; useRozeniteStoragePlugin = () => null; @@ -70,6 +79,12 @@ if (!isDev || isServer) { createAsyncStorageAdapter = asyncStorageModule.createAsyncStorageAdapter; createExpoAsyncStorageAdapter = asyncStorageModule.createExpoAsyncStorageAdapter; createExpoSecureStorageAdapter = secureStorageModule.createExpoSecureStorageAdapter; + createKeychainStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => + createNoopStorageAdapter( + options, + 'react-native-keychain', + 'React Native Keychain', + )) as typeof createKeychainStorageAdapter; createMMKVStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter(options, 'mmkv', 'MMKV')) as typeof createMMKVStorageAdapter; useRozeniteStoragePlugin = @@ -80,6 +95,8 @@ if (!isDev || isServer) { require('./src/react-native/adapters').createExpoAsyncStorageAdapter; createExpoSecureStorageAdapter = require('./src/react-native/adapters').createExpoSecureStorageAdapter; + createKeychainStorageAdapter = + require('./src/react-native/adapters').createKeychainStorageAdapter; createMMKVStorageAdapter = require('./src/react-native/adapters').createMMKVStorageAdapter; useRozeniteStoragePlugin = require('./src/react-native/useRozeniteStoragePlugin').useRozeniteStoragePlugin; diff --git a/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts new file mode 100644 index 000000000..b93df6a53 --- /dev/null +++ b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createKeychainStorageAdapter } from '../adapters/keychain'; +import type { KeychainLike } from '../adapters/keychain'; + +const createFakeKeychain = ( + initial: Record = {}, +) => { + const items = new Map(Object.entries(initial)); + + const keychain = { + getAllGenericPasswordServices: vi.fn(async () => [...items.keys()]), + getGenericPassword: vi.fn( + async ({ service }: { service: string }) => items.get(service) ?? false, + ), + setGenericPassword: vi.fn( + async (username: string, password: string, options?: { service?: string }) => { + items.set(options?.service ?? 'default', { username, password }); + }, + ), + resetGenericPassword: vi.fn(async ({ service }: { service: string }) => { + items.delete(service); + }), + } satisfies KeychainLike; + + return { keychain, items }; +}; + +const getStorage = (adapter: ReturnType) => { + const [node] = adapter.storages; + if (node.storage.kind !== 'async') { + throw new Error('Expected an async storage.'); + } + + return node.storage; +}; + +describe('createKeychainStorageAdapter', () => { + it('exposes one string-only storage with sensible defaults', () => { + const { keychain } = createFakeKeychain(); + const adapter = createKeychainStorageAdapter({ keychain }); + + expect(adapter.id).toBe('react-native-keychain'); + expect(adapter.name).toBe('React Native Keychain'); + expect(adapter.storages).toHaveLength(1); + expect(adapter.storages[0].capabilities.supportedTypes).toEqual(['string']); + }); + + it('exposes no storages when react-native-keychain is missing', () => { + expect(createKeychainStorageAdapter({ keychain: undefined }).storages).toEqual([]); + expect(createKeychainStorageAdapter({ keychain: null }).storages).toEqual([]); + }); + + it('lists every keychain service by default', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'a', password: '1' }, + session: { username: 'b', password: '2' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.getAllKeys()).toEqual(['token', 'session']); + }); + + it('uses the services option, either as a list or as a function', async () => { + const { keychain } = createFakeKeychain(); + + const fromList = getStorage(createKeychainStorageAdapter({ keychain, services: ['a'] })); + const fromFunction = getStorage( + createKeychainStorageAdapter({ keychain, services: async () => ['b'] }), + ); + + expect(await fromList.getAllKeys()).toEqual(['a']); + expect(await fromFunction.getAllKeys()).toEqual(['b']); + expect(keychain.getAllGenericPasswordServices).not.toHaveBeenCalled(); + }); + + it('reads the password as the entry value and returns undefined for missing services', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'secret' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.get('token')).toEqual({ + key: 'token', + type: 'string', + value: 'secret', + }); + expect(await storage.get('missing')).toBeUndefined(); + }); + + it('keeps the existing username when updating an entry', async () => { + const { keychain, items } = createFakeKeychain({ + token: { username: 'alice', password: 'old' }, + }); + const storage = getStorage( + createKeychainStorageAdapter({ + keychain, + setOptions: { accessible: 'AccessibleWhenUnlocked' }, + }), + ); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(items.get('token')).toEqual({ username: 'alice', password: 'new' }); + expect(keychain.setGenericPassword).toHaveBeenCalledWith('alice', 'new', { + accessible: 'AccessibleWhenUnlocked', + service: 'token', + }); + }); + + it('uses the default username for new entries', async () => { + const { keychain, items } = createFakeKeychain(); + const storage = getStorage( + createKeychainStorageAdapter({ keychain, defaultUsername: 'rozenite' }), + ); + + await storage.set({ key: 'fresh', type: 'string', value: 'value' }); + + expect(items.get('fresh')).toEqual({ + username: 'rozenite', + password: 'value', + }); + }); + + it('rejects non-string values', async () => { + const { keychain } = createFakeKeychain(); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await expect(storage.set({ key: 'n', type: 'number', value: 1 })).rejects.toThrow( + 'supports only string values', + ); + }); + + it('deletes a single service and clears all of them', async () => { + const { keychain, items } = createFakeKeychain({ + a: { username: 'u', password: '1' }, + b: { username: 'u', password: '2' }, + c: { username: 'u', password: '3' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await storage.delete('a'); + expect([...items.keys()]).toEqual(['b', 'c']); + + await storage.clear(); + expect(items.size).toBe(0); + }); + + it('rejects every mutation when read-only', async () => { + const { keychain, items } = createFakeKeychain({ + a: { username: 'u', password: '1' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain, readOnly: true })); + + await expect(storage.set({ key: 'a', type: 'string', value: 'x' })).rejects.toThrow( + 'read-only', + ); + await expect(storage.delete('a')).rejects.toThrow('read-only'); + await expect(storage.clear()).rejects.toThrow('read-only'); + expect(items.get('a')).toEqual({ username: 'u', password: '1' }); + }); +}); diff --git a/packages/storage-plugin/src/react-native/adapters/index.ts b/packages/storage-plugin/src/react-native/adapters/index.ts index 9fe05900a..68789f13d 100644 --- a/packages/storage-plugin/src/react-native/adapters/index.ts +++ b/packages/storage-plugin/src/react-native/adapters/index.ts @@ -9,5 +9,8 @@ export type { CreateExpoAsyncStorageAdapterOptions, } from './async-storage'; +export { createKeychainStorageAdapter } from './keychain'; +export type { CreateKeychainStorageAdapterOptions, KeychainLike } from './keychain'; + export { createExpoSecureStorageAdapter } from './secure-storage'; export type { CreateExpoSecureStorageAdapterOptions, SecureStorageLike } from './secure-storage'; diff --git a/packages/storage-plugin/src/react-native/adapters/keychain.ts b/packages/storage-plugin/src/react-native/adapters/keychain.ts new file mode 100644 index 000000000..fa7519fd1 --- /dev/null +++ b/packages/storage-plugin/src/react-native/adapters/keychain.ts @@ -0,0 +1,123 @@ +import type { StorageAdapter, StorageNode } from '../../shared/types'; + +export type KeychainLike = { + getAllGenericPasswordServices(): Promise; + getGenericPassword(options: { + service: string; + }): Promise; + setGenericPassword( + username: string, + password: string, + options?: { service?: string; [option: string]: unknown }, + ): Promise; + resetGenericPassword(options: { service: string }): Promise; +}; + +type ServiceSource = readonly string[] | (() => Promise); + +export type CreateKeychainStorageAdapterOptions = { + /** The `react-native-keychain` module. When it is missing the adapter exposes no storages. */ + keychain: KeychainLike | null | undefined; + /** Services to show. Defaults to every service reported by `getAllGenericPasswordServices`. */ + services?: ServiceSource; + /** Username stored with entries created from the panel. Existing entries keep their username. */ + defaultUsername?: string; + /** Extra options forwarded to `setGenericPassword`, such as `accessible` or `accessControl`. */ + setOptions?: Record; + /** Reject set, delete and clear from the panel. */ + readOnly?: boolean; + adapterId?: string; + adapterName?: string; + storageId?: string; + storageName?: string; + blacklist?: RegExp; +}; + +export const createKeychainStorageAdapter = ({ + keychain, + services, + defaultUsername = 'user', + setOptions, + readOnly = false, + adapterId = 'react-native-keychain', + adapterName = 'React Native Keychain', + storageId = 'default', + storageName = 'Keychain / Keystore', + blacklist, +}: CreateKeychainStorageAdapterOptions): StorageAdapter => { + if (!keychain) { + return { id: adapterId, name: adapterName, storages: [] }; + } + + const resolveServices = async (): Promise => { + if (typeof services === 'function') { + return services(); + } + + return services ?? keychain.getAllGenericPasswordServices(); + }; + + const assertWritable = () => { + if (readOnly) { + throw new Error('Keychain adapter is read-only.'); + } + }; + + const remove = async (service: string) => { + assertWritable(); + await keychain.resetGenericPassword({ service }); + }; + + const storageNode: StorageNode = { + id: storageId, + name: storageName, + blacklist, + capabilities: { + supportedTypes: ['string'], + }, + storage: { + kind: 'async', + getAllKeys: async () => [...(await resolveServices())], + get: async (key) => { + const credentials = await keychain.getGenericPassword({ service: key }); + if (!credentials) { + return undefined; + } + + return { + key, + type: 'string', + value: credentials.password, + }; + }, + set: async (entry) => { + assertWritable(); + if (entry.type !== 'string') { + throw new Error('Keychain adapter supports only string values.'); + } + + // setGenericPassword replaces the whole entry, so keep the existing username. + const existing = await keychain.getGenericPassword({ + service: entry.key, + }); + const username = existing ? existing.username : defaultUsername; + await keychain.setGenericPassword(username, entry.value, { + ...setOptions, + service: entry.key, + }); + }, + delete: remove, + clear: async () => { + assertWritable(); + const all = await resolveServices(); + await Promise.all(all.map(remove)); + }, + }, + }; + + return { + id: adapterId, + name: adapterName, + storages: [storageNode], + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f30e50492..e03803de8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -271,6 +271,9 @@ importers: react-native-harness: specifier: ^1.0.0-alpha.24 version: 1.0.0-alpha.25(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(immer@10.1.1)(metro-config@0.84.4(supports-color@8.1.1))(metro@0.84.4(supports-color@8.1.1))(react-native@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3)(supports-color@8.1.1) + react-native-keychain: + specifier: ^10.0.0 + version: 10.0.0 react-native-mmkv: specifier: ^4.3.2 version: 4.3.2(react-native-nitro-modules@0.36.5(react-native@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3) @@ -12919,6 +12922,10 @@ packages: react: '*' react-native: '*' + react-native-keychain@10.0.0: + resolution: {integrity: sha512-YzPKSAnSzGEJ12IK6CctNLU79T1W15WDrElRQ+1/FsOazGX9ucFPTQwgYe8Dy8jiSEDJKM4wkVa3g4lD2Z+Pnw==} + engines: {node: '>=16'} + react-native-mmkv@3.3.0: resolution: {integrity: sha512-2iPjIJ+IAODXN35wm53EN6nv+hR/0NXLLiLWOdPA/0gXDB2dYVrr6MqvoiFpxhLhdj0B8fkf5ARNVavJaSvuuQ==} peerDependencies: @@ -29796,6 +29803,8 @@ snapshots: react: 19.2.3 react-native: 0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1) + react-native-keychain@10.0.0: {} + react-native-mmkv@3.3.0(react-native@0.86.0(@babel/core@7.29.0(supports-color@8.1.1))(@react-native/metro-config@0.86.2(@babel/core@7.29.0(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.3)(supports-color@8.1.1))(react@19.2.3): dependencies: react: 19.2.3 diff --git a/website/src/content/docs/docs/official-plugins/storage.mdx b/website/src/content/docs/docs/official-plugins/storage.mdx index 6e151e90b..2fc825b7a 100644 --- a/website/src/content/docs/docs/official-plugins/storage.mdx +++ b/website/src/content/docs/docs/official-plugins/storage.mdx @@ -4,7 +4,7 @@ title: 'Storage Plugin' import PackageManagerTabs from '@/components/PackageManagerTabs.astro'; -The Storage plugin is a single DevTools panel for MMKV, AsyncStorage, and Expo SecureStore. Register whichever adapters your app uses and inspect them all from one place. +The Storage plugin is a single DevTools panel for MMKV, AsyncStorage, Expo SecureStore, and react-native-keychain. Register whichever adapters your app uses and inspect them all from one place. ## Installation @@ -14,7 +14,7 @@ Make sure to go through the [Getting Started guide](/docs/getting-started) befor Install the peer dependencies for the storages you use: - + ## Setup @@ -22,6 +22,7 @@ Install the peer dependencies for the storages you use: import { createAsyncStorageAdapter, createExpoSecureStorageAdapter, + createKeychainStorageAdapter, createMMKVStorageAdapter, useRozeniteStoragePlugin, } from '@rozenite/storage-plugin'; @@ -37,6 +38,9 @@ const storages = [ storage: SecureStore, keys: ['token', 'session'], }), + createKeychainStorageAdapter({ + keychain: Keychain, + }), ]; function App() { @@ -89,9 +93,25 @@ createExpoSecureStorageAdapter({ SecureStore doesn't support key enumeration, so you provide known keys via `keys`. +### react-native-keychain + +```ts title="App.tsx" +import * as Keychain from 'react-native-keychain'; + +createKeychainStorageAdapter({ + keychain: Keychain, + storageName: 'Credentials', + readOnly: true, +}); +``` + +Each entry is one Keychain (iOS) or Keystore (Android) service, and its value is the stored password. By default every service reported by `getAllGenericPasswordServices` is listed; pass `services` (a list or an async function) to limit it. Editing an entry keeps its existing username, new entries use `defaultUsername`, and `setOptions` is forwarded to `setGenericPassword` (for example `accessible`). Set `readOnly` to reject changes from the panel. + +If `keychain` is `undefined`, for example because you load the library optionally, the adapter exposes no storages instead of throwing. + ## Binary values -MMKV storages that hold binary values render and edit them through a hex viewer with a Base64 mode for copy/paste. AsyncStorage and Expo SecureStore don't support binary values. +MMKV storages that hold binary values render and edit them through a hex viewer with a Base64 mode for copy/paste. AsyncStorage, Expo SecureStore, and react-native-keychain don't support binary values. ## Hiding sensitive keys From 7bb4f72082956feca750d31d6c18990398c38d4d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 20 Oct 2026 09:41:42 +0200 Subject: [PATCH 2/4] fix(storage-plugin): harden the keychain adapter and add read-only storages - Skip unreadable entries instead of failing the whole listing. - List services with skipUIAuth (default true) so the panel does not trigger authentication prompts on iOS. - Keep the Android storage type when editing, and accept setOptions as a per-service function. - Clear only services not hidden by the blacklist, and report the ones that failed instead of stopping partway. - Add capabilities.readOnly: the panel disables mutations and the storage view rejects them; agents see it in list-storages. - Declare react-native-keychain as an optional peer like the other storage libraries, share the no-op adapter factory, and install the storage libraries as regular dependencies in the docs. --- .changeset/storage-plugin-keychain-adapter.md | 2 + packages/storage-plugin/package.json | 4 + packages/storage-plugin/react-native.ts | 21 ++- .../__tests__/keychain-adapter.test.ts | 120 ++++++++++++++++-- .../__tests__/storage-view.test.ts | 34 +++++ .../__tests__/use-storage-agent-tools.test.ts | 2 + .../src/react-native/adapters/keychain.ts | 76 ++++++++--- .../src/react-native/storage-view.ts | 12 ++ .../src/react-native/useStorageAgentTools.ts | 8 +- .../storage-plugin/src/shared/agent-tools.ts | 12 +- packages/storage-plugin/src/shared/types.ts | 4 + .../src/ui/__tests__/panel.test.tsx | 19 ++- packages/storage-plugin/src/ui/panel.tsx | 62 ++++----- .../docs/docs/official-plugins/storage.mdx | 20 ++- 14 files changed, 313 insertions(+), 83 deletions(-) diff --git a/.changeset/storage-plugin-keychain-adapter.md b/.changeset/storage-plugin-keychain-adapter.md index 8f2c5f428..e77b3d0f0 100644 --- a/.changeset/storage-plugin-keychain-adapter.md +++ b/.changeset/storage-plugin-keychain-adapter.md @@ -3,3 +3,5 @@ --- Add `createKeychainStorageAdapter` to inspect and edit react-native-keychain (iOS Keychain and Android Keystore) entries in the Storage panel. + +Storages can now be marked read-only with `capabilities.readOnly`. The panel disables adding, editing, deleting, purging and importing for them, and the device rejects those changes. diff --git a/packages/storage-plugin/package.json b/packages/storage-plugin/package.json index f18eb969e..ac8aac5b0 100644 --- a/packages/storage-plugin/package.json +++ b/packages/storage-plugin/package.json @@ -76,6 +76,7 @@ "expo-secure-store": "*", "react": "*", "react-native": "*", + "react-native-keychain": "*", "react-native-mmkv": "*" }, "peerDependenciesMeta": { @@ -85,6 +86,9 @@ "expo-secure-store": { "optional": true }, + "react-native-keychain": { + "optional": true + }, "react-native-mmkv": { "optional": true } diff --git a/packages/storage-plugin/react-native.ts b/packages/storage-plugin/react-native.ts index 87e754c52..8a23b1a5f 100644 --- a/packages/storage-plugin/react-native.ts +++ b/packages/storage-plugin/react-native.ts @@ -49,6 +49,13 @@ const createNoopStorageAdapter = ( storages: [], }); +const createNoopKeychainStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => + createNoopStorageAdapter( + options, + 'react-native-keychain', + 'React Native Keychain', + )) as typeof createKeychainStorageAdapter; + if (!isDev || isServer) { createAsyncStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter( @@ -63,12 +70,7 @@ if (!isDev || isServer) { 'expo-secure-store', 'Expo SecureStore', )) as typeof createExpoSecureStorageAdapter; - createKeychainStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => - createNoopStorageAdapter( - options, - 'react-native-keychain', - 'React Native Keychain', - )) as typeof createKeychainStorageAdapter; + createKeychainStorageAdapter = createNoopKeychainStorageAdapter; createMMKVStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter(options, 'mmkv', 'MMKV')) as typeof createMMKVStorageAdapter; useRozeniteStoragePlugin = () => null; @@ -79,12 +81,7 @@ if (!isDev || isServer) { createAsyncStorageAdapter = asyncStorageModule.createAsyncStorageAdapter; createExpoAsyncStorageAdapter = asyncStorageModule.createExpoAsyncStorageAdapter; createExpoSecureStorageAdapter = secureStorageModule.createExpoSecureStorageAdapter; - createKeychainStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => - createNoopStorageAdapter( - options, - 'react-native-keychain', - 'React Native Keychain', - )) as typeof createKeychainStorageAdapter; + createKeychainStorageAdapter = createNoopKeychainStorageAdapter; createMMKVStorageAdapter = ((options: { adapterId?: string; adapterName?: string }) => createNoopStorageAdapter(options, 'mmkv', 'MMKV')) as typeof createMMKVStorageAdapter; useRozeniteStoragePlugin = diff --git a/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts index b93df6a53..5e77feafa 100644 --- a/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts @@ -2,18 +2,24 @@ import { describe, expect, it, vi } from 'vitest'; import { createKeychainStorageAdapter } from '../adapters/keychain'; import type { KeychainLike } from '../adapters/keychain'; -const createFakeKeychain = ( - initial: Record = {}, -) => { +type FakeItem = { username: string; password: string; storage?: string }; + +const createFakeKeychain = (initial: Record = {}) => { const items = new Map(Object.entries(initial)); const keychain = { - getAllGenericPasswordServices: vi.fn(async () => [...items.keys()]), + getAllGenericPasswordServices: vi.fn( + async () => [...items.keys()], + ), getGenericPassword: vi.fn( async ({ service }: { service: string }) => items.get(service) ?? false, ), setGenericPassword: vi.fn( - async (username: string, password: string, options?: { service?: string }) => { + async ( + username: string, + password: string, + options?: { service?: string; [option: string]: unknown }, + ) => { items.set(options?.service ?? 'default', { username, password }); }, ), @@ -42,7 +48,10 @@ describe('createKeychainStorageAdapter', () => { expect(adapter.id).toBe('react-native-keychain'); expect(adapter.name).toBe('React Native Keychain'); expect(adapter.storages).toHaveLength(1); - expect(adapter.storages[0].capabilities.supportedTypes).toEqual(['string']); + expect(adapter.storages[0].capabilities).toEqual({ + supportedTypes: ['string'], + readOnly: false, + }); }); it('exposes no storages when react-native-keychain is missing', () => { @@ -60,6 +69,20 @@ describe('createKeychainStorageAdapter', () => { expect(await storage.getAllKeys()).toEqual(['token', 'session']); }); + it('skips services that need authentication when listing, unless disabled', async () => { + const { keychain } = createFakeKeychain(); + + await getStorage(createKeychainStorageAdapter({ keychain })).getAllKeys(); + await getStorage(createKeychainStorageAdapter({ keychain, skipUIAuth: false })).getAllKeys(); + + expect(keychain.getAllGenericPasswordServices).toHaveBeenNthCalledWith(1, { + skipUIAuth: true, + }); + expect(keychain.getAllGenericPasswordServices).toHaveBeenNthCalledWith(2, { + skipUIAuth: false, + }); + }); + it('uses the services option, either as a list or as a function', async () => { const { keychain } = createFakeKeychain(); @@ -87,6 +110,16 @@ describe('createKeychainStorageAdapter', () => { expect(await storage.get('missing')).toBeUndefined(); }); + it('returns undefined for entries that cannot be read', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'secret' }, + }); + keychain.getGenericPassword.mockRejectedValueOnce(new Error('User canceled the operation.')); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.get('token')).toBeUndefined(); + }); + it('keeps the existing username when updating an entry', async () => { const { keychain, items } = createFakeKeychain({ token: { username: 'alice', password: 'old' }, @@ -107,6 +140,42 @@ describe('createKeychainStorageAdapter', () => { }); }); + it('keeps the Android storage type of an existing entry', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'old', storage: 'KeystoreAESGCM' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(keychain.setGenericPassword).toHaveBeenCalledWith('alice', 'new', { + storage: 'KeystoreAESGCM', + service: 'token', + }); + }); + + it('resolves setOptions per service when given a function', async () => { + const { keychain } = createFakeKeychain(); + const storage = getStorage( + createKeychainStorageAdapter({ + keychain, + setOptions: (service) => + service === 'token' ? { accessControl: 'BiometryCurrentSet' } : undefined, + }), + ); + + await storage.set({ key: 'token', type: 'string', value: 'a' }); + await storage.set({ key: 'other', type: 'string', value: 'b' }); + + expect(keychain.setGenericPassword).toHaveBeenNthCalledWith(1, 'user', 'a', { + accessControl: 'BiometryCurrentSet', + service: 'token', + }); + expect(keychain.setGenericPassword).toHaveBeenNthCalledWith(2, 'user', 'b', { + service: 'other', + }); + }); + it('uses the default username for new entries', async () => { const { keychain, items } = createFakeKeychain(); const storage = getStorage( @@ -145,17 +214,40 @@ describe('createKeychainStorageAdapter', () => { expect(items.size).toBe(0); }); - it('rejects every mutation when read-only', async () => { + it('does not clear services hidden by the blacklist', async () => { + const { keychain, items } = createFakeKeychain({ + token: { username: 'u', password: '1' }, + 'auth-refresh': { username: 'u', password: '2' }, + }); + const storage = getStorage(createKeychainStorageAdapter({ keychain, blacklist: /auth/ })); + + await storage.clear(); + + expect([...items.keys()]).toEqual(['auth-refresh']); + }); + + it('clears every service it can and reports the ones that failed', async () => { const { keychain, items } = createFakeKeychain({ a: { username: 'u', password: '1' }, + b: { username: 'u', password: '2' }, + c: { username: 'u', password: '3' }, + }); + keychain.resetGenericPassword.mockImplementation(async ({ service }) => { + if (service === 'b') { + throw new Error('Keystore failure'); + } + items.delete(service); }); - const storage = getStorage(createKeychainStorageAdapter({ keychain, readOnly: true })); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); - await expect(storage.set({ key: 'a', type: 'string', value: 'x' })).rejects.toThrow( - 'read-only', - ); - await expect(storage.delete('a')).rejects.toThrow('read-only'); - await expect(storage.clear()).rejects.toThrow('read-only'); - expect(items.get('a')).toEqual({ username: 'u', password: '1' }); + await expect(storage.clear()).rejects.toThrow('Could not clear Keychain services: b.'); + expect([...items.keys()]).toEqual(['b']); + }); + + it('marks the storage read-only', () => { + const { keychain } = createFakeKeychain(); + const adapter = createKeychainStorageAdapter({ keychain, readOnly: true }); + + expect(adapter.storages[0].capabilities.readOnly).toBe(true); }); }); diff --git a/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts b/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts index e749d9901..524f4d714 100644 --- a/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/storage-view.test.ts @@ -170,3 +170,37 @@ describe('StorageView subscriptions', () => { expect(onChange).not.toHaveBeenCalled(); }); }); + +describe('StorageView read-only storages', () => { + it('rejects set, delete and purge without touching the storage', async () => { + const storage = { + kind: 'sync' as const, + getAllKeys: vi.fn(() => ['key']), + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + clear: vi.fn(), + }; + const [view] = createStorageViews([ + { + id: 'manual', + name: 'Manual', + storages: [ + { + id: 'storage', + name: 'Storage', + capabilities: { supportedTypes: ['string'], readOnly: true }, + storage, + }, + ], + }, + ]); + + await expect(view.set({ key: 'key', type: 'string', value: 'x' })).rejects.toThrow('read-only'); + await expect(view.delete('key')).rejects.toThrow('read-only'); + await expect(view.purge()).rejects.toThrow('read-only'); + expect(storage.set).not.toHaveBeenCalled(); + expect(storage.delete).not.toHaveBeenCalled(); + expect(storage.clear).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts b/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts index 08fb73ce4..10d7e119d 100644 --- a/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts @@ -58,6 +58,7 @@ describe('storage Agent tool handlers', () => { adapterName: 'Adapter one', storageName: 'Storage first', supportedTypes: ['string', 'buffer'], + readOnly: false, }, { adapterId: 'two', @@ -65,6 +66,7 @@ describe('storage Agent tool handlers', () => { adapterName: 'Adapter two', storageName: 'Storage second', supportedTypes: ['string', 'buffer'], + readOnly: false, }, ], }); diff --git a/packages/storage-plugin/src/react-native/adapters/keychain.ts b/packages/storage-plugin/src/react-native/adapters/keychain.ts index fa7519fd1..f04752769 100644 --- a/packages/storage-plugin/src/react-native/adapters/keychain.ts +++ b/packages/storage-plugin/src/react-native/adapters/keychain.ts @@ -1,10 +1,10 @@ import type { StorageAdapter, StorageNode } from '../../shared/types'; export type KeychainLike = { - getAllGenericPasswordServices(): Promise; + getAllGenericPasswordServices(options?: { skipUIAuth?: boolean }): Promise; getGenericPassword(options: { service: string; - }): Promise; + }): Promise; setGenericPassword( username: string, password: string, @@ -15,16 +15,30 @@ export type KeychainLike = { type ServiceSource = readonly string[] | (() => Promise); +type SetOptionsSource = + | Record + | ((service: string) => Record | undefined); + export type CreateKeychainStorageAdapterOptions = { /** The `react-native-keychain` module. When it is missing the adapter exposes no storages. */ keychain: KeychainLike | null | undefined; /** Services to show. Defaults to every service reported by `getAllGenericPasswordServices`. */ services?: ServiceSource; + /** + * Skip services that need user authentication when listing them, so the panel does not + * trigger biometric or passcode prompts (iOS). Ignored when `services` is set. + * @default true + */ + skipUIAuth?: boolean; /** Username stored with entries created from the panel. Existing entries keep their username. */ defaultUsername?: string; - /** Extra options forwarded to `setGenericPassword`, such as `accessible` or `accessControl`. */ - setOptions?: Record; - /** Reject set, delete and clear from the panel. */ + /** + * Options forwarded to `setGenericPassword`, such as `accessible` or `accessControl`. + * The keychain does not report these for stored entries, so pass a function to return + * the options each service was written with; otherwise edits re-save with these options. + */ + setOptions?: SetOptionsSource; + /** Disable add, edit, delete, purge and import in the panel and reject them on the device. */ readOnly?: boolean; adapterId?: string; adapterName?: string; @@ -36,6 +50,7 @@ export type CreateKeychainStorageAdapterOptions = { export const createKeychainStorageAdapter = ({ keychain, services, + skipUIAuth = true, defaultUsername = 'user', setOptions, readOnly = false, @@ -54,32 +69,42 @@ export const createKeychainStorageAdapter = ({ return services(); } - return services ?? keychain.getAllGenericPasswordServices(); + return services ?? keychain.getAllGenericPasswordServices({ skipUIAuth }); }; - const assertWritable = () => { - if (readOnly) { - throw new Error('Keychain adapter is read-only.'); + const isBlacklisted = (service: string) => { + if (!blacklist) { + return false; } - }; - const remove = async (service: string) => { - assertWritable(); - await keychain.resetGenericPassword({ service }); + blacklist.lastIndex = 0; + return blacklist.test(service); }; + const resolveSetOptions = (service: string) => + typeof setOptions === 'function' ? setOptions(service) : setOptions; + const storageNode: StorageNode = { id: storageId, name: storageName, blacklist, capabilities: { supportedTypes: ['string'], + readOnly, }, storage: { kind: 'async', getAllKeys: async () => [...(await resolveServices())], get: async (key) => { - const credentials = await keychain.getGenericPassword({ service: key }); + let credentials: Awaited>; + try { + credentials = await keychain.getGenericPassword({ service: key }); + } catch { + // Entries behind biometrics, or with an invalidated Keystore key, cannot be read. + // Skip them instead of failing the whole listing. + return undefined; + } + if (!credentials) { return undefined; } @@ -91,26 +116,35 @@ export const createKeychainStorageAdapter = ({ }; }, set: async (entry) => { - assertWritable(); if (entry.type !== 'string') { throw new Error('Keychain adapter supports only string values.'); } - // setGenericPassword replaces the whole entry, so keep the existing username. + // setGenericPassword replaces the whole entry, so keep the existing username and, + // on Android, the storage type it was encrypted with. const existing = await keychain.getGenericPassword({ service: entry.key, }); const username = existing ? existing.username : defaultUsername; await keychain.setGenericPassword(username, entry.value, { - ...setOptions, + ...resolveSetOptions(entry.key), + ...(existing && existing.storage ? { storage: existing.storage } : {}), service: entry.key, }); }, - delete: remove, + delete: async (key) => { + await keychain.resetGenericPassword({ service: key }); + }, clear: async () => { - assertWritable(); - const all = await resolveServices(); - await Promise.all(all.map(remove)); + // Only clear what the panel shows: hidden services stay untouched. + const visible = (await resolveServices()).filter((service) => !isBlacklisted(service)); + const results = await Promise.allSettled( + visible.map((service) => keychain.resetGenericPassword({ service })), + ); + const failed = visible.filter((_, index) => results[index].status === 'rejected'); + if (failed.length > 0) { + throw new Error(`Could not clear Keychain services: ${failed.join(', ')}.`); + } }, }, }; diff --git a/packages/storage-plugin/src/react-native/storage-view.ts b/packages/storage-plugin/src/react-native/storage-view.ts index 8f15c5fac..4dd72904e 100644 --- a/packages/storage-plugin/src/react-native/storage-view.ts +++ b/packages/storage-plugin/src/react-native/storage-view.ts @@ -1,5 +1,6 @@ import { getStorageViewId, + isReadOnly, supportsType, type AsyncStorage, type StorageAdapter, @@ -44,6 +45,14 @@ const checkTypeSupport = ( ); }; +const checkWritable = (capabilities: StorageCapabilities, target: StorageTarget) => { + if (!isReadOnly(capabilities)) { + return; + } + + throw new Error(`Storage "${target.storageId}" in adapter "${target.adapterId}" is read-only.`); +}; + const getAllKeys = async (storage: AsyncStorageLike) => { if (isAsyncStorage(storage)) { return storage.getAllKeys(); @@ -139,6 +148,7 @@ export const createStorageView = ( blacklist: storageNode.blacklist, get, set: async (entry) => { + checkWritable(storageNode.capabilities, target); checkTypeSupport(storageNode.capabilities, entry.type, target); locallyMutatingKeys.add(entry.key); try { @@ -148,6 +158,7 @@ export const createStorageView = ( } }, delete: async (key) => { + checkWritable(storageNode.capabilities, target); locallyMutatingKeys.add(key); try { await deleteEntry(storage, key); @@ -156,6 +167,7 @@ export const createStorageView = ( } }, purge: async () => { + checkWritable(storageNode.capabilities, target); isLocallyPurging = true; try { await clearStorage(storage); diff --git a/packages/storage-plugin/src/react-native/useStorageAgentTools.ts b/packages/storage-plugin/src/react-native/useStorageAgentTools.ts index e79b607b6..7d69f6e02 100644 --- a/packages/storage-plugin/src/react-native/useStorageAgentTools.ts +++ b/packages/storage-plugin/src/react-native/useStorageAgentTools.ts @@ -5,7 +5,12 @@ import { storageToolDefinitions, type StorageListEntriesArgs, } from '../shared/agent-tools'; -import type { StorageEntry, StorageEntryType, StorageEntryValue } from '../shared/types'; +import { + isReadOnly, + type StorageEntry, + type StorageEntryType, + type StorageEntryValue, +} from '../shared/types'; import type { StorageView } from './storage-view'; import { compareKeys } from './entry-preview-pagination'; @@ -129,6 +134,7 @@ export const createStorageAgentHandlers = (views: readonly StorageView[]) => ({ adapterName: view.adapterName, storageName: view.storageName, supportedTypes: view.capabilities.supportedTypes, + readOnly: isReadOnly(view.capabilities), })), }), diff --git a/packages/storage-plugin/src/shared/agent-tools.ts b/packages/storage-plugin/src/shared/agent-tools.ts index 881e15a4f..26d61d9da 100644 --- a/packages/storage-plugin/src/shared/agent-tools.ts +++ b/packages/storage-plugin/src/shared/agent-tools.ts @@ -39,6 +39,7 @@ export type StorageListStoragesItem = { adapterName: string; storageName: string; supportedTypes: StorageEntryType[]; + readOnly: boolean; }; export type StorageListStoragesResult = { @@ -109,11 +110,18 @@ export const storageToolDefinitions = { >({ name: 'list-storages', description: - 'List all storage adapters and their storage nodes currently available on the device, including supported entry types. Entry counts are omitted to avoid enumerating every storage.', + 'List all storage adapters and their storage nodes currently available on the device, including supported entry types and whether the storage is read-only. Entry counts are omitted to avoid enumerating every storage.', inputSchema: { type: 'object', properties: {} }, rows: { key: 'storages', - fields: ['adapterId', 'storageId', 'adapterName', 'storageName', 'supportedTypes'], + fields: [ + 'adapterId', + 'storageId', + 'adapterName', + 'storageName', + 'supportedTypes', + 'readOnly', + ], }, }), listEntries: definePaginatedAgentToolContract({ diff --git a/packages/storage-plugin/src/shared/types.ts b/packages/storage-plugin/src/shared/types.ts index f230e089e..720973deb 100644 --- a/packages/storage-plugin/src/shared/types.ts +++ b/packages/storage-plugin/src/shared/types.ts @@ -17,6 +17,8 @@ export type StorageEntryPreview = { export type StorageCapabilities = { supportedTypes: StorageEntryType[]; + /** The panel disables add, edit, delete, purge and import, and the device rejects them. */ + readOnly?: boolean; }; export type StorageDescriptor = { @@ -82,3 +84,5 @@ export const getStorageViewId = ({ adapterId, storageId }: StorageTarget) => export const supportsType = (capabilities: StorageCapabilities, type: StorageEntryType) => capabilities.supportedTypes.includes(type); + +export const isReadOnly = (capabilities: StorageCapabilities) => capabilities.readOnly === true; diff --git a/packages/storage-plugin/src/ui/__tests__/panel.test.tsx b/packages/storage-plugin/src/ui/__tests__/panel.test.tsx index 77c4dc2b4..e1146a7ea 100644 --- a/packages/storage-plugin/src/ui/__tests__/panel.test.tsx +++ b/packages/storage-plugin/src/ui/__tests__/panel.test.tsx @@ -202,13 +202,13 @@ const renderPanel = async () => { }; const discoveryRequests = () => mocks.client.send.mock.calls.filter(([type]) => type === 'discover-storages'); -const discover = async () => { +const discover = async (storages: StorageDescriptor[] = descriptors) => { const request = discoveryRequests().at(-1)?.[1]; await act(async () => mocks.emit('storage-descriptors', { type: 'storage-descriptors', requestId: request.requestId, - storages: descriptors, + storages, }), ); }; @@ -393,4 +393,19 @@ describe('StoragePanel preview query cutover', () => { expect(container.textContent).toContain('First'); await act(async () => root.unmount()); }); + + it('disables add, purge and import for read-only storages', async () => { + const { root, container } = await renderPanel(); + await discover([ + { ...descriptors[0], capabilities: { supportedTypes: ['string'], readOnly: true } }, + ]); + const button = (label: string) => + container.querySelector(`button[aria-label="${label}"]`); + + expect(button('Add entry')?.disabled).toBe(true); + expect(button('Purge storage')?.disabled).toBe(true); + expect(button('Import storage')?.disabled).toBe(true); + expect(button('Export storage')?.disabled).toBe(false); + await act(async () => root.unmount()); + }); }); diff --git a/packages/storage-plugin/src/ui/panel.tsx b/packages/storage-plugin/src/ui/panel.tsx index c465d7eaf..2be300a6c 100644 --- a/packages/storage-plugin/src/ui/panel.tsx +++ b/packages/storage-plugin/src/ui/panel.tsx @@ -30,7 +30,7 @@ import type { StorageEntryValue, StorageTarget, } from '../shared/types'; -import { getStorageViewId } from '../shared/types'; +import { getStorageViewId, isReadOnly } from '../shared/types'; import { AddEntryDialog } from './add-entry-dialog'; import { EditEntryDialog } from './edit-entry-dialog'; import { EntryDetailDialog } from './entry-detail-dialog'; @@ -116,6 +116,7 @@ function StoragePanelContent() { }); const rows = flattenStorageEntryPreviewPages(previews.data); const supportedTypes = selectedDescriptor?.capabilities.supportedTypes ?? []; + const readOnly = selectedDescriptor ? isReadOnly(selectedDescriptor.capabilities) : false; const sidebarGroups = useMemo(() => { const storages = new Map( descriptors.map((descriptor) => [getStorageViewId(descriptor.target), descriptor]), @@ -508,30 +509,31 @@ function StoragePanelContent() { id: 'actions', header: '', enableSorting: false, - cell: ({ row }) => ( -
event.stopPropagation()}> - setInteraction({ key: row.original.key, mode: 'edit' })} - label={`Edit value for ${row.original.key}`} - > - - - void handleDeleteClick(row.original.key)} - label={`Delete entry ${row.original.key}`} - > - - -
- ), + cell: ({ row }) => + readOnly ? null : ( +
event.stopPropagation()}> + setInteraction({ key: row.original.key, mode: 'edit' })} + label={`Edit value for ${row.original.key}`} + > + + + void handleDeleteClick(row.original.key)} + label={`Delete entry ${row.original.key}`} + > + + +
+ ), }, ], - [handleDeleteClick], + [handleDeleteClick, readOnly], ); const sorting: SortingState = [{ id: 'key', desc: keySortDirection === 'descending' }]; const handleSortingChange: OnChangeFn = (updater) => { @@ -608,7 +610,7 @@ function StoragePanelContent() { setShowAddDialog(true)} - disabled={!selectedDescriptor} + disabled={!selectedDescriptor || readOnly} aria-label="Add entry" title="Add entry" className="w-6 px-0" @@ -617,7 +619,7 @@ function StoragePanelContent() { void handlePurgeClick()} - disabled={!client || !selectedTarget || previews.isFetching} + disabled={!client || !selectedTarget || previews.isFetching || readOnly} aria-label="Purge storage" title="Purge storage" className="w-6 px-0 text-muted-foreground hover:text-danger" @@ -641,7 +643,7 @@ function StoragePanelContent() { { if (!open) closeInteraction(); }} - onEdit={() => - setInteraction((current) => (current ? { ...current, mode: 'edit' } : current)) + onEdit={ + readOnly + ? undefined + : () => setInteraction((current) => (current ? { ...current, mode: 'edit' } : current)) } entry={fullEntry.entry ?? null} /> + ## Setup @@ -105,7 +105,23 @@ createKeychainStorageAdapter({ }); ``` -Each entry is one Keychain (iOS) or Keystore (Android) service, and its value is the stored password. By default every service reported by `getAllGenericPasswordServices` is listed; pass `services` (a list or an async function) to limit it. Editing an entry keeps its existing username, new entries use `defaultUsername`, and `setOptions` is forwarded to `setGenericPassword` (for example `accessible`). Set `readOnly` to reject changes from the panel. +Each entry is one Keychain (iOS) or Keystore (Android) service, and its value is the stored password. By default every service reported by `getAllGenericPasswordServices` is listed, which can include services written by other libraries in your app. Pass `services` (a list or an async function) to limit it. Purging the storage removes every listed service except the ones hidden by `blacklist`. + +On iOS, services that need Face ID, Touch ID or a passcode are skipped when listing, so the panel doesn't trigger authentication prompts. Set `skipUIAuth: false` to list them anyway. Entries that can't be read are left out instead of failing the whole list. + +Editing an entry keeps its existing username and, on Android, its storage type. New entries use `defaultUsername`. `setOptions` is forwarded to `setGenericPassword` (for example `accessible` or `accessControl`). The keychain doesn't report these options for stored entries, so if your entries use different ones, pass a function that returns the options for each service: + +```ts +createKeychainStorageAdapter({ + keychain: Keychain, + setOptions: (service) => + service === 'auth-token' + ? { accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET } + : undefined, +}); +``` + +Set `readOnly` to disable adding, editing, deleting, purging and importing in the panel. If `keychain` is `undefined`, for example because you load the library optionally, the adapter exposes no storages instead of throwing. From d69c0b25b84656f217da4b068278dacddb98e163 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 20 Oct 2026 09:55:19 +0200 Subject: [PATCH 3/4] fix(storage-plugin): handle unreadable keychain entries in writes and agent tools - Warn when a Keychain entry cannot be read instead of skipping it silently. - Overwrite unreadable entries with the defaults instead of failing set(). - Let setOptions.storage take precedence over the existing storage type. - List each service once; iOS repeats services with several accounts. - Agent create, edit and remove tools fall back to the key list, so listed entries that cannot be read still count as existing. --- .../__tests__/keychain-adapter.test.ts | 46 +++++++++++++++++- .../__tests__/use-storage-agent-tools.test.ts | 33 ++++++++++++- .../src/react-native/adapters/keychain.ts | 47 +++++++++++-------- .../src/react-native/useStorageAgentTools.ts | 15 +++--- .../docs/docs/official-plugins/storage.mdx | 4 +- 5 files changed, 114 insertions(+), 31 deletions(-) diff --git a/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts index 5e77feafa..1e8ed384a 100644 --- a/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/keychain-adapter.test.ts @@ -110,7 +110,8 @@ describe('createKeychainStorageAdapter', () => { expect(await storage.get('missing')).toBeUndefined(); }); - it('returns undefined for entries that cannot be read', async () => { + it('returns undefined and warns for entries that cannot be read', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { keychain } = createFakeKeychain({ token: { username: 'alice', password: 'secret' }, }); @@ -118,6 +119,19 @@ describe('createKeychainStorageAdapter', () => { const storage = getStorage(createKeychainStorageAdapter({ keychain })); expect(await storage.get('token')).toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Could not read Keychain service "token"'), + expect.any(Error), + ); + warn.mockRestore(); + }); + + it('lists each service once', async () => { + const { keychain } = createFakeKeychain(); + keychain.getAllGenericPasswordServices.mockResolvedValueOnce(['token', 'token', 'session']); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + expect(await storage.getAllKeys()).toEqual(['token', 'session']); }); it('keeps the existing username when updating an entry', async () => { @@ -154,6 +168,36 @@ describe('createKeychainStorageAdapter', () => { }); }); + it('overwrites an unreadable entry with the defaults', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { keychain, items } = createFakeKeychain({ + token: { username: 'alice', password: 'old' }, + }); + keychain.getGenericPassword.mockRejectedValueOnce(new Error('Key permanently invalidated')); + const storage = getStorage(createKeychainStorageAdapter({ keychain })); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(items.get('token')).toEqual({ username: 'user', password: 'new' }); + warn.mockRestore(); + }); + + it('lets setOptions override the existing storage type', async () => { + const { keychain } = createFakeKeychain({ + token: { username: 'alice', password: 'old', storage: 'KeystoreAESCBC' }, + }); + const storage = getStorage( + createKeychainStorageAdapter({ keychain, setOptions: { storage: 'KeystoreAESGCM_NoAuth' } }), + ); + + await storage.set({ key: 'token', type: 'string', value: 'new' }); + + expect(keychain.setGenericPassword).toHaveBeenCalledWith('alice', 'new', { + storage: 'KeystoreAESGCM_NoAuth', + service: 'token', + }); + }); + it('resolves setOptions per service when given a function', async () => { const { keychain } = createFakeKeychain(); const storage = getStorage( diff --git a/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts b/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts index 10d7e119d..01cc11d94 100644 --- a/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts +++ b/packages/storage-plugin/src/react-native/__tests__/use-storage-agent-tools.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { useRozenitePluginAgentTool } from '@rozenite/agent-bridge'; import { MAX_PAGE_LIMIT } from '@rozenite/agent-shared'; import { storageToolDefinitions } from '../../shared/agent-tools'; import type { StorageAdapter, StorageEntry } from '../../shared/types'; @@ -7,7 +8,7 @@ vi.mock('@rozenite/agent-bridge', () => ({ useRozenitePluginAgentTool: vi.fn(), })); -import { createStorageAgentHandlers } from '../useStorageAgentTools'; +import { createStorageAgentHandlers, useStorageAgentTools } from '../useStorageAgentTools'; import { createStorageViews } from '../storage-view'; const createViews = ( @@ -292,4 +293,34 @@ describe('storage Agent tool handlers', () => { expect(adapters[0].storages[0].storage.get).toHaveBeenCalledTimes(1); expect(adapters[0].storages[0].storage.get).toHaveBeenCalledWith('large'); }); + + it('treats listed entries that cannot be read as existing in write tools', async () => { + const { views } = createViews([ + { adapterId: 'adapter', storageId: 'storage', entries: entries('readable') }, + ]); + // The key is listed, but reading it returns nothing (e.g. a locked Keychain item). + const getAllKeys = vi.spyOn(views[0], 'getAllKeys').mockResolvedValue(['readable', 'locked']); + vi.mocked(useRozenitePluginAgentTool).mockClear(); + useStorageAgentTools(views); + const handler = (name: string) => { + const call = vi + .mocked(useRozenitePluginAgentTool) + .mock.calls.find(([options]) => options.tool.name === name); + return call?.[0].handler as (input: Record) => Promise; + }; + + await expect( + handler('create-entry')({ key: 'locked', type: 'string', value: 'x' }), + ).rejects.toThrow('already exists'); + await expect( + handler('edit-entry')({ key: 'locked', type: 'string', value: 'x' }), + ).resolves.toMatchObject({ edited: true }); + await expect(handler('remove-entry')({ key: 'locked' })).resolves.toMatchObject({ + removed: true, + }); + await expect(handler('remove-entry')({ key: 'missing' })).resolves.toMatchObject({ + removed: false, + }); + getAllKeys.mockRestore(); + }); }); diff --git a/packages/storage-plugin/src/react-native/adapters/keychain.ts b/packages/storage-plugin/src/react-native/adapters/keychain.ts index f04752769..88aa296a7 100644 --- a/packages/storage-plugin/src/react-native/adapters/keychain.ts +++ b/packages/storage-plugin/src/react-native/adapters/keychain.ts @@ -64,12 +64,27 @@ export const createKeychainStorageAdapter = ({ return { id: adapterId, name: adapterName, storages: [] }; } - const resolveServices = async (): Promise => { - if (typeof services === 'function') { - return services(); - } + const resolveServices = async (): Promise => { + const resolved = + typeof services === 'function' + ? await services() + : (services ?? (await keychain.getAllGenericPasswordServices({ skipUIAuth }))); + + // iOS reports one service per Keychain item, so a service with several accounts repeats. + return [...new Set(resolved)]; + }; - return services ?? keychain.getAllGenericPasswordServices({ skipUIAuth }); + const readCredentials = async (service: string) => { + try { + return await keychain.getGenericPassword({ service }); + } catch (error) { + // Entries behind biometrics, or with an invalidated Keystore key, cannot be read. + console.warn( + `[Rozenite] Storage Plugin: Could not read Keychain service "${service}".`, + error, + ); + return false; + } }; const isBlacklisted = (service: string) => { @@ -94,17 +109,10 @@ export const createKeychainStorageAdapter = ({ }, storage: { kind: 'async', - getAllKeys: async () => [...(await resolveServices())], + getAllKeys: resolveServices, get: async (key) => { - let credentials: Awaited>; - try { - credentials = await keychain.getGenericPassword({ service: key }); - } catch { - // Entries behind biometrics, or with an invalidated Keystore key, cannot be read. - // Skip them instead of failing the whole listing. - return undefined; - } - + // Unreadable entries are skipped instead of failing the whole listing. + const credentials = await readCredentials(key); if (!credentials) { return undefined; } @@ -121,14 +129,13 @@ export const createKeychainStorageAdapter = ({ } // setGenericPassword replaces the whole entry, so keep the existing username and, - // on Android, the storage type it was encrypted with. - const existing = await keychain.getGenericPassword({ - service: entry.key, - }); + // on Android, the storage type it was encrypted with. An unreadable entry is + // overwritten with the defaults. + const existing = await readCredentials(entry.key); const username = existing ? existing.username : defaultUsername; await keychain.setGenericPassword(username, entry.value, { - ...resolveSetOptions(entry.key), ...(existing && existing.storage ? { storage: existing.storage } : {}), + ...resolveSetOptions(entry.key), service: entry.key, }); }, diff --git a/packages/storage-plugin/src/react-native/useStorageAgentTools.ts b/packages/storage-plugin/src/react-native/useStorageAgentTools.ts index 7d69f6e02..ca65da55d 100644 --- a/packages/storage-plugin/src/react-native/useStorageAgentTools.ts +++ b/packages/storage-plugin/src/react-native/useStorageAgentTools.ts @@ -167,6 +167,11 @@ export const createStorageAgentHandlers = (views: readonly StorageView[]) => ({ }, }); +// Some entries exist but cannot be read (for example Keychain items behind biometrics), +// so fall back to the key list when reading returns nothing. +const hasEntry = async (view: StorageView, key: string) => + !!(await view.get(key)) || (await view.getAllKeys()).includes(key); + export const useStorageAgentTools = (views: StorageView[]) => { const handlers = createStorageAgentHandlers(views); @@ -193,9 +198,7 @@ export const useStorageAgentTools = (views: StorageView[]) => { tool: storageToolDefinitions.createEntry, handler: async ({ adapterId, storageId, key, type, value }) => { const view = resolveStorage(views, adapterId, storageId); - const existing = await view.get(key); - - if (existing) { + if (await hasEntry(view, key)) { throw new Error( `Key "${key}" already exists in storage "${formatViewRef(view)}". Use edit-entry instead.`, ); @@ -218,9 +221,7 @@ export const useStorageAgentTools = (views: StorageView[]) => { tool: storageToolDefinitions.editEntry, handler: async ({ adapterId, storageId, key, type, value }) => { const view = resolveStorage(views, adapterId, storageId); - const existing = await view.get(key); - - if (!existing) { + if (!(await hasEntry(view, key))) { throw new Error( `Key "${key}" not found in storage "${formatViewRef(view)}". Use create-entry instead.`, ); @@ -243,7 +244,7 @@ export const useStorageAgentTools = (views: StorageView[]) => { tool: storageToolDefinitions.removeEntry, handler: async ({ adapterId, storageId, key }) => { const view = resolveStorage(views, adapterId, storageId); - const existed = !!(await view.get(key)); + const existed = await hasEntry(view, key); await view.delete(key); return { diff --git a/website/src/content/docs/docs/official-plugins/storage.mdx b/website/src/content/docs/docs/official-plugins/storage.mdx index ae7d5c4ce..cd2aa8715 100644 --- a/website/src/content/docs/docs/official-plugins/storage.mdx +++ b/website/src/content/docs/docs/official-plugins/storage.mdx @@ -107,9 +107,9 @@ createKeychainStorageAdapter({ Each entry is one Keychain (iOS) or Keystore (Android) service, and its value is the stored password. By default every service reported by `getAllGenericPasswordServices` is listed, which can include services written by other libraries in your app. Pass `services` (a list or an async function) to limit it. Purging the storage removes every listed service except the ones hidden by `blacklist`. -On iOS, services that need Face ID, Touch ID or a passcode are skipped when listing, so the panel doesn't trigger authentication prompts. Set `skipUIAuth: false` to list them anyway. Entries that can't be read are left out instead of failing the whole list. +On iOS, services that need Face ID, Touch ID or a passcode are skipped when listing, so the panel doesn't trigger authentication prompts. Set `skipUIAuth: false` to list them anyway. Entries that can't be read, for example after a biometric change invalidates their Keystore key, are left out of the table with a warning in the app's console instead of failing the whole list. They still count towards the storage's entry count, and purging removes them. -Editing an entry keeps its existing username and, on Android, its storage type. New entries use `defaultUsername`. `setOptions` is forwarded to `setGenericPassword` (for example `accessible` or `accessControl`). The keychain doesn't report these options for stored entries, so if your entries use different ones, pass a function that returns the options for each service: +Editing an entry keeps its existing username and, on Android, its storage type unless `setOptions` sets `storage`. New entries, and entries that can't be read, use `defaultUsername`. `setOptions` is forwarded to `setGenericPassword` (for example `accessible` or `accessControl`). The keychain doesn't report these options for stored entries, so if your entries use different ones, pass a function that returns the options for each service: ```ts createKeychainStorageAdapter({ From ea1f461bc83bf473fa10cd3a224dd5fa244fd98e Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Tue, 20 Oct 2026 09:57:10 +0200 Subject: [PATCH 4/4] fix(storage-plugin): refresh the panel after a purge that fails partway Adapters such as Expo SecureStore and react-native-keychain can remove some entries before clear() rejects. The purge handler only sent the invalidation on success, so the panel kept showing removed entries. --- .changeset/storage-plugin-purge-refresh.md | 5 + .../use-rozenite-storage-plugin.test.tsx | 105 ++++++++++++++++++ .../react-native/useRozeniteStoragePlugin.ts | 11 +- 3 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 .changeset/storage-plugin-purge-refresh.md create mode 100644 packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx diff --git a/.changeset/storage-plugin-purge-refresh.md b/.changeset/storage-plugin-purge-refresh.md new file mode 100644 index 000000000..12d43adbe --- /dev/null +++ b/.changeset/storage-plugin-purge-refresh.md @@ -0,0 +1,5 @@ +--- +'@rozenite/storage-plugin': patch +--- + +Refresh the Storage panel after a purge that fails partway, so it no longer shows entries that were already removed. diff --git a/packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx b/packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx new file mode 100644 index 000000000..dbbf53317 --- /dev/null +++ b/packages/storage-plugin/src/react-native/__tests__/use-rozenite-storage-plugin.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorageAdapter } from '../../shared/types'; + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean | undefined; +} + +type Listener = (payload: unknown) => void; + +const mocks = vi.hoisted(() => { + const listeners = new Map(); + return { + listeners, + client: { + send: vi.fn(), + onMessage: vi.fn((type: string, listener: Listener) => { + listeners.set(type, listener); + return { remove: () => listeners.delete(type) }; + }), + }, + }; +}); + +vi.mock('@rozenite/plugin-bridge', () => ({ + useRozeniteDevToolsClient: () => mocks.client, +})); +vi.mock('@rozenite/agent-bridge', () => ({ + useRozenitePluginAgentTool: vi.fn(), +})); + +import { useRozeniteStoragePlugin } from '../useRozeniteStoragePlugin'; + +const target = { adapterId: 'adapter', storageId: 'storage' }; + +const render = async (storages: StorageAdapter[]) => { + const Harness = () => { + useRozeniteStoragePlugin({ storages }); + return null; + }; + const root = createRoot(document.createElement('div')); + await act(async () => root.render()); + return root; +}; + +describe('useRozeniteStoragePlugin purge-storage', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + mocks.listeners.clear(); + mocks.client.send.mockReset(); + }); + afterEach(() => { + delete globalThis.IS_REACT_ACT_ENVIRONMENT; + }); + + it('refreshes the panel even when the purge fails partway', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const keys = ['a', 'b', 'c']; + const root = await render([ + { + id: target.adapterId, + name: 'Adapter', + storages: [ + { + id: target.storageId, + name: 'Storage', + capabilities: { supportedTypes: ['string'] }, + storage: { + kind: 'async', + getAllKeys: async () => keys, + get: async () => undefined, + set: async () => {}, + delete: async () => {}, + clear: async () => { + keys.splice(0, keys.length, 'b'); + throw new Error('Could not clear services: b.'); + }, + }, + }, + ], + }, + ]); + + await act(async () => + mocks.listeners.get('purge-storage')?.({ type: 'purge-storage', target }), + ); + + expect(mocks.client.send).toHaveBeenCalledWith('storage-invalidated', { + type: 'storage-invalidated', + target, + key: undefined, + operation: 'purge', + entryCount: 1, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to purge storage in adapter/storage'), + expect.any(Error), + ); + warn.mockRestore(); + await act(async () => root.unmount()); + }); +}); diff --git a/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts b/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts index 31590331e..ae2685979 100644 --- a/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts +++ b/packages/storage-plugin/src/react-native/useRozeniteStoragePlugin.ts @@ -158,13 +158,22 @@ export const useRozeniteStoragePlugin = ({ storages }: RozeniteStoragePluginOpti try { await view.purge(); - await sendInvalidation(view.target, undefined, 'purge'); } catch (error) { console.warn( `[Rozenite] Storage Plugin: Failed to purge storage in ${target.adapterId}/${target.storageId}.`, error, ); } + + // A failed purge may still have removed some entries, so refresh the panel either way. + try { + await sendInvalidation(view.target, undefined, 'purge'); + } catch (error) { + console.warn( + `[Rozenite] Storage Plugin: Failed to refresh storage in ${target.adapterId}/${target.storageId}.`, + error, + ); + } }), client.onMessage('discover-storages', async (event: StorageDiscoverStoragesRequestEvent) => { const response = await handleStorageDiscoveryRequest(views, event);