react-native-nitro-storage 0.4.4 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +107 -7
  2. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +61 -10
  3. package/ios/IOSStorageAdapterCpp.mm +44 -14
  4. package/lib/commonjs/index.js +221 -5
  5. package/lib/commonjs/index.js.map +1 -1
  6. package/lib/commonjs/index.web.js +444 -202
  7. package/lib/commonjs/index.web.js.map +1 -1
  8. package/lib/commonjs/indexeddb-backend.js +129 -7
  9. package/lib/commonjs/indexeddb-backend.js.map +1 -1
  10. package/lib/commonjs/storage-runtime.js +41 -0
  11. package/lib/commonjs/storage-runtime.js.map +1 -0
  12. package/lib/commonjs/web-storage-backend.js +90 -0
  13. package/lib/commonjs/web-storage-backend.js.map +1 -0
  14. package/lib/module/index.js +213 -5
  15. package/lib/module/index.js.map +1 -1
  16. package/lib/module/index.web.js +436 -202
  17. package/lib/module/index.web.js.map +1 -1
  18. package/lib/module/indexeddb-backend.js +129 -7
  19. package/lib/module/indexeddb-backend.js.map +1 -1
  20. package/lib/module/storage-runtime.js +36 -0
  21. package/lib/module/storage-runtime.js.map +1 -0
  22. package/lib/module/web-storage-backend.js +86 -0
  23. package/lib/module/web-storage-backend.js.map +1 -0
  24. package/lib/typescript/index.d.ts +11 -7
  25. package/lib/typescript/index.d.ts.map +1 -1
  26. package/lib/typescript/index.web.d.ts +12 -8
  27. package/lib/typescript/index.web.d.ts.map +1 -1
  28. package/lib/typescript/indexeddb-backend.d.ts +6 -2
  29. package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
  30. package/lib/typescript/storage-runtime.d.ts +16 -0
  31. package/lib/typescript/storage-runtime.d.ts.map +1 -0
  32. package/lib/typescript/web-storage-backend.d.ts +30 -0
  33. package/lib/typescript/web-storage-backend.d.ts.map +1 -0
  34. package/package.json +1 -1
  35. package/src/index.ts +264 -20
  36. package/src/index.web.ts +597 -245
  37. package/src/indexeddb-backend.ts +147 -6
  38. package/src/storage-runtime.ts +94 -0
  39. package/src/web-storage-backend.ts +129 -0
package/README.md CHANGED
@@ -40,7 +40,11 @@ Every feature in this package is documented with at least one runnable example i
40
40
  - Prefix utilities (`getKeysByPrefix`, `getByPrefix`) — see Prefix Queries and Namespace Inspection
41
41
  - Versioned item API (`getWithVersion`, `setIfVersion`) — see Optimistic Versioned Writes
42
42
  - Metrics API (`setMetricsObserver`, `getMetricsSnapshot`, `resetMetrics`) — see Storage Metrics Instrumentation
43
+ - Runtime capability introspection (`storage.getCapabilities()`) — see Global utility examples
44
+ - Structured storage error codes (`getStorageErrorCode`, `isKeychainLockedError`) — see Error Classification
45
+ - Web disk backend override (`setWebDiskStorageBackend`, `getWebDiskStorageBackend`) — see Custom Web Disk and Secure Backends
43
46
  - Web secure backend override (`setWebSecureStorageBackend`, `getWebSecureStorageBackend`) — see Custom Web Secure Backend
47
+ - Web backend durability (`flushWebStorageBackends`) — see Custom Web Disk and Secure Backends
44
48
  - IndexedDB backend factory (`createIndexedDBBackend`) — see IndexedDB Backend for Web
45
49
  - Bulk import (`storage.import`) — see Bulk Data Import
46
50
  - Batch APIs (`getBatch`, `setBatch`, `removeBatch`) — see Batch Operations and Bulk Bootstrap with Batch APIs
@@ -196,6 +200,7 @@ function createStorageItem<T = undefined>(
196
200
  | `expiration` | `{ ttlMs: number }` | — | Time-to-live in milliseconds |
197
201
  | `onExpired` | `(key: string) => void` | — | Callback fired when a TTL value expires on read |
198
202
  | `readCache` | `boolean` | `false` | Cache deserialized values in JS (avoids repeated native reads) |
203
+ | `coalesceDiskWrites` | `boolean` | `false` | Batch same-tick Disk writes per key until `flushDiskWrites()` |
199
204
  | `coalesceSecureWrites` | `boolean` | `false` | Batch same-tick Secure writes per key |
200
205
  | `namespace` | `string` | — | Prefix key as `namespace:key` for isolation |
201
206
  | `biometric` | `boolean` | `false` | Require biometric auth (Secure scope only) |
@@ -278,7 +283,10 @@ import { storage, StorageScope } from "react-native-nitro-storage";
278
283
  | `storage.getByPrefix(prefix, scope)` | Get raw key-value pairs for keys matching a prefix |
279
284
  | `storage.getAll(scope)` | Get all key-value pairs as `Record<string, string>` |
280
285
  | `storage.size(scope)` | Number of stored keys |
286
+ | `storage.getCapabilities()` | Read runtime backend metadata and buffering support |
281
287
  | `storage.setAccessControl(level)` | Set default secure access control for subsequent secure writes (native only) |
288
+ | `storage.setDiskWritesAsync(enabled)` | Buffer raw Disk writes in JS until flushed (all platforms) |
289
+ | `storage.flushDiskWrites()` | Force flush queued Disk writes from raw APIs / coalesced items |
282
290
  | `storage.setSecureWritesAsync(enabled)` | Toggle async secure writes on Android (`false` by default) |
283
291
  | `storage.flushSecureWrites()` | Force flush of queued secure writes when coalescing is enabled |
284
292
  | `storage.setKeychainAccessGroup(group)` | Set keychain access group for app sharing (native only) |
@@ -290,6 +298,14 @@ import { storage, StorageScope } from "react-native-nitro-storage";
290
298
  | `storage.getMetricsSnapshot()` | Get aggregate counters/latency stats keyed by operation |
291
299
  | `storage.resetMetrics()` | Reset in-memory metrics counters |
292
300
 
301
+ | Web helper | Description |
302
+ | -------------------------------------- | -------------------------------------------------------------------- |
303
+ | `setWebDiskStorageBackend(backend?)` | Override the web Disk backend (web only) |
304
+ | `getWebDiskStorageBackend()` | Read the active web Disk backend (web only) |
305
+ | `setWebSecureStorageBackend(backend?)` | Override the web Secure backend (web only) |
306
+ | `getWebSecureStorageBackend()` | Read the active web Secure backend (web only) |
307
+ | `flushWebStorageBackends()` | Await optional backend durability hooks for Disk + Secure (web only) |
308
+
293
309
  > `storage.getAll(StorageScope.Secure)` returns regular secure entries. Biometric-protected values are not included in this snapshot API.
294
310
 
295
311
  #### Global utility examples
@@ -307,6 +323,7 @@ storage.getKeysByPrefix("user-42:", StorageScope.Disk);
307
323
  storage.getByPrefix("user-42:", StorageScope.Disk);
308
324
  storage.getAll(StorageScope.Disk);
309
325
  storage.size(StorageScope.Disk);
326
+ storage.getCapabilities();
310
327
 
311
328
  storage.clearNamespace("user-42", StorageScope.Disk);
312
329
  storage.clearBiometric();
@@ -318,6 +335,32 @@ storage.clear(StorageScope.Memory);
318
335
  storage.clearAll();
319
336
  ```
320
337
 
338
+ #### Disk write buffering
339
+
340
+ Disk writes can now be buffered in JS, similar to secure write coalescing, which is useful when you are doing bursty persistence and want an explicit durability boundary.
341
+
342
+ ```ts
343
+ import {
344
+ createStorageItem,
345
+ storage,
346
+ StorageScope,
347
+ } from "react-native-nitro-storage";
348
+
349
+ const bufferedDraft = createStorageItem({
350
+ key: "draft",
351
+ scope: StorageScope.Disk,
352
+ defaultValue: "",
353
+ coalesceDiskWrites: true,
354
+ });
355
+
356
+ bufferedDraft.set("hello");
357
+ storage.setDiskWritesAsync(true);
358
+ storage.setString("draft:raw", "value", StorageScope.Disk);
359
+
360
+ storage.flushDiskWrites(); // commit queued Disk writes
361
+ storage.setDiskWritesAsync(false);
362
+ ```
363
+
321
364
  #### Android secure write mode
322
365
 
323
366
  `storage.setSecureWritesAsync(true)` switches secure writes from synchronous `commit()` to asynchronous `apply()` on Android.
@@ -335,25 +378,63 @@ storage.setSecureWritesAsync(true);
335
378
  storage.flushSecureWrites(); // deterministic durability boundary
336
379
  ```
337
380
 
338
- #### Custom web secure backend
381
+ #### Custom Web Disk and Secure Backends
382
+
383
+ By default, web Disk and Secure scopes use `localStorage`. Disk excludes Nitro's secure prefixes, and Secure stores under `__secure_` / `__bio_` prefixes.
339
384
 
340
- By default, web Secure scope uses `localStorage` with `__secure_` key prefixing. You can replace it with a custom backend (for example encrypted IndexedDB adapter).
385
+ You can replace either backend with a custom implementation. The minimal backend contract is:
386
+
387
+ ```ts
388
+ type WebStorageBackend = {
389
+ getItem(key: string): string | null;
390
+ setItem(key: string, value: string): void;
391
+ removeItem(key: string): void;
392
+ clear(): void;
393
+ getAllKeys(): string[];
394
+ getMany?: (keys: string[]) => (string | null)[];
395
+ setMany?: (entries: ReadonlyArray<readonly [string, string]>) => void;
396
+ removeMany?: (keys: string[]) => void;
397
+ size?: () => number;
398
+ subscribe?: (
399
+ listener: (event: { key: string | null; newValue: string | null }) => void,
400
+ ) => () => void;
401
+ flush?: () => Promise<void>;
402
+ name?: string;
403
+ };
404
+ ```
405
+
406
+ Optional hooks are used for faster batch operations, custom cross-tab sync, and explicit durability boundaries.
341
407
 
342
408
  ```ts
343
409
  import {
410
+ flushWebStorageBackends,
411
+ getWebDiskStorageBackend,
344
412
  getWebSecureStorageBackend,
413
+ setWebDiskStorageBackend,
345
414
  setWebSecureStorageBackend,
346
415
  } from "react-native-nitro-storage";
347
416
 
417
+ setWebDiskStorageBackend({
418
+ getItem: (key) => diskStore.get(key) ?? null,
419
+ setItem: (key, value) => diskStore.set(key, value),
420
+ removeItem: (key) => diskStore.delete(key),
421
+ clear: () => diskStore.clear(),
422
+ getAllKeys: () => Array.from(diskStore.keys()),
423
+ });
424
+
348
425
  setWebSecureStorageBackend({
349
426
  getItem: (key) => encryptedStore.get(key) ?? null,
350
427
  setItem: (key, value) => encryptedStore.set(key, value),
351
428
  removeItem: (key) => encryptedStore.delete(key),
352
429
  clear: () => encryptedStore.clear(),
353
- getAllKeys: () => encryptedStore.keys(),
430
+ getAllKeys: () => Array.from(encryptedStore.keys()),
354
431
  });
355
432
 
433
+ await flushWebStorageBackends();
434
+
435
+ const diskBackend = getWebDiskStorageBackend();
356
436
  const backend = getWebSecureStorageBackend();
437
+ console.log("custom disk backend active:", diskBackend !== undefined);
357
438
  console.log("custom backend active:", backend !== undefined);
358
439
  ```
359
440
 
@@ -376,12 +457,24 @@ setWebSecureStorageBackend(backend);
376
457
 
377
458
  - **Async init**: `createIndexedDBBackend()` opens (or creates) the IndexedDB database and hydrates an in-memory cache from all stored entries before resolving.
378
459
  - **Synchronous reads**: all `getItem` calls are served from the in-memory cache — no async overhead after init.
379
- - **Fire-and-forget writes**: `setItem`, `removeItem`, and `clear` update the cache synchronously, then persist to IndexedDB in the background. The cache is always the authoritative source.
460
+ - **Queued writes + durability**: writes update the cache synchronously, persist in the background, and can be awaited via `await backend.flush()` or `await flushWebStorageBackends()`.
461
+ - **Cross-tab sync**: backend instances on the same `dbName`/`storeName` coordinate through `BroadcastChannel` so cache invalidation reaches other tabs.
380
462
  - **Custom database/store**: optionally pass `dbName` and `storeName` to isolate databases per environment or tenant.
381
463
 
382
464
  ```ts
383
465
  const backend = await createIndexedDBBackend("my-app-db", "secure-kv");
384
466
  setWebSecureStorageBackend(backend);
467
+ await backend.flush?.();
468
+ ```
469
+
470
+ You can also pass an optional third argument to receive async persistence failures:
471
+
472
+ ```ts
473
+ const backend = await createIndexedDBBackend("my-app-db", "secure-kv", {
474
+ onError: (error) => {
475
+ console.error("indexeddb persistence failed", error);
476
+ },
477
+ });
385
478
  ```
386
479
 
387
480
  ---
@@ -530,16 +623,23 @@ These are synchronous and go directly to the native backend without any serializ
530
623
 
531
624
  ---
532
625
 
533
- ### `isKeychainLockedError(err)`
626
+ ### Error Classification
534
627
 
535
- Utility to detect iOS Keychain locked errors and Android key invalidation errors in secure storage operations. Returns `true` if the error was caused by a locked keychain (device locked, first unlock not yet performed, etc.) or an Android `KeyPermanentlyInvalidatedException` / `InvalidKeyException`. Always returns `false` on web.
628
+ `getStorageErrorCode(err)` returns a stable classification for common native/web storage failures. Native bridges now emit stable `[nitro-error:<code>]` tags so the classification path does not depend on platform exception wording alone.
629
+ `isKeychainLockedError(err)` remains the convenience helper for retry-after-unlock flows and now delegates to the structured code path.
536
630
 
537
631
  ```ts
538
- import { isKeychainLockedError } from "react-native-nitro-storage";
632
+ import {
633
+ getStorageErrorCode,
634
+ isKeychainLockedError,
635
+ } from "react-native-nitro-storage";
539
636
 
540
637
  try {
541
638
  secureItem.get();
542
639
  } catch (err) {
640
+ const code = getStorageErrorCode(err);
641
+ // "keychain_locked" | "authentication_required" | ...
642
+
543
643
  if (isKeychainLockedError(err)) {
544
644
  // device is locked — retry after unlock
545
645
  }
@@ -1,11 +1,15 @@
1
1
  package com.nitrostorage
2
2
 
3
3
  import android.content.Context
4
+ import android.security.keystore.KeyPermanentlyInvalidatedException
5
+ import android.security.keystore.UserNotAuthenticatedException
4
6
  import android.content.SharedPreferences
5
7
  import android.util.Log
6
8
  import androidx.security.crypto.EncryptedSharedPreferences
7
9
  import androidx.security.crypto.MasterKey
10
+ import java.security.InvalidKeyException
8
11
  import java.security.KeyStore
12
+ import java.security.KeyStoreException
9
13
  import javax.crypto.AEADBadTagException
10
14
 
11
15
  private fun Throwable.hasCause(type: Class<*>): Boolean {
@@ -17,6 +21,30 @@ private fun Throwable.hasCause(type: Class<*>): Boolean {
17
21
  return false
18
22
  }
19
23
 
24
+ private fun Throwable.storageErrorCode(): String? {
25
+ return when {
26
+ hasCause(AEADBadTagException::class.java) -> "storage_corruption"
27
+ hasCause(UserNotAuthenticatedException::class.java) ||
28
+ hasCause(KeyStoreException::class.java) -> "authentication_required"
29
+ hasCause(KeyPermanentlyInvalidatedException::class.java) ||
30
+ hasCause(InvalidKeyException::class.java) -> "key_invalidated"
31
+ else -> null
32
+ }
33
+ }
34
+
35
+ private fun Throwable.wrapStorageException(
36
+ defaultMessage: String,
37
+ defaultCode: String? = null,
38
+ ): RuntimeException {
39
+ val code = storageErrorCode() ?: defaultCode
40
+ val message = if (code != null) {
41
+ "[nitro-error:$code] $defaultMessage"
42
+ } else {
43
+ defaultMessage
44
+ }
45
+ return RuntimeException(message, this)
46
+ }
47
+
20
48
  class AndroidStorageAdapter private constructor(private val context: Context) {
21
49
  private val sharedPreferences: SharedPreferences =
22
50
  context.getSharedPreferences("NitroStorage", Context.MODE_PRIVATE)
@@ -44,8 +72,11 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
44
72
  initializeEncryptedPreferences("NitroStorageBiometric", bioKey)
45
73
  } catch (e: Exception) {
46
74
  Log.e("NitroStorage", "Biometric storage unavailable: ${e.message}")
47
- throw RuntimeException("NitroStorage: Biometric storage is not available on this device. " +
48
- "Ensure biometric hardware is present and credentials are enrolled.", e)
75
+ throw e.wrapStorageException(
76
+ "NitroStorage: Biometric storage is not available on this device. " +
77
+ "Ensure biometric hardware is present and credentials are enrolled.",
78
+ defaultCode = "biometric_unavailable",
79
+ )
49
80
  }
50
81
  }
51
82
 
@@ -83,14 +114,17 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
83
114
  EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
84
115
  )
85
116
  } catch (retryEx: Exception) {
86
- throw RuntimeException("NitroStorage: Unrecoverable storage corruption in $name", retryEx)
117
+ throw retryEx.wrapStorageException(
118
+ "NitroStorage: Unrecoverable storage corruption in $name",
119
+ defaultCode = "storage_corruption",
120
+ )
87
121
  }
88
122
  }
89
123
  else -> {
90
124
  // Don't wipe on non-corruption failures (e.g., locked keystore)
91
- throw RuntimeException(
125
+ throw e.wrapStorageException(
92
126
  "NitroStorage: Failed to initialize $name (${e::class.simpleName}). " +
93
- "This may be a temporary keystore issue. If it persists, clear app data.", e
127
+ "This may be a temporary keystore issue. If it persists, clear app data.",
94
128
  )
95
129
  }
96
130
  }
@@ -136,7 +170,9 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
136
170
  editor.commit()
137
171
  }
138
172
  } catch (e: Exception) {
139
- throw RuntimeException("NitroStorage: Failed to write to secure storage: ${e.message}", e)
173
+ throw e.wrapStorageException(
174
+ "NitroStorage: Failed to write to secure storage: ${e.message}",
175
+ )
140
176
  }
141
177
  }
142
178
 
@@ -303,14 +339,26 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
303
339
  @JvmStatic
304
340
  fun getSecure(key: String): String? {
305
341
  val inst = getInstanceOrThrow()
306
- return inst.getSecureSafe(inst.encryptedPreferences, key)
342
+ return try {
343
+ inst.getSecureSafe(inst.encryptedPreferences, key)
344
+ } catch (e: Exception) {
345
+ throw e.wrapStorageException(
346
+ "NitroStorage: Failed to read secure storage: ${e.message}",
347
+ )
348
+ }
307
349
  }
308
350
 
309
351
  @JvmStatic
310
352
  fun getSecureBatch(keys: Array<String>): Array<String?> {
311
353
  val inst = getInstanceOrThrow()
312
- return Array(keys.size) { index ->
313
- inst.getSecureSafe(inst.encryptedPreferences, keys[index])
354
+ return try {
355
+ Array(keys.size) { index ->
356
+ inst.getSecureSafe(inst.encryptedPreferences, keys[index])
357
+ }
358
+ } catch (e: Exception) {
359
+ throw e.wrapStorageException(
360
+ "NitroStorage: Failed to read secure storage batch: ${e.message}",
361
+ )
314
362
  }
315
363
  }
316
364
 
@@ -406,7 +454,10 @@ class AndroidStorageAdapter private constructor(private val context: Context) {
406
454
  inst.applySecureEditor(editor)
407
455
  inst.invalidateSecureKeysCache()
408
456
  } catch (e: Exception) {
409
- throw RuntimeException("NitroStorage: Biometric storage unavailable on this device", e)
457
+ throw e.wrapStorageException(
458
+ "NitroStorage: Biometric storage unavailable on this device",
459
+ defaultCode = "biometric_unavailable",
460
+ )
410
461
  }
411
462
  }
412
463
 
@@ -9,6 +9,12 @@ static NSString* const kKeychainService = @"com.nitrostorage.keychain";
9
9
  static NSString* const kBiometricKeychainService = @"com.nitrostorage.biometric";
10
10
  static NSString* const kDiskSuiteName = @"com.nitrostorage.disk";
11
11
 
12
+ static std::runtime_error taggedStorageError(const char* code, const std::string& message) {
13
+ return std::runtime_error(
14
+ std::string("[nitro-error:") + code + "] " + message
15
+ );
16
+ }
17
+
12
18
  static NSUserDefaults* NitroDiskDefaults() {
13
19
  static NSUserDefaults* defaults = [[NSUserDefaults alloc] initWithSuiteName:kDiskSuiteName];
14
20
  return defaults ?: [NSUserDefaults standardUserDefaults];
@@ -191,7 +197,8 @@ static std::vector<std::string> keychainAccountsForService(NSString* service, NS
191
197
  std::vector<std::string> keys;
192
198
  OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
193
199
  if (status == errSecInteractionNotAllowed) {
194
- throw std::runtime_error(
200
+ throw taggedStorageError(
201
+ "keychain_locked",
195
202
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
196
203
  "The item is not accessible until the device is unlocked."
197
204
  );
@@ -247,7 +254,8 @@ void IOSStorageAdapterCpp::setSecure(const std::string& key, const std::string&
247
254
  const OSStatus addStatus = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
248
255
  if (addStatus != errSecSuccess) {
249
256
  if (addStatus == errSecInteractionNotAllowed) {
250
- throw std::runtime_error(
257
+ throw taggedStorageError(
258
+ "keychain_locked",
251
259
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
252
260
  "The item is not accessible until the device is unlocked."
253
261
  );
@@ -261,7 +269,8 @@ void IOSStorageAdapterCpp::setSecure(const std::string& key, const std::string&
261
269
  }
262
270
 
263
271
  if (status == errSecInteractionNotAllowed) {
264
- throw std::runtime_error(
272
+ throw taggedStorageError(
273
+ "keychain_locked",
265
274
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
266
275
  "The item is not accessible until the device is unlocked."
267
276
  );
@@ -292,7 +301,8 @@ std::optional<std::string> IOSStorageAdapterCpp::getSecure(const std::string& ke
292
301
  if (str) return std::string([str UTF8String]);
293
302
  }
294
303
  if (status == errSecInteractionNotAllowed) {
295
- throw std::runtime_error(
304
+ throw taggedStorageError(
305
+ "keychain_locked",
296
306
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
297
307
  "The item is not accessible until the device is unlocked."
298
308
  );
@@ -312,7 +322,8 @@ void IOSStorageAdapterCpp::deleteSecure(const std::string& key) {
312
322
  NSMutableDictionary* secureQuery = baseKeychainQuery(nsKey, kKeychainService, group);
313
323
  OSStatus secureStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
314
324
  if (secureStatus == errSecInteractionNotAllowed) {
315
- throw std::runtime_error(
325
+ throw taggedStorageError(
326
+ "keychain_locked",
316
327
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
317
328
  "The item is not accessible until the device is unlocked."
318
329
  );
@@ -321,7 +332,8 @@ void IOSStorageAdapterCpp::deleteSecure(const std::string& key) {
321
332
  NSMutableDictionary* biometricQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
322
333
  OSStatus biometricStatus = SecItemDelete((__bridge CFDictionaryRef)biometricQuery);
323
334
  if (biometricStatus == errSecInteractionNotAllowed) {
324
- throw std::runtime_error(
335
+ throw taggedStorageError(
336
+ "keychain_locked",
325
337
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
326
338
  "The item is not accessible until the device is unlocked."
327
339
  );
@@ -427,7 +439,10 @@ void IOSStorageAdapterCpp::clearSecure() {
427
439
  OSStatus secStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
428
440
  if (secStatus != errSecSuccess && secStatus != errSecItemNotFound) {
429
441
  if (secStatus == errSecInteractionNotAllowed) {
430
- throw std::runtime_error("NitroStorage: Cannot clear secure storage: keychain is locked (errSecInteractionNotAllowed)");
442
+ throw taggedStorageError(
443
+ "keychain_locked",
444
+ "NitroStorage: Cannot clear secure storage: keychain is locked (errSecInteractionNotAllowed)"
445
+ );
431
446
  }
432
447
  throw std::runtime_error(
433
448
  std::string("NitroStorage: clearSecure failed with status ") + std::to_string(secStatus));
@@ -443,7 +458,10 @@ void IOSStorageAdapterCpp::clearSecure() {
443
458
  OSStatus bioStatus = SecItemDelete((__bridge CFDictionaryRef)biometricQuery);
444
459
  if (bioStatus != errSecSuccess && bioStatus != errSecItemNotFound) {
445
460
  if (bioStatus == errSecInteractionNotAllowed) {
446
- throw std::runtime_error("NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)");
461
+ throw taggedStorageError(
462
+ "keychain_locked",
463
+ "NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)"
464
+ );
447
465
  }
448
466
  throw std::runtime_error(
449
467
  std::string("NitroStorage: clearSecureBiometric failed with status ") + std::to_string(bioStatus));
@@ -533,7 +551,10 @@ void IOSStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, c
533
551
  if (error || !access) {
534
552
  if (access) CFRelease(access);
535
553
  if (error) CFRelease(error);
536
- throw std::runtime_error("NitroStorage: Failed to create biometric access control");
554
+ throw taggedStorageError(
555
+ "biometric_unavailable",
556
+ "NitroStorage: Failed to create biometric access control"
557
+ );
537
558
  }
538
559
 
539
560
  NSMutableDictionary* attrs = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
@@ -546,14 +567,16 @@ void IOSStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, c
546
567
  try {
547
568
  setSecure(key, *backup);
548
569
  } catch (const std::exception& restoreEx) {
549
- throw std::runtime_error(
570
+ throw taggedStorageError(
571
+ "biometric_unavailable",
550
572
  std::string("NitroStorage: Biometric set failed with status ") +
551
573
  std::to_string(addStatus) +
552
574
  " and previous value restoration also failed: " + restoreEx.what());
553
575
  }
554
576
  }
555
577
  if (addStatus == errSecInteractionNotAllowed) {
556
- throw std::runtime_error(
578
+ throw taggedStorageError(
579
+ "keychain_locked",
557
580
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
558
581
  "The item is not accessible until the device is unlocked."
559
582
  );
@@ -586,13 +609,17 @@ std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::s
586
609
  if (str) return std::string([str UTF8String]);
587
610
  }
588
611
  if (status == errSecInteractionNotAllowed) {
589
- throw std::runtime_error(
612
+ throw taggedStorageError(
613
+ "keychain_locked",
590
614
  "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
591
615
  "The item is not accessible until the device is unlocked."
592
616
  );
593
617
  }
594
618
  if (status == errSecUserCanceled || status == errSecAuthFailed) {
595
- throw std::runtime_error("NitroStorage: Biometric authentication failed");
619
+ throw taggedStorageError(
620
+ "authentication_required",
621
+ "NitroStorage: Biometric authentication failed"
622
+ );
596
623
  }
597
624
  return std::nullopt;
598
625
  }
@@ -640,7 +667,10 @@ void IOSStorageAdapterCpp::clearSecureBiometric() {
640
667
  OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query);
641
668
  if (status != errSecSuccess && status != errSecItemNotFound) {
642
669
  if (status == errSecInteractionNotAllowed) {
643
- throw std::runtime_error("NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)");
670
+ throw taggedStorageError(
671
+ "keychain_locked",
672
+ "NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)"
673
+ );
644
674
  }
645
675
  throw std::runtime_error(
646
676
  std::string("NitroStorage: clearSecureBiometric failed with status ") + std::to_string(status));