react-native-nitro-storage 0.6.0 → 0.8.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 (108) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +36 -12
  3. package/SECURITY.md +2 -2
  4. package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +225 -76
  5. package/app.plugin.js +2 -0
  6. package/cpp/bindings/HybridStorage.cpp +4 -12
  7. package/docs/api-reference.md +11 -1
  8. package/docs/batch-transactions-migrations.md +8 -2
  9. package/docs/benchmarks.md +19 -15
  10. package/docs/secure-storage.md +4 -0
  11. package/docs/web-backends.md +18 -1
  12. package/ios/IOSStorageAdapterCpp.mm +162 -17
  13. package/lib/commonjs/capabilities.js +26 -0
  14. package/lib/commonjs/capabilities.js.map +1 -0
  15. package/lib/commonjs/core/durability.js +137 -0
  16. package/lib/commonjs/core/durability.js.map +1 -0
  17. package/lib/commonjs/core/metrics.js +91 -0
  18. package/lib/commonjs/core/metrics.js.map +1 -0
  19. package/lib/commonjs/index.js +47 -22
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/index.web.js +118 -66
  22. package/lib/commonjs/index.web.js.map +1 -1
  23. package/lib/commonjs/indexeddb-backend.js +73 -23
  24. package/lib/commonjs/indexeddb-backend.js.map +1 -1
  25. package/lib/commonjs/internal.js +27 -4
  26. package/lib/commonjs/internal.js.map +1 -1
  27. package/lib/commonjs/shared.js +2 -2
  28. package/lib/commonjs/shared.js.map +1 -1
  29. package/lib/commonjs/storage-core.js +217 -233
  30. package/lib/commonjs/storage-core.js.map +1 -1
  31. package/lib/commonjs/storage-platform.js +6 -0
  32. package/lib/commonjs/storage-platform.js.map +1 -0
  33. package/lib/commonjs/storage-runtime.js +3 -21
  34. package/lib/commonjs/storage-runtime.js.map +1 -1
  35. package/lib/commonjs/testing.js +19 -4
  36. package/lib/commonjs/testing.js.map +1 -1
  37. package/lib/commonjs/web-backend-contract.js +27 -0
  38. package/lib/commonjs/web-backend-contract.js.map +1 -0
  39. package/lib/module/capabilities.js +21 -0
  40. package/lib/module/capabilities.js.map +1 -0
  41. package/lib/module/core/durability.js +133 -0
  42. package/lib/module/core/durability.js.map +1 -0
  43. package/lib/module/core/metrics.js +87 -0
  44. package/lib/module/core/metrics.js.map +1 -0
  45. package/lib/module/index.js +48 -23
  46. package/lib/module/index.js.map +1 -1
  47. package/lib/module/index.web.js +103 -64
  48. package/lib/module/index.web.js.map +1 -1
  49. package/lib/module/indexeddb-backend.js +73 -23
  50. package/lib/module/indexeddb-backend.js.map +1 -1
  51. package/lib/module/internal.js +24 -3
  52. package/lib/module/internal.js.map +1 -1
  53. package/lib/module/shared.js +2 -2
  54. package/lib/module/shared.js.map +1 -1
  55. package/lib/module/storage-core.js +218 -234
  56. package/lib/module/storage-core.js.map +1 -1
  57. package/lib/module/storage-platform.js +4 -0
  58. package/lib/module/storage-platform.js.map +1 -0
  59. package/lib/module/storage-runtime.js +3 -21
  60. package/lib/module/storage-runtime.js.map +1 -1
  61. package/lib/module/testing.js +5 -3
  62. package/lib/module/testing.js.map +1 -1
  63. package/lib/module/web-backend-contract.js +22 -0
  64. package/lib/module/web-backend-contract.js.map +1 -0
  65. package/lib/typescript/capabilities.d.ts +11 -0
  66. package/lib/typescript/capabilities.d.ts.map +1 -0
  67. package/lib/typescript/core/durability.d.ts +27 -0
  68. package/lib/typescript/core/durability.d.ts.map +1 -0
  69. package/lib/typescript/core/metrics.d.ts +12 -0
  70. package/lib/typescript/core/metrics.d.ts.map +1 -0
  71. package/lib/typescript/index.d.ts +11 -3
  72. package/lib/typescript/index.d.ts.map +1 -1
  73. package/lib/typescript/index.web.d.ts +14 -5
  74. package/lib/typescript/index.web.d.ts.map +1 -1
  75. package/lib/typescript/indexeddb-backend.d.ts.map +1 -1
  76. package/lib/typescript/internal.d.ts +3 -0
  77. package/lib/typescript/internal.d.ts.map +1 -1
  78. package/lib/typescript/shared.d.ts +2 -1
  79. package/lib/typescript/shared.d.ts.map +1 -1
  80. package/lib/typescript/storage-core.d.ts +14 -4
  81. package/lib/typescript/storage-core.d.ts.map +1 -1
  82. package/lib/typescript/storage-events.d.ts +2 -2
  83. package/lib/typescript/storage-events.d.ts.map +1 -1
  84. package/lib/typescript/storage-platform.d.ts +12 -0
  85. package/lib/typescript/storage-platform.d.ts.map +1 -0
  86. package/lib/typescript/storage-runtime.d.ts.map +1 -1
  87. package/lib/typescript/testing.d.ts +17 -3
  88. package/lib/typescript/testing.d.ts.map +1 -1
  89. package/lib/typescript/web-backend-contract.d.ts +10 -0
  90. package/lib/typescript/web-backend-contract.d.ts.map +1 -0
  91. package/lib/typescript/web-storage-backend.d.ts +1 -1
  92. package/lib/typescript/web-storage-backend.d.ts.map +1 -1
  93. package/package.json +6 -6
  94. package/src/capabilities.ts +36 -0
  95. package/src/core/durability.ts +170 -0
  96. package/src/core/metrics.ts +133 -0
  97. package/src/index.ts +67 -42
  98. package/src/index.web.ts +159 -88
  99. package/src/indexeddb-backend.ts +92 -22
  100. package/src/internal.ts +51 -5
  101. package/src/shared.ts +7 -6
  102. package/src/storage-core.ts +336 -287
  103. package/src/storage-events.ts +3 -3
  104. package/src/storage-platform.ts +16 -0
  105. package/src/storage-runtime.ts +13 -47
  106. package/src/testing.ts +14 -7
  107. package/src/web-backend-contract.ts +34 -0
  108. package/src/web-storage-backend.ts +1 -1
@@ -80,7 +80,7 @@ const snapshot = storage.export(StorageScope.Disk);
80
80
  storage.import(snapshot, StorageScope.Disk);
81
81
  ```
82
82
 
83
- For Memory scope, import is atomic: all keys are written before listeners fire. For Disk and Secure, import delegates to native or web batch paths.
83
+ For Memory scope, import is atomic: all keys are written before listeners fire. Disk and Secure imports flush pending coalesced writes first, so the imported snapshot cannot be overwritten by an earlier pending flush, then delegate to native or web batch paths.
84
84
 
85
85
  Secure exports contain raw secret values. `storage.export(StorageScope.Secure)` requires `{ includeSecureValues: true }`; `storage.exportSecureUnsafe()` is the explicit equivalent. Do not log Secure exports or include them in diagnostics, analytics, crash reports, or support bundles.
86
86
 
@@ -124,7 +124,9 @@ Transaction context methods:
124
124
  - `setItem(item, value)`
125
125
  - `removeItem(item)`
126
126
 
127
- If the callback throws, Nitro Storage restores the keys it changed during that transaction.
127
+ If the callback throws, Nitro Storage restores the keys it changed during that transaction and emits exactly one typed `rollback` batch event so observers and the global event observer can react to the rollback.
128
+
129
+ Memory-scope batch removes are atomic: all keys are deleted before listeners fire, and scope, key, and prefix subscribers receive a single `removeBatch` event.
128
130
 
129
131
  ## Migrations
130
132
 
@@ -157,6 +159,8 @@ migrateToLatest(StorageScope.Disk);
157
159
 
158
160
  Migration context methods work with raw strings. Use item serializers manually when migrating structured data.
159
161
 
162
+ Each migration step runs inside its own transaction together with the version marker write. A step that throws rolls back both its data changes and the version marker, so the scope stays on the last completed version and rerunning `migrateToLatest()` retries deterministically.
163
+
160
164
  ```ts
161
165
  registerMigration(3, (ctx) => {
162
166
  const raw = ctx.getRaw("settings");
@@ -198,3 +202,5 @@ const didWrite = themeItem.setIfVersion(
198
202
  ```
199
203
 
200
204
  `setIfVersion()` returns `false` if another write changed the item after `getWithVersion()`.
205
+
206
+ Compare-and-set is optimistic: the version is read, compared, and written without backend-level atomicity. Two writers on different runtimes can both pass the check between their reads and writes, so use it for single-runtime coordination or last-writer-wins-tolerant flows. There is no native compare-and-swap primitive.
@@ -2,36 +2,40 @@
2
2
 
3
3
  Benchmarks are release checks, not product promises. Use them to catch regressions on the local machine and CI image used by this repo.
4
4
 
5
- Run:
5
+ Run from the repo root:
6
6
 
7
7
  ```sh
8
- bun run benchmark -- --filter=react-native-nitro-storage
8
+ bun run build
9
+ bun run benchmark
9
10
  ```
10
11
 
11
- The benchmark script checks representative synchronous read/write paths and fails when results drift beyond the configured threshold.
12
+ ## Scope: Web Only
13
+
14
+ `benchmark` loads `lib/commonjs/index.web.js` and measures the web entry against the localStorage backend. The `disk:` and `secure:` labels describe web scopes, not native Disk or Secure storage.
15
+
16
+ Native Disk/Secure baselines require a device or simulator run and are not part of this gate. Do not compare these numbers against native storage.
12
17
 
13
18
  ## Interpreting Results
14
19
 
15
20
  - Compare results on the same machine and Node/Bun version.
16
- - Treat large deltas as a prompt to inspect recent storage-runtime, serialization, native bridge, or cache changes.
21
+ - Treat large deltas as a prompt to inspect recent storage-runtime, serialization, cache, or event changes.
17
22
  - Do not compare web backend numbers against native secure storage numbers; they measure different systems.
18
- - Secure storage performance depends on platform state, device lock state, biometric prompts, and Keystore/Keychain behavior.
19
23
 
20
24
  ## Release Checklist
21
25
 
22
26
  Before publishing:
23
27
 
24
28
  ```sh
25
- bun run lint -- --filter=react-native-nitro-storage
26
- bun run format:check -- --filter=react-native-nitro-storage
27
- bun run typecheck -- --filter=react-native-nitro-storage
28
- bun run test:types -- --filter=react-native-nitro-storage
29
- bun run test -- --filter=react-native-nitro-storage
30
- bun run test:cpp -- --filter=react-native-nitro-storage
31
- bun run build -- --filter=react-native-nitro-storage
32
- bun run benchmark -- --filter=react-native-nitro-storage
33
- (cd packages/react-native-nitro-storage && bun run check:pack)
34
- npm publish --dry-run
29
+ bun run codegen:check
30
+ bun run lint:check
31
+ bun run format:check
32
+ bun run typecheck
33
+ bun run test:types
34
+ bun run test
35
+ bun run test:cpp
36
+ bun run build
37
+ bun run benchmark
38
+ bun run --cwd packages/react-native-nitro-storage check:pack
35
39
  ```
36
40
 
37
41
  Keep the dry-publish output in the release notes when validating a version locally.
@@ -47,6 +47,8 @@ export const recoveryCodeItem = createStorageItem<string>({
47
47
 
48
48
  `BiometricLevel.BiometryOnly` does not allow passcode fallback. Use `BiometricLevel.BiometryOrPasscode` when passcode fallback is acceptable.
49
49
 
50
+ On Android 11 and newer, the two levels use separate Android Keystore keys with distinct allowed authenticators. Android 10 and older support `BiometryOrPasscode`; `BiometryOnly` reports `biometric_unavailable` because the older Keystore API cannot enforce that distinction safely for this storage backend. Android authorization remains valid for a 30-second window after successful authentication.
51
+
50
52
  ## Access Control
51
53
 
52
54
  `accessControl` maps to platform accessibility rules where available.
@@ -141,6 +143,8 @@ Android secure storage uses encrypted SharedPreferences. Restored encrypted pref
141
143
 
142
144
  - `NitroStorageSecure.xml`
143
145
  - `NitroStorageBiometric.xml`
146
+ - `NitroStorageBiometricOrPasscode.xml`
147
+ - `NitroStorageBiometricOnly.xml`
144
148
 
145
149
  If you disable `configureAndroidBackup` or maintain custom Android backup XML, add equivalent exclusions for both cloud backup and device transfer.
146
150
 
@@ -38,7 +38,7 @@ Optional methods improve performance and observability:
38
38
 
39
39
  `subscribe(listener)` should report `{ key, newValue }` changes. Use `key: null` when the whole backend is cleared.
40
40
 
41
- `close()` should release backend-owned resources such as database handles or broadcast channels. Nitro Storage calls it when a configured Disk or Secure backend is replaced.
41
+ `close()` should release backend-owned resources such as database handles or broadcast channels. A replaced backend is retired instead of closed on the spot: `flushWebStorageBackends()` flushes it and then closes it, so queued async writes can commit before the connection goes away. Backends without `flush()` are closed immediately on replacement, and a retired backend whose flush fails stays retired for a later explicit retry.
42
42
 
43
43
  ## Disk Backend
44
44
 
@@ -100,6 +100,23 @@ Reads are synchronous because they are served from memory after initial load. Wr
100
100
 
101
101
  The IndexedDB backend exposes `close()` and rejects later synchronous operations after it is closed.
102
102
 
103
+ ### Persistence Lifecycle
104
+
105
+ IndexedDB transactions cannot block a page unload, so in-flight writes may be aborted when the page closes. The backend mitigates this by starting a flush on `pagehide` and on `visibilitychange` (hidden), and `flush()` awaits every pending transaction. When persistence fails, `flush()` throws an error that names the affected keys, and `onError` receives each individual failure.
106
+
107
+ ```ts
108
+ const backend = await createIndexedDBBackend("app-secure", "keyvalue", {
109
+ onError: (error) => {
110
+ console.error("IndexedDB secure storage failed", error);
111
+ },
112
+ });
113
+
114
+ // Before assertions, navigation, or lifecycle boundaries:
115
+ await flushWebStorageBackends();
116
+ ```
117
+
118
+ Treat IndexedDB persistence as best-effort under abrupt termination: keep a durable copy of critical values elsewhere if they must survive an immediate page close.
119
+
103
120
  ## Cross-tab Updates
104
121
 
105
122
  The IndexedDB backend uses `BroadcastChannel` when available. Other tabs receive cache invalidation events and update their in-memory copy.
@@ -8,6 +8,8 @@ namespace NitroStorage {
8
8
  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
+ static NSString* const kLegacyDiskKeysRegistryKey =
12
+ @"__nitro_storage_legacy_disk_keys__";
11
13
 
12
14
  static std::runtime_error taggedStorageError(const char* code, const std::string& message) {
13
15
  return std::runtime_error(
@@ -15,11 +17,69 @@ static std::runtime_error taggedStorageError(const char* code, const std::string
15
17
  );
16
18
  }
17
19
 
20
+ // Unknown keychain statuses are surfaced untagged (matching Android's
21
+ // cause-based wrapping), except errSecNotAvailable which means the keychain
22
+ // is unavailable until the device is first unlocked — a locked condition.
23
+ static std::runtime_error keychainStatusError(OSStatus status, const std::string& operation) {
24
+ if (status == errSecNotAvailable) {
25
+ return taggedStorageError(
26
+ "keychain_locked",
27
+ std::string("NitroStorage: ") + operation + " failed: keychain is unavailable until the device is unlocked (errSecNotAvailable)."
28
+ );
29
+ }
30
+ return std::runtime_error(
31
+ std::string("NitroStorage: ") + operation + " failed with status " + std::to_string(status)
32
+ );
33
+ }
34
+
18
35
  static NSUserDefaults* NitroDiskDefaults() {
19
36
  static NSUserDefaults* defaults = [[NSUserDefaults alloc] initWithSuiteName:kDiskSuiteName];
20
37
  return defaults ?: [NSUserDefaults standardUserDefaults];
21
38
  }
22
39
 
40
+ // --- Legacy disk key registry ---
41
+ // Versions before the suite domain stored Disk values in standardUserDefaults.
42
+ // The registry records legacy keys observed through the storage API so that
43
+ // clearDisk can remove both stores and deleted legacy values cannot reappear.
44
+ // Only keys observed through the storage API are ever registered; the host
45
+ // app's own standard defaults keys are never enumerated or touched.
46
+
47
+ static NSSet<NSString*>* registeredLegacyDiskKeys() {
48
+ NSArray* stored = [NitroDiskDefaults() stringArrayForKey:kLegacyDiskKeysRegistryKey];
49
+ return stored ? [NSSet setWithArray:stored] : [NSSet set];
50
+ }
51
+
52
+ static void persistLegacyDiskKeys(NSSet<NSString*>* keys) {
53
+ if (keys.count == 0) {
54
+ [NitroDiskDefaults() removeObjectForKey:kLegacyDiskKeysRegistryKey];
55
+ return;
56
+ }
57
+ [NitroDiskDefaults() setObject:[keys allObjects] forKey:kLegacyDiskKeysRegistryKey];
58
+ }
59
+
60
+ static void registerLegacyDiskKey(NSString* key) {
61
+ NSMutableSet* keys = [registeredLegacyDiskKeys() mutableCopy];
62
+ [keys addObject:key];
63
+ persistLegacyDiskKeys(keys);
64
+ }
65
+
66
+ static void unregisterLegacyDiskKeys(NSArray<NSString*>* keys) {
67
+ if (keys.count == 0) {
68
+ return;
69
+ }
70
+ NSMutableSet* registered = [registeredLegacyDiskKeys() mutableCopy];
71
+ BOOL changed = NO;
72
+ for (NSString* key in keys) {
73
+ if ([registered containsObject:key]) {
74
+ [registered removeObject:key];
75
+ changed = YES;
76
+ }
77
+ }
78
+ if (changed) {
79
+ persistLegacyDiskKeys(registered);
80
+ }
81
+ }
82
+
23
83
  // Prevents the Keychain from showing auth UI. On iOS 14+ kSecUseAuthenticationUIFail is
24
84
  // deprecated; the correct replacement is an LAContext with interactionNotAllowed = YES.
25
85
  static void disableKeychainInteraction(NSMutableDictionary* query) {
@@ -52,6 +112,7 @@ void IOSStorageAdapterCpp::setDisk(const std::string& key, const std::string& va
52
112
  NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
53
113
  if ([standard objectForKey:nsKey] != nil) {
54
114
  [standard removeObjectForKey:nsKey];
115
+ unregisterLegacyDiskKeys(@[nsKey]);
55
116
  }
56
117
  }
57
118
 
@@ -66,6 +127,7 @@ std::optional<std::string> IOSStorageAdapterCpp::getDisk(const std::string& key)
66
127
  if (legacyValue) {
67
128
  [defaults setObject:legacyValue forKey:nsKey];
68
129
  [legacyDefaults removeObjectForKey:nsKey];
130
+ unregisterLegacyDiskKeys(@[nsKey]);
69
131
  result = legacyValue;
70
132
  }
71
133
  }
@@ -81,22 +143,40 @@ void IOSStorageAdapterCpp::deleteDisk(const std::string& key) {
81
143
  if ([standard objectForKey:nsKey] != nil) {
82
144
  [standard removeObjectForKey:nsKey];
83
145
  }
146
+ unregisterLegacyDiskKeys(@[nsKey]);
84
147
  }
85
148
 
86
149
  bool IOSStorageAdapterCpp::hasDisk(const std::string& key) {
87
150
  NSString* nsKey = [NSString stringWithUTF8String:key.c_str()];
88
151
  if ([NitroDiskDefaults() objectForKey:nsKey] != nil) return true;
89
152
  // Check legacy standardUserDefaults for un-migrated keys
90
- return [[NSUserDefaults standardUserDefaults] stringForKey:nsKey] != nil;
153
+ if ([[NSUserDefaults standardUserDefaults] stringForKey:nsKey] != nil) {
154
+ registerLegacyDiskKey(nsKey);
155
+ return true;
156
+ }
157
+ return false;
91
158
  }
92
159
 
93
160
  std::vector<std::string> IOSStorageAdapterCpp::getAllKeysDisk() {
94
161
  NSUserDefaults* defaults = NitroDiskDefaults();
95
162
  NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
96
- std::vector<std::string> keys;
97
- keys.reserve(entries.count);
163
+ std::unordered_set<std::string> combined;
98
164
  for (NSString* key in entries) {
99
- keys.push_back(std::string([key UTF8String]));
165
+ if (![key isEqualToString:kLegacyDiskKeysRegistryKey]) {
166
+ combined.insert(std::string([key UTF8String]));
167
+ }
168
+ }
169
+ // Only keys observed through the storage API are registered legacy keys;
170
+ // arbitrary host-app standard defaults keys are never enumerated.
171
+ for (NSString* key in [registeredLegacyDiskKeys() allObjects]) {
172
+ if (entries[key] == nil && ![key isEqualToString:kLegacyDiskKeysRegistryKey]) {
173
+ combined.insert(std::string([key UTF8String]));
174
+ }
175
+ }
176
+ std::vector<std::string> keys;
177
+ keys.reserve(combined.size());
178
+ for (const auto& key : combined) {
179
+ keys.push_back(key);
100
180
  }
101
181
  return keys;
102
182
  }
@@ -114,8 +194,7 @@ std::vector<std::string> IOSStorageAdapterCpp::getKeysByPrefixDisk(const std::st
114
194
  }
115
195
 
116
196
  size_t IOSStorageAdapterCpp::sizeDisk() {
117
- NSDictionary<NSString*, id>* entries = [NitroDiskDefaults() persistentDomainForName:kDiskSuiteName] ?: @{};
118
- return entries.count;
197
+ return getAllKeysDisk().size();
119
198
  }
120
199
 
121
200
  void IOSStorageAdapterCpp::setDiskBatch(
@@ -136,6 +215,7 @@ void IOSStorageAdapterCpp::setDiskBatch(
136
215
  for (NSString* key in legacyKeysToRemove) {
137
216
  [standard removeObjectForKey:key];
138
217
  }
218
+ unregisterLegacyDiskKeys(legacyKeysToRemove);
139
219
  }
140
220
 
141
221
  std::vector<std::optional<std::string>> IOSStorageAdapterCpp::getDiskBatch(
@@ -158,9 +238,26 @@ void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys)
158
238
  void IOSStorageAdapterCpp::clearDisk() {
159
239
  NSUserDefaults* defaults = NitroDiskDefaults();
160
240
  NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
241
+ // Capture the registry before clearing the suite domain, which removes it.
242
+ NSMutableSet* legacyKeys = [registeredLegacyDiskKeys() mutableCopy];
161
243
  for (NSString* key in entries) {
162
244
  [defaults removeObjectForKey:key];
163
245
  }
246
+ // Clear the standard-defaults copies of every storage-owned key so legacy
247
+ // values deleted here cannot reappear through the read-time migration path.
248
+ NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
249
+ for (NSString* key in entries) {
250
+ if ([standard objectForKey:key] != nil) {
251
+ [standard removeObjectForKey:key];
252
+ [legacyKeys removeObject:key];
253
+ }
254
+ }
255
+ for (NSString* key in legacyKeys) {
256
+ if ([standard objectForKey:key] != nil) {
257
+ [standard removeObjectForKey:key];
258
+ }
259
+ }
260
+ [defaults removeObjectForKey:kLegacyDiskKeysRegistryKey];
164
261
  }
165
262
 
166
263
  // --- Secure (Keychain) ---
@@ -228,9 +325,7 @@ static void setSecureValue(
228
325
  "The item is not accessible until the device is unlocked."
229
326
  );
230
327
  }
231
- throw std::runtime_error(
232
- "NitroStorage: Secure set failed with status " + std::to_string(addStatus)
233
- );
328
+ throw keychainStatusError(addStatus, "Secure set");
234
329
  }
235
330
 
236
331
  if (status == errSecInteractionNotAllowed) {
@@ -240,9 +335,7 @@ static void setSecureValue(
240
335
  "The item is not accessible until the device is unlocked."
241
336
  );
242
337
  }
243
- throw std::runtime_error(
244
- "NitroStorage: Secure set failed with status " + std::to_string(status)
245
- );
338
+ throw keychainStatusError(status, "Secure set");
246
339
  }
247
340
 
248
341
  static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* group) {
@@ -290,6 +383,20 @@ static void deleteSecureValue(NSString* nsKey, NSString* group) {
290
383
  }
291
384
  }
292
385
 
386
+ // Deletes only the plain (non-biometric) keychain copy. Promotion to biometric
387
+ // storage must remove the plain copy so stale plain reads cannot resurrect it.
388
+ static void deletePlainSecureValue(NSString* nsKey, NSString* group) {
389
+ NSMutableDictionary* secureQuery = baseKeychainQuery(nsKey, kKeychainService, group);
390
+ OSStatus secureStatus = SecItemDelete((__bridge CFDictionaryRef)secureQuery);
391
+ if (secureStatus == errSecInteractionNotAllowed) {
392
+ throw taggedStorageError(
393
+ "keychain_locked",
394
+ "NitroStorage: Keychain is locked (errSecInteractionNotAllowed). "
395
+ "The item is not accessible until the device is unlocked."
396
+ );
397
+ }
398
+ }
399
+
293
400
  static std::vector<std::string> keychainAccountsForService(NSString* service, NSString* accessGroup) {
294
401
  NSMutableDictionary* query = allAccountsQuery(service, accessGroup);
295
402
  disableKeychainInteraction(query);
@@ -490,8 +597,7 @@ void IOSStorageAdapterCpp::clearSecure() {
490
597
  "NitroStorage: Cannot clear secure storage: keychain is locked (errSecInteractionNotAllowed)"
491
598
  );
492
599
  }
493
- throw std::runtime_error(
494
- std::string("NitroStorage: clearSecure failed with status ") + std::to_string(secStatus));
600
+ throw keychainStatusError(secStatus, "clearSecure");
495
601
  }
496
602
 
497
603
  NSMutableDictionary* biometricQuery = [@{
@@ -509,8 +615,7 @@ void IOSStorageAdapterCpp::clearSecure() {
509
615
  "NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)"
510
616
  );
511
617
  }
512
- throw std::runtime_error(
513
- std::string("NitroStorage: clearSecureBiometric failed with status ") + std::to_string(bioStatus));
618
+ throw keychainStatusError(bioStatus, "clearSecureBiometric");
514
619
  }
515
620
  clearSecureKeyCache(); // Only clears cache AFTER confirmed deletion
516
621
  }
@@ -627,12 +732,52 @@ void IOSStorageAdapterCpp::setSecureBiometricWithLevel(const std::string& key, c
627
732
  "The item is not accessible until the device is unlocked."
628
733
  );
629
734
  }
630
- throw std::runtime_error(
735
+ if (addStatus == errSecNotAvailable) {
736
+ throw taggedStorageError(
737
+ "keychain_locked",
738
+ "NitroStorage: Biometric set failed: keychain is unavailable until the device is unlocked (errSecNotAvailable)."
739
+ );
740
+ }
741
+ throw taggedStorageError(
742
+ "biometric_unavailable",
631
743
  std::string("NitroStorage: Biometric set failed with status ") +
632
744
  std::to_string(addStatus) +
633
745
  (backup.has_value() ? " (previous value restored to non-biometric keychain)" : " (no previous value)"));
634
746
  }
747
+
748
+ // Promotion contract: the plain secure copy must not survive a biometric
749
+ // write, or plain reads would return a stale value (Android removes it too).
750
+ // If removing the plain copy fails after the biometric write succeeded,
751
+ // roll the biometric write back so the promotion either fully succeeds or
752
+ // leaves the previous state intact, then rethrow the original error.
753
+ try {
754
+ deletePlainSecureValue(nsKey, group);
755
+ } catch (const std::exception& plainDeleteError) {
756
+ if (backup.has_value()) {
757
+ CFErrorRef restoreError = NULL;
758
+ SecAccessControlRef restoreAccess = SecAccessControlCreateWithFlags(
759
+ kCFAllocatorDefault,
760
+ kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
761
+ flags,
762
+ &restoreError
763
+ );
764
+ if (restoreAccess && !restoreError) {
765
+ NSMutableDictionary* restoreQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
766
+ restoreQuery[(__bridge id)kSecValueData] = [nsStringFromStdString(*backup) dataUsingEncoding:NSUTF8StringEncoding];
767
+ restoreQuery[(__bridge id)kSecAttrAccessControl] = (__bridge_transfer id)restoreAccess;
768
+ SecItemAdd((__bridge CFDictionaryRef)restoreQuery, NULL);
769
+ } else {
770
+ if (restoreError) CFRelease(restoreError);
771
+ if (restoreAccess) CFRelease(restoreAccess);
772
+ }
773
+ } else {
774
+ NSMutableDictionary* rollbackQuery = baseKeychainQuery(nsKey, kBiometricKeychainService, group);
775
+ SecItemDelete((__bridge CFDictionaryRef)rollbackQuery);
776
+ }
777
+ throw;
778
+ }
635
779
  markBiometricKeySet(key);
780
+ markSecureKeyRemoved(key);
636
781
  }
637
782
 
638
783
  std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::string& key) {
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.resolveNativeWriteBuffering = resolveNativeWriteBuffering;
7
+ exports.resolveWebWriteBuffering = resolveWebWriteBuffering;
8
+ function resolveWebWriteBuffering(diskBackendName, secureBackendName) {
9
+ return {
10
+ disk: isIndexedDBWebBackendByName(diskBackendName),
11
+ secure: isIndexedDBWebBackendByName(secureBackendName)
12
+ };
13
+ }
14
+ function isIndexedDBWebBackendByName(backendName) {
15
+ if (!backendName) {
16
+ return false;
17
+ }
18
+ return backendName.startsWith("indexeddb:");
19
+ }
20
+ function resolveNativeWriteBuffering(platform, secureWritesAsync) {
21
+ return {
22
+ disk: true,
23
+ secure: platform === "android" ? secureWritesAsync : false
24
+ };
25
+ }
26
+ //# sourceMappingURL=capabilities.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["resolveWebWriteBuffering","diskBackendName","secureBackendName","disk","isIndexedDBWebBackendByName","secure","backendName","startsWith","resolveNativeWriteBuffering","platform","secureWritesAsync"],"sourceRoot":"../../src","sources":["capabilities.ts"],"mappings":";;;;;;;AAUO,SAASA,wBAAwBA,CACtCC,eAAmC,EACnCC,iBAAqC,EACrB;EAChB,OAAO;IACLC,IAAI,EAAEC,2BAA2B,CAACH,eAAe,CAAC;IAClDI,MAAM,EAAED,2BAA2B,CAACF,iBAAiB;EACvD,CAAC;AACH;AAEA,SAASE,2BAA2BA,CAACE,WAA+B,EAAW;EAC7E,IAAI,CAACA,WAAW,EAAE;IAChB,OAAO,KAAK;EACd;EACA,OAAOA,WAAW,CAACC,UAAU,CAAC,YAAY,CAAC;AAC7C;AAEO,SAASC,2BAA2BA,CACzCC,QAA2B,EAC3BC,iBAA0B,EACV;EAChB,OAAO;IACLP,IAAI,EAAE,IAAI;IACVE,MAAM,EAAEI,QAAQ,KAAK,SAAS,GAAGC,iBAAiB,GAAG;EACvD,CAAC;AACH","ignoreList":[]}
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.createDurabilityCoordinator = createDurabilityCoordinator;
7
+ var _shared = require("../shared");
8
+ var _Storage = require("../Storage.types");
9
+ function createDurabilityCoordinator(options) {
10
+ const pendingDiskWrites = new Map();
11
+ let diskFlushScheduled = false;
12
+ let diskWritesAsync = false;
13
+ const pendingSecureWrites = new Map();
14
+ let secureFlushScheduled = false;
15
+ function flushDiskWrites() {
16
+ diskFlushScheduled = false;
17
+ if (pendingDiskWrites.size === 0) {
18
+ return;
19
+ }
20
+ const writes = Array.from(pendingDiskWrites.values());
21
+ pendingDiskWrites.clear();
22
+ const keysToSet = [];
23
+ const valuesToSet = [];
24
+ const keysToRemove = [];
25
+ writes.forEach(({
26
+ key,
27
+ value
28
+ }) => {
29
+ if (value === undefined) {
30
+ keysToRemove.push(key);
31
+ return;
32
+ }
33
+ keysToSet.push(key);
34
+ valuesToSet.push(value);
35
+ });
36
+ if (keysToSet.length > 0) {
37
+ options.backend.setBatch(keysToSet, valuesToSet, _Storage.StorageScope.Disk);
38
+ }
39
+ if (keysToRemove.length > 0) {
40
+ options.backend.removeBatch(keysToRemove, _Storage.StorageScope.Disk);
41
+ }
42
+ }
43
+ function flushSecureWrites() {
44
+ secureFlushScheduled = false;
45
+ if (pendingSecureWrites.size === 0) {
46
+ return;
47
+ }
48
+ const writes = Array.from(pendingSecureWrites.values());
49
+ pendingSecureWrites.clear();
50
+ const groupedSetWrites = new Map();
51
+ const keysToRemove = [];
52
+ writes.forEach(({
53
+ key,
54
+ value,
55
+ accessControl
56
+ }) => {
57
+ if (value === undefined) {
58
+ keysToRemove.push(key);
59
+ } else {
60
+ const resolvedAccessControl = accessControl ?? options.resolveSecureDefaultAccessControl();
61
+ const existingGroup = groupedSetWrites.get(resolvedAccessControl);
62
+ const group = existingGroup ?? {
63
+ keys: [],
64
+ values: []
65
+ };
66
+ group.keys.push(key);
67
+ group.values.push(value);
68
+ if (!existingGroup) {
69
+ groupedSetWrites.set(resolvedAccessControl, group);
70
+ }
71
+ }
72
+ });
73
+ groupedSetWrites.forEach((group, accessControl) => {
74
+ options.backend.setSecureAccessControl(accessControl);
75
+ options.backend.setBatch(group.keys, group.values, _Storage.StorageScope.Secure);
76
+ });
77
+ if (keysToRemove.length > 0) {
78
+ options.backend.removeBatch(keysToRemove, _Storage.StorageScope.Secure);
79
+ }
80
+ }
81
+ function scheduleDiskWrite(key, value) {
82
+ pendingDiskWrites.set(key, {
83
+ key,
84
+ value
85
+ });
86
+ if (diskFlushScheduled) {
87
+ return;
88
+ }
89
+ diskFlushScheduled = true;
90
+ (0, _shared.runMicrotask)(flushDiskWrites);
91
+ }
92
+ function scheduleSecureWrite(key, value, accessControl) {
93
+ const pendingWrite = {
94
+ key,
95
+ value
96
+ };
97
+ if (accessControl !== undefined) {
98
+ pendingWrite.accessControl = accessControl;
99
+ }
100
+ pendingSecureWrites.set(key, pendingWrite);
101
+ if (secureFlushScheduled) {
102
+ return;
103
+ }
104
+ secureFlushScheduled = true;
105
+ (0, _shared.runMicrotask)(flushSecureWrites);
106
+ }
107
+ return {
108
+ setDiskWritesAsync(enabled) {
109
+ diskWritesAsync = enabled;
110
+ if (!enabled) {
111
+ flushDiskWrites();
112
+ }
113
+ },
114
+ isDiskWritesAsync: () => diskWritesAsync,
115
+ hasPendingDiskWrite: key => pendingDiskWrites.has(key),
116
+ hasPendingSecureWrite: key => pendingSecureWrites.has(key),
117
+ readPendingDiskWrite: key => pendingDiskWrites.get(key)?.value,
118
+ readPendingSecureWrite: key => pendingSecureWrites.get(key)?.value,
119
+ clearPendingDiskWrite: key => {
120
+ pendingDiskWrites.delete(key);
121
+ },
122
+ clearPendingSecureWrite: key => {
123
+ pendingSecureWrites.delete(key);
124
+ },
125
+ clearAllPendingDiskWrites: () => {
126
+ pendingDiskWrites.clear();
127
+ },
128
+ clearAllPendingSecureWrites: () => {
129
+ pendingSecureWrites.clear();
130
+ },
131
+ scheduleDiskWrite,
132
+ scheduleSecureWrite,
133
+ flushDiskWrites,
134
+ flushSecureWrites
135
+ };
136
+ }
137
+ //# sourceMappingURL=durability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["_shared","require","_Storage","createDurabilityCoordinator","options","pendingDiskWrites","Map","diskFlushScheduled","diskWritesAsync","pendingSecureWrites","secureFlushScheduled","flushDiskWrites","size","writes","Array","from","values","clear","keysToSet","valuesToSet","keysToRemove","forEach","key","value","undefined","push","length","backend","setBatch","StorageScope","Disk","removeBatch","flushSecureWrites","groupedSetWrites","accessControl","resolvedAccessControl","resolveSecureDefaultAccessControl","existingGroup","get","group","keys","set","setSecureAccessControl","Secure","scheduleDiskWrite","runMicrotask","scheduleSecureWrite","pendingWrite","setDiskWritesAsync","enabled","isDiskWritesAsync","hasPendingDiskWrite","has","hasPendingSecureWrite","readPendingDiskWrite","readPendingSecureWrite","clearPendingDiskWrite","delete","clearPendingSecureWrite","clearAllPendingDiskWrites","clearAllPendingSecureWrites"],"sourceRoot":"../../../src","sources":["core/durability.ts"],"mappings":"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","ignoreList":[]}