react-native-nitro-storage 0.14.0 → 0.15.0

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 (53) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +203 -4
  3. package/cpp/bindings/HybridStorage.cpp +21 -0
  4. package/cpp/bindings/HybridStorage.hpp +2 -0
  5. package/cpp/core/NativeStorageAdapter.hpp +6 -0
  6. package/docs/api-reference.md +63 -44
  7. package/docs/qa/agent-device-replay.md +41 -5
  8. package/ios/IOSStorageAdapterCpp.hpp +12 -0
  9. package/ios/IOSStorageAdapterCpp.mm +180 -2
  10. package/lib/commonjs/index.js +16 -1
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/index.web.js +10 -1
  13. package/lib/commonjs/index.web.js.map +1 -1
  14. package/lib/commonjs/shared.js +6 -0
  15. package/lib/commonjs/shared.js.map +1 -1
  16. package/lib/commonjs/storage-core.js +79 -0
  17. package/lib/commonjs/storage-core.js.map +1 -1
  18. package/lib/commonjs/testing.js +31 -2
  19. package/lib/commonjs/testing.js.map +1 -1
  20. package/lib/module/index.js +16 -1
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/index.web.js +10 -1
  23. package/lib/module/index.web.js.map +1 -1
  24. package/lib/module/shared.js +5 -0
  25. package/lib/module/shared.js.map +1 -1
  26. package/lib/module/storage-core.js +80 -1
  27. package/lib/module/storage-core.js.map +1 -1
  28. package/lib/module/testing.js +30 -1
  29. package/lib/module/testing.js.map +1 -1
  30. package/lib/typescript/Storage.nitro.d.ts +2 -0
  31. package/lib/typescript/Storage.nitro.d.ts.map +1 -1
  32. package/lib/typescript/index.d.ts +4 -1
  33. package/lib/typescript/index.d.ts.map +1 -1
  34. package/lib/typescript/index.web.d.ts +4 -1
  35. package/lib/typescript/index.web.d.ts.map +1 -1
  36. package/lib/typescript/shared.d.ts +1 -0
  37. package/lib/typescript/shared.d.ts.map +1 -1
  38. package/lib/typescript/storage-core.d.ts +28 -1
  39. package/lib/typescript/storage-core.d.ts.map +1 -1
  40. package/lib/typescript/storage-platform.d.ts +2 -0
  41. package/lib/typescript/storage-platform.d.ts.map +1 -1
  42. package/lib/typescript/testing.d.ts +9 -1
  43. package/lib/typescript/testing.d.ts.map +1 -1
  44. package/nitrogen/generated/shared/c++/HybridStorageSpec.cpp +2 -0
  45. package/nitrogen/generated/shared/c++/HybridStorageSpec.hpp +2 -0
  46. package/package.json +2 -2
  47. package/src/Storage.nitro.ts +2 -0
  48. package/src/index.ts +20 -0
  49. package/src/index.web.ts +14 -0
  50. package/src/shared.ts +10 -0
  51. package/src/storage-core.ts +121 -1
  52. package/src/storage-platform.ts +2 -0
  53. package/src/testing.ts +37 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  Breaking changes are always listed first in each release section.
8
8
 
9
+ ## [0.15.0] - 2026-10-07
10
+
11
+ ### Breaking changes
12
+
13
+ None.
14
+
15
+ ### Added
16
+
17
+ - `migrateSecureAccessControl(level, options?)` sets the default Secure access control level and rewrites existing Secure items so each iOS Keychain item moves to that accessibility class. It reads each stored string and writes it back unchanged, and it never deletes a value. It returns `{ migrated, locked, missing, skipped, failed, enumerationLocked }`: a key whose read or write fails with `keychain_locked` stays untouched and is listed in `locked`; other failures are listed in `failed` with their error code; biometric-protected items, including a key whose biometric check is `keychain_locked`, are listed in `skipped` and are not read. It first runs `flushSecureWrites()`. When that flush or the listing of the Secure keys fails with `keychain_locked`, nothing changes, including the default level, and `enumerationLocked` is `true`; any other listing error is thrown with state unchanged. `options.keys` limits the keys to rewrite. On Android and web it validates the level, records the default, and lists every Secure key in `skipped`. Each rewrite emits a change event with an unchanged value. It runs in JavaScript on the existing native surface, so it works with a 0.14 native binary.
18
+ - `storage.isProtectedDataAvailable()` reports whether iOS protected data is available, from a cached value that is never read on the calling thread. The state starts unknown and unknown reports `false`; the first read on the main thread usually lands within milliseconds and fires the listeners when data is available. A cold background launch inside the lock grace window can read `true` until the app next becomes active, so treat `keychain_locked` and the success of the actual read as the source of truth in background-only code. The value is re-read when the app becomes active or enters the foreground. `storage.onProtectedDataAvailable(listener)` calls `listener` each time protected data becomes available again and returns an unsubscribe function. In an iOS app extension, where `UIApplication` is unavailable, the value is `true`. On Android and web the value is always `true` and the listener never fires. Both need the 0.15.0 native module: rebuild the app. On an older native binary `isProtectedDataAvailable()` returns `true` and `onProtectedDataAvailable` returns a no-op unsubscribe instead of throwing. Both are also on the `react-native-nitro-storage/testing` entry, where availability is `true` until `setMockProtectedDataAvailable(false)` simulates a locked device (`true` fires the listeners; `resetNitroStorageMock()` restores `true` and drops every `onProtectedDataAvailable` listener).
19
+ - New exported types: `SecureAccessControlMigrationOptions` and `SecureAccessControlMigrationResult`.
20
+
21
+ ### Documentation
22
+
23
+ - README explains the `setAccessControl` lifecycle (per process, call before the first Secure write, writes only, no effect on Android), when to run `migrateSecureAccessControl`, the protected-data APIs, and how to avoid `keychain_locked` by deferring Secure reads until protected data is available rather than weakening the access class. It compares `AfterFirstUnlock` and `AfterFirstUnlockThisDeviceOnly`, including backup and device-transfer behavior, and notes that raw `storage.getString` has no `fallbackToCacheOnReadError`. It warns that an app extension sharing the Keychain access group can race the read-then-rewrite in `migrateSecureAccessControl`; run it while extensions are idle.
24
+
9
25
  ## [0.14.0] - 2026-10-03
10
26
 
11
27
  ### Breaking changes
package/README.md CHANGED
@@ -68,7 +68,7 @@ Nitro peer requirement: `react-native-nitro-modules >=0.37.0 <0.38.0`.
68
68
 
69
69
  | Tested on | Supported floor |
70
70
  | ------------------------------------------ | ----------------------------------- |
71
- | React Native `0.86.3` / Expo SDK `57.0.26` | React Native `0.77` / Expo SDK `53` |
71
+ | React Native `0.86.3` / Expo SDK `57.0.27` | React Native `0.77` / Expo SDK `53` |
72
72
 
73
73
  Nitro Storage supports React Native 0.77 or newer and Expo SDK 53 or newer,
74
74
  which is the minimum for Nitro Modules 0.37: its Android package does not
@@ -82,7 +82,7 @@ package exports are disabled (the default before React Native 0.79).
82
82
  The package gate uses React Native `0.86.3` and the Strict TypeScript API.
83
83
  `check:ci` also compiles the public source against React Native `0.87.0`'s
84
84
  Strict TypeScript API; this does not change the runtime baseline. The Expo
85
- example uses Expo SDK `57.0.26`, React Native
85
+ example uses Expo SDK `57.0.27`, React Native
86
86
  `0.86.3`, React `19.2.3`, and Nitro Modules `0.37.1`, which is the React Native
87
87
  version supported by that Expo SDK. Do not override Expo's React Native version.
88
88
 
@@ -91,7 +91,7 @@ before installing this package, then rebuild the native app so the generated
91
91
  Nitro bindings and native runtime use the same major-minor version:
92
92
 
93
93
  ```sh
94
- bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.14.0
94
+ bun add react-native-nitro-modules@0.37.1 react-native-nitro-storage@0.15.0
95
95
  bunx expo prebuild
96
96
  ```
97
97
 
@@ -578,6 +578,190 @@ storage instance and handle retry failures at the calling boundary. The observer
578
578
  reports the synchronous backend handoff; it cannot report a later Android
579
579
  `apply()` or IndexedDB persistence failure that the backend does not expose.
580
580
 
581
+ ### Access Control Lifecycle
582
+
583
+ `storage.setAccessControl(level)` sets the default `AccessControl` for Secure
584
+ writes. Follow these rules:
585
+
586
+ - It is per process and is not persisted. Call it on every launch, before the
587
+ first Secure write, for example at module scope in your app entry.
588
+ - It affects writes only. Reads never use the level, and an existing Keychain
589
+ item keeps its accessibility class until it is written again. On iOS every
590
+ Secure write, including an update of an existing item, sets
591
+ `kSecAttrAccessible`, so a rewrite moves the item to the current level.
592
+ - An item created with its own `accessControl` option uses that level instead
593
+ of the default.
594
+ - Android has no Keychain accessibility classes. `setAccessControl` only records
595
+ the JavaScript default there and changes no native behavior.
596
+ - Raw `storage.setString(key, value, StorageScope.Secure)` writes use the same
597
+ default level.
598
+
599
+ ```ts
600
+ import { AccessControl, storage } from "react-native-nitro-storage";
601
+
602
+ storage.setAccessControl(AccessControl.AfterFirstUnlockThisDeviceOnly);
603
+ ```
604
+
605
+ ### Migrating Existing Secure Items
606
+
607
+ Items written under `WhenUnlocked` stay unreadable while the device is locked,
608
+ even after you change the default. `migrateSecureAccessControl(level, options?)`
609
+ sets `level` as the default and rewrites each Secure item so the Keychain item
610
+ gets the new class. It reads the stored string and writes the same string back,
611
+ so values are never decoded, changed, or deleted.
612
+
613
+ ```ts
614
+ import {
615
+ AccessControl,
616
+ migrateSecureAccessControl,
617
+ } from "react-native-nitro-storage";
618
+
619
+ const result = migrateSecureAccessControl(
620
+ AccessControl.AfterFirstUnlockThisDeviceOnly,
621
+ );
622
+ // { migrated: string[]; locked: string[]; missing: string[];
623
+ // skipped: string[]; failed: { key: string; code?: StorageErrorCode }[];
624
+ // enumerationLocked: boolean }
625
+
626
+ if (result.locked.length > 0) {
627
+ // Retry only the locked keys once protected data is available.
628
+ migrateSecureAccessControl(AccessControl.AfterFirstUnlockThisDeviceOnly, {
629
+ keys: result.locked,
630
+ });
631
+ }
632
+ ```
633
+
634
+ - Run it while the device is unlocked, for example from a foreground user
635
+ action or after `storage.isProtectedDataAvailable()` returns `true`. Reading
636
+ or rewriting a `WhenUnlocked` item while locked fails with `keychain_locked`.
637
+ - It first runs `flushSecureWrites()` so queued Secure writes land before the
638
+ rewrite, then (without `options.keys`) lists the Secure keys. If the flush or
639
+ the listing fails with `keychain_locked`, it changes nothing, including the
640
+ default level, and returns `enumerationLocked: true` with empty lists (the
641
+ queued writes stay queued). Call it again once
642
+ protected data is available. Any other listing error is thrown and also
643
+ leaves the default level unchanged.
644
+ - A key whose read or write fails with `keychain_locked` is left unchanged and
645
+ listed in `locked`. Any other error leaves the key unchanged and lists it in
646
+ `failed` with its error code. The helper never deletes data.
647
+ - `options.keys` limits the work to specific keys. Without it, every Secure key
648
+ is processed. Biometric-protected items are listed in `skipped` and are never
649
+ read, because reading them can show a biometric prompt. A key whose biometric
650
+ check fails with `keychain_locked` is also listed in `skipped`, not `locked`:
651
+ biometric items are never migrated.
652
+ - The level becomes the default for later writes even when a key fails, so new
653
+ writes already use it.
654
+ - Items that you configured with their own `accessControl` option are rewritten
655
+ with `level` too. Pass `options.keys` to leave them out.
656
+ - On Android and web there is no accessibility class to change. The helper
657
+ validates the level, records the default, reads and writes nothing, and lists
658
+ every Secure key in `skipped`.
659
+ - Each rewrite is a native write, so storage listeners and observers receive a
660
+ change event for every migrated key, even though the value is unchanged.
661
+ - It reads each key and writes it back as separate steps. An app extension that
662
+ shares the Keychain access group and writes the same key between the two steps
663
+ can lose that write. Run the migration while extensions are idle.
664
+ - `migrateSecureAccessControl` is JavaScript only and works with any
665
+ 0.14-compatible native module. The protected-data APIs below need the 0.15.0
666
+ native module.
667
+ - Its result has `enumerationLocked: boolean` besides the key lists.
668
+
669
+ ### Protected Data Availability
670
+
671
+ On iOS, Keychain items in the `WhenUnlocked` classes (`WhenUnlocked`,
672
+ `WhenUnlockedThisDeviceOnly`, and `WhenPasscodeSetThisDeviceOnly`) can be read
673
+ only while protected data is available. Use these APIs to
674
+ wait instead of catching `keychain_locked`:
675
+
676
+ ```ts
677
+ import { storage } from "react-native-nitro-storage";
678
+
679
+ const available: boolean = storage.isProtectedDataAvailable();
680
+
681
+ const unsubscribe = storage.onProtectedDataAvailable(() => {
682
+ // Protected data became available again.
683
+ });
684
+ unsubscribe();
685
+ ```
686
+
687
+ - `isProtectedDataAvailable()` returns a cached value and never blocks. The
688
+ state starts unknown, and unknown reports `false`, so a locked device is never
689
+ reported as open. The first value is read from `UIApplication` on the main
690
+ thread: synchronously when the native module is created on the main thread,
691
+ otherwise asynchronously on the main queue, usually within a few
692
+ milliseconds. Until then the first calls can report `false` even when data is
693
+ available. When the read finds protected data available, listeners fire, so
694
+ gate on `onProtectedDataAvailable` instead of treating an early `false` as
695
+ final. iOS keeps the value current from the
696
+ `UIApplicationProtectedDataDidBecomeAvailable` and
697
+ `UIApplicationProtectedDataWillBecomeUnavailable` notifications, and
698
+ re-reads `UIApplication` when the app becomes active or enters the
699
+ foreground, because the available notification is not guaranteed when the
700
+ user unlocks inside the short window before protected data drops.
701
+ - Residual cases. A cold background launch that happens inside the grace
702
+ window after the device locks reads `true` until the app next becomes active,
703
+ because `UIApplication` still reports available during that window. A
704
+ background-only consumer can also keep a `false` cache after an unlock inside
705
+ the grace window until the next foreground. Treat `keychain_locked` and the
706
+ success of the actual read as the source of truth, not the cached value.
707
+ Before `UIApplication` exists (before `UIApplicationMain`), the state stays
708
+ unknown and reports `false` until the app becomes active.
709
+ - `onProtectedDataAvailable(listener)` calls `listener` each time protected data
710
+ changes from unavailable or unknown to available, and returns an unsubscribe
711
+ function. It does not call the listener on subscribe. Subscribe first, then
712
+ check `isProtectedDataAvailable()`, so a change between the two calls is not
713
+ missed.
714
+ - Both functions need the 0.15.0 native module. On an older native binary,
715
+ `isProtectedDataAvailable()` returns `true` and `onProtectedDataAvailable`
716
+ returns a no-op unsubscribe. Rebuild the app to get real values.
717
+ - In an iOS app extension `UIApplication` is not available. The module cannot
718
+ read the state there, so `isProtectedDataAvailable()` returns `true` and
719
+ `keychain_locked` remains the signal.
720
+ - Android and web always return `true`, and the listener never fires.
721
+
722
+ ### Handling keychain_locked
723
+
724
+ When a production app sees `keychain_locked` because Secure reads run while the
725
+ device is locked, for example during a background launch, defer those reads
726
+ until protected data is available. This keeps the default `WhenUnlocked`
727
+ protection.
728
+
729
+ ```ts
730
+ import { storage } from "react-native-nitro-storage";
731
+
732
+ export function whenProtectedDataAvailable(run: () => void): () => void {
733
+ let done = false;
734
+ const runOnce = () => {
735
+ if (done) return;
736
+ done = true;
737
+ unsubscribe();
738
+ run();
739
+ };
740
+ const unsubscribe = storage.onProtectedDataAvailable(runOnce);
741
+ if (storage.isProtectedDataAvailable()) runOnce();
742
+ return unsubscribe;
743
+ }
744
+ ```
745
+
746
+ Moving items to `AfterFirstUnlock` or `AfterFirstUnlockThisDeviceOnly` removes
747
+ the lock failure after the first unlock following a restart, but it weakens
748
+ protection: the items stay readable while the device is locked. Choose the
749
+ `ThisDeviceOnly` variant when items must not leave the device:
750
+
751
+ | Level | Readable while locked after first unlock | Included in backups and device transfer |
752
+ | -------------------------------- | ---------------------------------------- | --------------------------------------- |
753
+ | `WhenUnlocked` | No | Yes |
754
+ | `AfterFirstUnlock` | Yes | Yes |
755
+ | `AfterFirstUnlockThisDeviceOnly` | Yes | No |
756
+
757
+ Items in a `ThisDeviceOnly` class are not restored onto a new device from a
758
+ backup or device transfer, so the user must sign in again there.
759
+
760
+ Raw `storage.getString(key, StorageScope.Secure)` has no
761
+ `fallbackToCacheOnReadError`. It throws `keychain_locked` while the Keychain is
762
+ locked. Only typed items created with `fallbackToCacheOnReadError: true` return
763
+ their last cached value for that error.
764
+
581
765
  ## Batch Operations
582
766
 
583
767
  `getBatch()` preserves tuple value types, so IDEs infer each result from the
@@ -759,7 +943,10 @@ and Storybook run without native modules. Item subscribers and hooks re-render
759
943
  for Memory, Disk, and Secure writes. It does not model platform behaviour:
760
944
  there are no keychain locks, biometric prompts, access-control levels,
761
945
  coalesced native write timing, or web backends (the web backend functions are
762
- no-ops). Mock the package with it, or use it directly.
946
+ no-ops). Mock the package with it, or use it directly. To simulate a locked
947
+ device, call `setMockProtectedDataAvailable(false)`; `true` fires the
948
+ `onProtectedDataAvailable` listeners, and `resetNitroStorageMock()` restores
949
+ `true` and drops every `onProtectedDataAvailable` listener.
763
950
 
764
951
  ```ts
765
952
  import {
@@ -780,6 +967,18 @@ beforeEach(() => {
780
967
  const { storage, memoryItem } = createNitroStorageMock();
781
968
  ```
782
969
 
970
+ ```ts
971
+ import {
972
+ setMockProtectedDataAvailable,
973
+ storage,
974
+ } from "react-native-nitro-storage/testing";
975
+
976
+ const stop = storage.onProtectedDataAvailable(() => loadSession());
977
+ setMockProtectedDataAvailable(false); // storage.isProtectedDataAvailable() === false
978
+ setMockProtectedDataAvailable(true); // listener fires once
979
+ stop();
980
+ ```
981
+
783
982
  ## API
784
983
 
785
984
  The package exposes named `storage`, `createStorageItem`, the scoped item
@@ -535,6 +535,27 @@ void HybridStorage::clearSecureBiometric() {
535
535
  notifyListeners(static_cast<int>(Scope::Secure), kClearSentinelKey, std::nullopt);
536
536
  }
537
537
 
538
+ bool HybridStorage::isProtectedDataAvailable() {
539
+ ensureAdapter();
540
+ return runAdapterOperation(
541
+ [&] { return nativeAdapter_->isProtectedDataAvailable(); },
542
+ "Protected data availability");
543
+ }
544
+
545
+ std::function<void()> HybridStorage::onProtectedDataAvailable(const std::function<void()>& listener) {
546
+ ensureAdapter();
547
+ return runAdapterOperation(
548
+ [&] {
549
+ return nativeAdapter_->addProtectedDataAvailableListener([listener]() {
550
+ try {
551
+ listener();
552
+ } catch (...) {
553
+ }
554
+ });
555
+ },
556
+ "Protected data subscription");
557
+ }
558
+
538
559
  // --- Internal ---
539
560
 
540
561
  std::vector<HybridStorage::Listener> HybridStorage::copyListenersForScope(int scope) {
@@ -56,6 +56,8 @@ public:
56
56
  void deleteSecureBiometric(const std::string& key) override;
57
57
  bool hasSecureBiometric(const std::string& key) override;
58
58
  void clearSecureBiometric() override;
59
+ bool isProtectedDataAvailable() override;
60
+ std::function<void()> onProtectedDataAvailable(const std::function<void()>& listener) override;
59
61
 
60
62
  private:
61
63
  enum class Scope {
@@ -1,6 +1,7 @@
1
1
  #pragma once
2
2
 
3
3
  #include <cstddef>
4
+ #include <functional>
4
5
  #include <optional>
5
6
  #include <stdexcept>
6
7
  #include <string>
@@ -58,6 +59,11 @@ public:
58
59
  virtual void deleteSecureBiometric(const std::string& key) = 0;
59
60
  virtual bool hasSecureBiometric(const std::string& key) = 0;
60
61
  virtual void clearSecureBiometric() = 0;
62
+
63
+ virtual bool isProtectedDataAvailable() { return true; }
64
+ virtual std::function<void()> addProtectedDataAvailableListener(std::function<void()>) {
65
+ return []() {};
66
+ }
61
67
  };
62
68
 
63
69
  } // namespace NitroStorage
@@ -112,50 +112,52 @@ See [react-hooks.md](react-hooks.md).
112
112
 
113
113
  `storage` exposes raw and cross-item utilities:
114
114
 
115
- | Method | Purpose |
116
- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
117
- | `clear(scope, options?)` | Clear one scope, optionally preserving selected keys. |
118
- | `clearAll()` | Clear Memory, Disk, and Secure scopes. |
119
- | `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. |
120
- | `clearGroup(group)` | Remove registered items in a group across their scopes. |
121
- | `getGroupItems(group)` | List registered items in a group. |
122
- | `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry. |
123
- | `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions. |
124
- | `getRegisteredKeys()` | List registered `(scope, key)` definitions. |
125
- | `subscribe(scope, listener)` | Subscribe to raw scope-level change events. |
126
- | `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key. |
127
- | `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes. |
128
- | `subscribeNamespace(namespace, scope, listener)` | Subscribe to raw events for `namespace:` keys. |
129
- | `setEventObserver(observer, options?)` | Receive all change events for devtools or logging. Secure values are redacted by default. |
130
- | `clearBiometric()` | Clear biometric Secure entries. |
131
- | `has(key, scope)` | Check for a raw key. |
132
- | `getAllKeys(scope)` | List raw keys. |
133
- | `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix. |
134
- | `getByPrefix(prefix, scope)` | Read raw string values by prefix. |
135
- | `getAll(scope)` | Read all raw string values in a scope. |
136
- | `size(scope)` | Return approximate scope entry count. |
137
- | `setAccessControl(accessControl)` | Set the default Secure access control level. |
138
- | `setSecureWritesAsync(enabled)` | Toggle Android secure writes between sync and async modes. |
139
- | `setDiskWritesAsync(enabled)` | Toggle coalesced Disk write behavior. |
140
- | `flushDiskWrites()` | Flush pending Disk writes. |
141
- | `flushSecureWrites()` | Drain queued Secure writes into the backend; not an Android `apply()` persistence barrier. |
142
- | `setScheduledFlushErrorObserver(observer)` | Observe scheduled Disk/Secure flush failures; pass `undefined` to restore uncaught failures. Explicit flush calls still throw. |
143
- | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
144
- | `setMetricsObserver(observer)` | Receive operation timing events. |
145
- | `getMetricsSnapshot()` | Read aggregated metrics. |
146
- | `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
147
- | `resetMetrics()` | Clear metrics counters. |
148
- | `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
149
- | `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
150
- | `getSecurityCapabilities()` | Read secure backend capability metadata. |
151
- | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
152
- | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
153
- | `getString(key, scope)` | Read a raw string. |
154
- | `setString(key, value, scope)` | Write a raw string. |
155
- | `deleteString(key, scope)` | Remove a raw key. |
156
- | `export(scope, options?)` | Snapshot raw strings from one scope. Secure scope requires explicit unsafe opt-in. |
157
- | `exportSecureUnsafe()` | Snapshot raw Secure strings for short-lived migration workflows. |
158
- | `import(data, scope)` | Bulk import raw strings. |
115
+ | Method | Purpose |
116
+ | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
117
+ | `clear(scope, options?)` | Clear one scope, optionally preserving selected keys. |
118
+ | `clearAll()` | Clear Memory, Disk, and Secure scopes. |
119
+ | `clearNamespace(namespace, scope)` | Remove keys under `namespace:`. |
120
+ | `clearGroup(group)` | Remove registered items in a group across their scopes. |
121
+ | `getGroupItems(group)` | List registered items in a group. |
122
+ | `subscribeExpired(scope, listener)` | Receive item events caused by TTL expiry. |
123
+ | `findDuplicateKeys()` | Find duplicate registered `(scope, key)` definitions. |
124
+ | `getRegisteredKeys()` | List registered `(scope, key)` definitions. |
125
+ | `subscribe(scope, listener)` | Subscribe to raw scope-level change events. |
126
+ | `subscribeKey(scope, key, listener)` | Subscribe to raw events for one key. |
127
+ | `subscribePrefix(scope, prefix, listener)` | Subscribe to raw events for matching key prefixes. |
128
+ | `subscribeNamespace(namespace, scope, listener)` | Subscribe to raw events for `namespace:` keys. |
129
+ | `setEventObserver(observer, options?)` | Receive all change events for devtools or logging. Secure values are redacted by default. |
130
+ | `clearBiometric()` | Clear biometric Secure entries. |
131
+ | `has(key, scope)` | Check for a raw key. |
132
+ | `getAllKeys(scope)` | List raw keys. |
133
+ | `getKeysByPrefix(prefix, scope)` | List raw keys with a prefix. |
134
+ | `getByPrefix(prefix, scope)` | Read raw string values by prefix. |
135
+ | `getAll(scope)` | Read all raw string values in a scope. |
136
+ | `size(scope)` | Return approximate scope entry count. |
137
+ | `setAccessControl(accessControl)` | Set the default Secure access control level. |
138
+ | `isProtectedDataAvailable()` | Read cached iOS protected-data availability; may report `false` briefly until the first read lands. Android, web, and 0.14 native binaries return `true`. |
139
+ | `onProtectedDataAvailable(listener)` | Call `listener` when protected data becomes available (including the first known-available read); returns an unsubscribe function. Android and web never fire. |
140
+ | `setSecureWritesAsync(enabled)` | Toggle Android secure writes between sync and async modes. |
141
+ | `setDiskWritesAsync(enabled)` | Toggle coalesced Disk write behavior. |
142
+ | `flushDiskWrites()` | Flush pending Disk writes. |
143
+ | `flushSecureWrites()` | Drain queued Secure writes into the backend; not an Android `apply()` persistence barrier. |
144
+ | `setScheduledFlushErrorObserver(observer)` | Observe scheduled Disk/Secure flush failures; pass `undefined` to restore uncaught failures. Explicit flush calls still throw. |
145
+ | `setKeychainAccessGroup(group)` | Configure iOS Keychain access group. |
146
+ | `setMetricsObserver(observer)` | Receive operation timing events. |
147
+ | `getMetricsSnapshot()` | Read aggregated metrics. |
148
+ | `getScopedMetricsSnapshot()` | Read metrics grouped by storage scope. |
149
+ | `resetMetrics()` | Clear metrics counters. |
150
+ | `getCacheMetrics()` | Read raw-cache hits, misses, live entries, and estimated bytes. |
151
+ | `getCapabilities()` | Read runtime storage capabilities. Native `backend.disk` is `"sqlite"`. |
152
+ | `getSecurityCapabilities()` | Read secure backend capability metadata. |
153
+ | `getSecureMetadata(key)` | Read secure metadata for one key without returning its value. |
154
+ | `getAllSecureMetadata()` | Read secure metadata for all secure keys without values. |
155
+ | `getString(key, scope)` | Read a raw string. |
156
+ | `setString(key, value, scope)` | Write a raw string. |
157
+ | `deleteString(key, scope)` | Remove a raw key. |
158
+ | `export(scope, options?)` | Snapshot raw strings from one scope. Secure scope requires explicit unsafe opt-in. |
159
+ | `exportSecureUnsafe()` | Snapshot raw Secure strings for short-lived migration workflows. |
160
+ | `import(data, scope)` | Bulk import raw strings. |
159
161
 
160
162
  Raw string APIs bypass item serialization and validation. Prefer `StorageItem<T>` unless you are migrating, exporting/importing, or writing a custom integration.
161
163
 
@@ -256,6 +258,12 @@ Migration versions are tracked per scope.
256
258
  Migration callbacks must be synchronous. An async callback is rejected without
257
259
  advancing its version, and the next migration run retries that step.
258
260
 
261
+ ## Secure Access Control Migration
262
+
263
+ | API | Purpose |
264
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
265
+ | `migrateSecureAccessControl(level, options?)` | Set the default Secure access control level and rewrite each Secure item so its iOS Keychain item moves to that class. Returns `{ migrated, locked, missing, skipped, failed, enumerationLocked }`. It runs `flushSecureWrites()` first. `enumerationLocked: true` means that flush or listing the Secure keys failed with `keychain_locked`: nothing was changed, including the default level, and queued writes stay queued; retry later. Other enumeration errors are thrown with state unchanged. `options.keys` limits the keys. JavaScript only; Android and web record the default and list every key in `skipped`. |
266
+
259
267
  ## Secure Auth Storage
260
268
 
261
269
  ```ts
@@ -319,6 +327,15 @@ function as typed no-ops for shared code.
319
327
 
320
328
  See [web-backends.md](web-backends.md).
321
329
 
330
+ ## Testing Entry
331
+
332
+ `react-native-nitro-storage/testing` exports an in-memory implementation of the public surface.
333
+
334
+ | API | Description |
335
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
336
+ | `setMockProtectedDataAvailable(available)` | Set the mock protected-data availability (default `true`). `false` simulates a locked device; `true` after `false` fires the `onProtectedDataAvailable` listeners. Throws a `TypeError` for a non-boolean. |
337
+ | `resetNitroStorageMock()` | Clear all mock data and observers, restore protected-data availability to `true`, and drop every `onProtectedDataAvailable` listener. |
338
+
322
339
  ## Enums
323
340
 
324
341
  ```ts
@@ -348,6 +365,8 @@ enum BiometricLevel {
348
365
  Common public types:
349
366
 
350
367
  - `Storage`
368
+ - `SecureAccessControlMigrationOptions`
369
+ - `SecureAccessControlMigrationResult`
351
370
  - `Validator<T>`
352
371
  - `ExpirationConfig`
353
372
  - `StorageItem<T>`
@@ -37,8 +37,42 @@ The runner supplies a fresh UUID as `RUN_ID` to each replay. The persistence
37
37
  flow writes one `RUN_ID`-scoped Disk key, closes and relaunches the installed
38
38
  app with the same ID, compares the stored value with the expected value, and
39
39
  deletes that key. Other smoke and lab cases use reserved QA keys and clean only
40
- those keys. The default replay never clears a whole storage scope or the app's
41
- data.
40
+ those keys. Only the final `clear-all` suite clears whole scopes.
41
+
42
+ The `clear-all` suite (`e2e/qa-clear-all.ad`) seeds one reserved key in Memory,
43
+ Disk, and Secure, then calls `storage.clearAll()`. This wipes all example
44
+ storage in every scope on the target install, not only QA keys. It is the last
45
+ suite in the manifest, and the runner passes suites to `agent-device test` in
46
+ manifest order, so it runs after every other suite. Run it only on a disposable
47
+ example install. Use `--flow` to skip it when the install holds data you need.
48
+
49
+ The `api-extended` suite (`e2e/qa-api-extended.ad`) opens a run-on-load screen
50
+ and asserts its results through the on-screen `e2e-ext-results` label, which
51
+ joins one `;`-terminated token per check. It covers item `merge` and
52
+ `setOrDelete`, Set `toggle` and `values`, `clearGroup`, TTL `onExpired` and
53
+ `subscribeExpired`, Secure metadata and export inventory, default access
54
+ control, coalesced Secure writes, the `invalid_key` error, scoped metrics,
55
+ native Disk key events, the React hooks, and `createSecureAuthStorage` TTL. It
56
+ uses reserved `__e2e_ext_`, `__e2e_inv_`, and `__e2e_auth` keys and restores the
57
+ default access control to `WhenUnlocked` after each access-control check. Its
58
+ passcode access-control row is a gating probe for later iOS biometric work: the
59
+ flow asserts only that a `passcode-acl=` result rendered. A device without a
60
+ passcode can reject that write, and the row is not a pass for passcode-protected
61
+ storage.
62
+
63
+ The `biometric-ios` suite (`e2e/qa-biometric-ios.ad`) runs only with
64
+ `--platform ios`; its manifest entry sets `platforms: ["ios"]`, the runner skips
65
+ it for Android, and `--flow biometric-ios` with `--platform android` fails before
66
+ any device command. It enrolls simulator Face ID, opens the run-on-load
67
+ `e2e-biometric` route with `RUN_ID`, and writes one `__e2e_bio_<RUN_ID>` item
68
+ with `biometric: true` and the default level. The `e2e-biometric-results` label
69
+ then shows the seed and metadata tokens and `bio:read=prompting;`. After a fixed
70
+ 1500 ms wait, `settings faceid match` resolves the blocked read, and the flow
71
+ expects `bio:read=value-matched;` and `bio:cleanup=missing;` before it unenrolls
72
+ Face ID. Cleanup calls `storage.clearBiometric()`, which removes every
73
+ biometric Secure item in the example install. This is simulator evidence only,
74
+ not Secure Enclave hardware evidence. Android biometrics and iOS nonmatch or
75
+ cancel outcomes remain pending.
42
76
 
43
77
  Each run uses a unique session and an artifact directory under the OS temporary
44
78
  directory. `agent-device test` closes each attempt session itself, including
@@ -50,7 +84,8 @@ itself is not sufficient. Smoke and integrity rows are asserted through the
50
84
  on-screen `smoke-results` and `e2e-integrity-results` labels because
51
85
  agent-device `.ad` waits only see on-screen elements. The keychain suite runs one no-prompt Secure
52
86
  roundtrip and keeps biometric, lock, corruption, and hardware-backed checks
53
- explicitly pending. The smoke test's constructed `storage_full` error is read
87
+ explicitly pending; only the iOS simulator Face ID match path is asserted, by
88
+ `biometric-ios`. The smoke test's constructed `storage_full` error is read
54
89
  through the public `/testing` entrypoint; it proves error-code classification
55
90
  at that test adapter boundary only.
56
91
 
@@ -62,8 +97,9 @@ Disk, and Secure.
62
97
 
63
98
  Native replay rows identify the public runtime platform and backend capability,
64
99
  then assert values returned through the default package entrypoint. This
65
- supports installed-app Disk and Secure behavior claims for that target. It does
66
- not prove hardware-backed keys, biometrics, locked-device behavior, corruption
100
+ supports installed-app Disk and Secure behavior claims for that target. Apart
101
+ from the iOS simulator Face ID match in `biometric-ios`, it does not prove
102
+ hardware-backed keys, biometrics, locked-device behavior, corruption
67
103
  recovery, or a native Disk/Secure write failure. Those checks need controlled
68
104
  native or hardware prerequisites and remain pending in the coverage manifest.
69
105
  Unit tests for an injected adapter boundary are useful logic evidence, but they
@@ -2,6 +2,9 @@
2
2
 
3
3
  #include "../core/NativeStorageAdapter.hpp"
4
4
  #include <atomic>
5
+ #include <functional>
6
+ #include <optional>
7
+ #include <memory>
5
8
  #include <mutex>
6
9
  #include <unordered_set>
7
10
 
@@ -9,7 +12,10 @@ namespace NitroStorage {
9
12
 
10
13
  class IOSStorageAdapterCpp : public NativeStorageAdapter {
11
14
  public:
15
+ using ProtectedDataReader = std::function<std::optional<bool>()>;
16
+
12
17
  IOSStorageAdapterCpp();
18
+ explicit IOSStorageAdapterCpp(ProtectedDataReader protectedDataReader);
13
19
  ~IOSStorageAdapterCpp() override;
14
20
 
15
21
  void setDisk(const std::string& key, const std::string& value) override;
@@ -48,7 +54,13 @@ public:
48
54
  bool hasSecureBiometric(const std::string& key) override;
49
55
  void clearSecureBiometric() override;
50
56
 
57
+ bool isProtectedDataAvailable() override;
58
+ std::function<void()> addProtectedDataAvailableListener(std::function<void()> listener) override;
59
+
51
60
  private:
61
+ struct ProtectedDataState;
62
+ std::shared_ptr<ProtectedDataState> protectedData_;
63
+
52
64
  int accessControlLevel_ = 0;
53
65
  std::string keychainAccessGroup_;
54
66
  mutable std::mutex secureKeysMutex_;