react-native-nitro-storage 0.12.0 → 0.14.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.
- package/CHANGELOG.md +47 -0
- package/README.md +107 -15
- package/SECURITY.md +2 -2
- package/android/build.gradle +5 -0
- package/android/src/main/cpp/AndroidStorageAdapterCpp.cpp +12 -13
- package/android/src/main/cpp/JniSize.hpp +21 -0
- package/android/src/main/java/com/nitrostorage/AndroidStorageAdapter.kt +94 -7
- package/android/src/main/java/com/nitrostorage/DiskSqliteStore.kt +147 -34
- package/cpp/bindings/HybridStorage.cpp +12 -4
- package/cpp/core/SqliteDiskStore.cpp +172 -26
- package/cpp/core/SqliteDiskStore.hpp +10 -1
- package/docs/api-reference.md +60 -54
- package/docs/batch-transactions-migrations.md +7 -0
- package/docs/native-libraries.md +28 -0
- package/docs/qa/agent-device-replay.md +70 -0
- package/docs/secure-storage.md +12 -6
- package/ios/IOSStorageAdapterCpp.mm +89 -22
- package/lib/commonjs/Storage.types.js +4 -2
- package/lib/commonjs/Storage.types.js.map +1 -1
- package/lib/commonjs/core/durability.js +30 -2
- package/lib/commonjs/core/durability.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/shared.js.map +1 -1
- package/lib/commonjs/storage-core.js +63 -9
- package/lib/commonjs/storage-core.js.map +1 -1
- package/lib/commonjs/testing.js +1 -0
- package/lib/commonjs/testing.js.map +1 -1
- package/lib/module/Storage.types.js +4 -2
- package/lib/module/Storage.types.js.map +1 -1
- package/lib/module/core/durability.js +30 -2
- package/lib/module/core/durability.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/shared.js.map +1 -1
- package/lib/module/storage-core.js +63 -9
- package/lib/module/storage-core.js.map +1 -1
- package/lib/module/testing.js +1 -0
- package/lib/module/testing.js.map +1 -1
- package/lib/typescript/Storage.types.d.ts +4 -2
- package/lib/typescript/Storage.types.d.ts.map +1 -1
- package/lib/typescript/core/durability.d.ts +7 -1
- package/lib/typescript/core/durability.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +4 -2
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +4 -2
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/shared.d.ts +8 -1
- package/lib/typescript/shared.d.ts.map +1 -1
- package/lib/typescript/storage-core.d.ts +6 -3
- package/lib/typescript/storage-core.d.ts.map +1 -1
- package/lib/typescript/testing.d.ts +7 -4
- package/lib/typescript/testing.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/Storage.types.ts +4 -2
- package/src/core/durability.ts +46 -2
- package/src/index.ts +4 -0
- package/src/index.web.ts +4 -0
- package/src/shared.ts +17 -1
- package/src/storage-core.ts +87 -13
- package/src/testing.ts +5 -0
|
@@ -20,7 +20,10 @@ public:
|
|
|
20
20
|
|
|
21
21
|
static SqliteDiskStore& shared(const std::string& path);
|
|
22
22
|
static void resetShared();
|
|
23
|
+
static void recreateShared(const std::string& path);
|
|
24
|
+
static void removeDatabaseFiles(const std::string& path);
|
|
23
25
|
static std::string defaultPath();
|
|
26
|
+
static bool isValidUtf8(const std::string& value);
|
|
24
27
|
|
|
25
28
|
const std::string& path() const { return path_; }
|
|
26
29
|
|
|
@@ -44,10 +47,14 @@ public:
|
|
|
44
47
|
const std::string& name,
|
|
45
48
|
const std::vector<std::pair<std::string, std::string>>& entries
|
|
46
49
|
);
|
|
50
|
+
void recreate();
|
|
47
51
|
void limitPageCountForTesting(int pages);
|
|
52
|
+
void limitValueLengthForTesting(int bytes);
|
|
48
53
|
|
|
49
54
|
private:
|
|
50
55
|
void openLocked();
|
|
56
|
+
void reopenLocked();
|
|
57
|
+
void ensureOpenLocked();
|
|
51
58
|
void closeLocked();
|
|
52
59
|
void execLocked(const char* sql);
|
|
53
60
|
void beginLocked();
|
|
@@ -68,7 +75,9 @@ private:
|
|
|
68
75
|
sqlite3_stmt* removeStmt_ = nullptr;
|
|
69
76
|
sqlite3_stmt* hasStmt_ = nullptr;
|
|
70
77
|
sqlite3_stmt* keysStmt_ = nullptr;
|
|
71
|
-
sqlite3_stmt*
|
|
78
|
+
sqlite3_stmt* prefixRangeStmt_ = nullptr;
|
|
79
|
+
sqlite3_stmt* prefixFromStmt_ = nullptr;
|
|
80
|
+
sqlite3_stmt* prefixLikeStmt_ = nullptr;
|
|
72
81
|
sqlite3_stmt* sizeStmt_ = nullptr;
|
|
73
82
|
sqlite3_stmt* clearStmt_ = nullptr;
|
|
74
83
|
sqlite3_stmt* insertAbsentStmt_ = nullptr;
|
package/docs/api-reference.md
CHANGED
|
@@ -112,49 +112,50 @@ 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()` |
|
|
142
|
-
| `
|
|
143
|
-
| `
|
|
144
|
-
| `
|
|
145
|
-
| `
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
148
|
-
| `
|
|
149
|
-
| `
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `
|
|
155
|
-
| `
|
|
156
|
-
| `
|
|
157
|
-
| `
|
|
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. |
|
|
158
159
|
|
|
159
160
|
Raw string APIs bypass item serialization and validation. Prefer `StorageItem<T>` unless you are migrating, exporting/importing, or writing a custom integration.
|
|
160
161
|
|
|
@@ -234,6 +235,9 @@ runTransaction(StorageScope.Disk, (tx) => {
|
|
|
234
235
|
```
|
|
235
236
|
|
|
236
237
|
If the callback throws, previously changed keys in that transaction are rolled back synchronously.
|
|
238
|
+
Promise or thenable results are rejected with `TypeError` and rollback. Context
|
|
239
|
+
methods are valid only during the synchronous callback. Finish awaited work
|
|
240
|
+
before entering the transaction.
|
|
237
241
|
|
|
238
242
|
## Migrations
|
|
239
243
|
|
|
@@ -249,6 +253,8 @@ migrateToLatest(StorageScope.Disk);
|
|
|
249
253
|
```
|
|
250
254
|
|
|
251
255
|
Migration versions are tracked per scope.
|
|
256
|
+
Migration callbacks must be synchronous. An async callback is rejected without
|
|
257
|
+
advancing its version, and the next migration run retries that step.
|
|
252
258
|
|
|
253
259
|
## Secure Auth Storage
|
|
254
260
|
|
|
@@ -276,17 +282,17 @@ the native or web adapter. `isStorageError(error, code)` matches one exact code
|
|
|
276
282
|
without parsing platform message text. See [secure-storage.md](secure-storage.md)
|
|
277
283
|
for recovery semantics.
|
|
278
284
|
|
|
279
|
-
| Code | Raised when
|
|
280
|
-
| ----------------------------- |
|
|
281
|
-
| `keychain_locked` | The protected store is locked.
|
|
282
|
-
| `authentication_required` | The item needs user authentication, or the user cancelled the prompt.
|
|
283
|
-
| `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change.
|
|
284
|
-
| `biometric_unavailable` | The requested biometric level is not available on this device or OS version.
|
|
285
|
-
| `storage_corruption` | Stored secure data could not be
|
|
286
|
-
| `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed.
|
|
287
|
-
| `unsupported` | The operation is not available on this platform or environment.
|
|
288
|
-
| `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying.
|
|
289
|
-
| `invalid_key` | A storage key is empty.
|
|
285
|
+
| Code | Raised when |
|
|
286
|
+
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
287
|
+
| `keychain_locked` | The protected store is locked. |
|
|
288
|
+
| `authentication_required` | The item needs user authentication, or the user cancelled the prompt. |
|
|
289
|
+
| `key_invalidated` | The protecting key was invalidated, for example by a biometric enrolment change. |
|
|
290
|
+
| `biometric_unavailable` | The requested biometric level is not available on this device or OS version. |
|
|
291
|
+
| `storage_corruption` | Stored secure data could not be decoded, the Disk database is corrupt, or (Android) the Secure master key or store cannot be created. Do not retry. |
|
|
292
|
+
| `storage_compensation_failed` | A multi-step write failed and restoring the previous state also failed. |
|
|
293
|
+
| `unsupported` | The operation is not available on this platform or environment. |
|
|
294
|
+
| `storage_full` | The device or database is out of space, or the web storage quota is exceeded. Free space before retrying. |
|
|
295
|
+
| `invalid_key` | A storage key is empty. |
|
|
290
296
|
|
|
291
297
|
`StorageCompositeError` and `StorageCompensationError` describe errors that
|
|
292
298
|
carry a primary failure plus secondary failures from reconciliation or
|
|
@@ -126,6 +126,11 @@ Transaction context methods:
|
|
|
126
126
|
|
|
127
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
128
|
|
|
129
|
+
Callbacks must return synchronously. A Promise or thenable result throws a
|
|
130
|
+
`TypeError` and triggers the same rollback. Context methods reject access after
|
|
131
|
+
the callback finishes, including from an async continuation. Complete awaited
|
|
132
|
+
work before the transaction and never retain its context for later use.
|
|
133
|
+
|
|
129
134
|
Memory-scope batch removes are atomic: all keys are deleted before listeners fire, and scope, key, and prefix subscribers receive a single `removeBatch` event.
|
|
130
135
|
|
|
131
136
|
## Migrations
|
|
@@ -160,6 +165,8 @@ migrateToLatest(StorageScope.Disk);
|
|
|
160
165
|
Migration context methods work with raw strings. Use item serializers manually when migrating structured data.
|
|
161
166
|
|
|
162
167
|
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.
|
|
168
|
+
Migration callbacks follow the same synchronous contract as transactions. An
|
|
169
|
+
async migration does not advance its version marker.
|
|
163
170
|
|
|
164
171
|
```ts
|
|
165
172
|
registerMigration(3, (ctx) => {
|
package/docs/native-libraries.md
CHANGED
|
@@ -28,6 +28,34 @@ wipe leaves no pre-SQLite copy behind. Later Disk reads and writes use SQLite.
|
|
|
28
28
|
| RocksDB / LevelDB | Rejected. Heavy native footprint for small preference-sized values. |
|
|
29
29
|
| yyjson | Rejected for Disk. Typed TTL/JSON envelopes stay in JavaScript. |
|
|
30
30
|
|
|
31
|
+
## Limits and durability
|
|
32
|
+
|
|
33
|
+
- Disk writes wait up to 5 seconds for another connection that holds the
|
|
34
|
+
SQLite write lock. The library uses one connection per process, so this wait
|
|
35
|
+
only happens when another process (for example an app extension in the same
|
|
36
|
+
container) is writing. Reads do not wait in WAL mode. Five seconds gives the
|
|
37
|
+
other writer time to finish and stays below the iOS and Android
|
|
38
|
+
unresponsive-app limits.
|
|
39
|
+
- If the Disk database file is deleted while the app runs, the open connection
|
|
40
|
+
keeps reading and writing the deleted file, and the next launch starts with
|
|
41
|
+
an empty database. The library does not check for this on each call, because
|
|
42
|
+
the check costs a system call on every write. Do not delete
|
|
43
|
+
`nitro-storage-disk.sqlite` yourself; call
|
|
44
|
+
`storage.clear(StorageScope.Disk)`.
|
|
45
|
+
- Each platform keeps its system SQLite checkpoint settings. iOS checkpoints
|
|
46
|
+
every 1000 pages and truncates the WAL to 32 KiB; these values are set in
|
|
47
|
+
code so they do not depend on the SQLite build. Android checkpoints every
|
|
48
|
+
100 pages with a 512 KiB limit, applied by the Android framework to every
|
|
49
|
+
pooled connection.
|
|
50
|
+
- Android opens the Disk database in WAL mode. If that open fails for a reason
|
|
51
|
+
other than corruption (for example a full device while an older database
|
|
52
|
+
converts to WAL), it opens once without WAL and uses `synchronous=FULL`, so
|
|
53
|
+
reads keep working; the next launch tries WAL again. If WAL is active and the
|
|
54
|
+
device is so full that the WAL shared-memory file cannot be created, reads
|
|
55
|
+
fail with `storage_full` until space is free. iOS has the same limit.
|
|
56
|
+
- With WAL and `synchronous=NORMAL`, an app kill never loses a committed
|
|
57
|
+
write. A power loss can lose the most recent commits.
|
|
58
|
+
|
|
31
59
|
## Compatibility
|
|
32
60
|
|
|
33
61
|
- Public Disk APIs (`get`/`set`/`setBatch`/`getKeysByPrefix`/`import`) are
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Storage agent-device replay
|
|
2
|
+
|
|
3
|
+
Run these flows only against an already installed release example that matches
|
|
4
|
+
the source revision under test. Select the platform and exact device every time:
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
bun run example:replay --platform ios --udid <exact-udid>
|
|
8
|
+
bun run example:replay --platform android --serial <exact-serial>
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
To run selected manifest suites, pass `--flow <id>`. The runner accepts this
|
|
12
|
+
option more than once for distinct suite IDs:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
bun run example:replay --platform ios --udid <exact-udid> --flow integrity
|
|
16
|
+
bun run example:replay --platform android --serial <exact-serial> --flow keychain --flow persistence-relaunch
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Use an installed release build from the source being checked. The replay does
|
|
20
|
+
not start Metro, prebuild, or build an app. A development client, a different
|
|
21
|
+
source revision, an inferred device, or a run without the exact platform and
|
|
22
|
+
device selector is not release replay evidence. The task owner must separately
|
|
23
|
+
authorize device work after the required package implementations are ready.
|
|
24
|
+
|
|
25
|
+
Before replay, check that the example source and replay manifest match the
|
|
26
|
+
committed source lock. If a flow, status ID, or expected value changes, update
|
|
27
|
+
`e2e/storage-replay-coverage.json` and refresh that lock with the repository's
|
|
28
|
+
replay maintenance command. Run the checker again before the device command:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
bun run example:replay:refresh
|
|
32
|
+
bun run example:replay:check
|
|
33
|
+
bun run example:replay:test
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The runner supplies a fresh UUID as `RUN_ID` to each replay. The persistence
|
|
37
|
+
flow writes one `RUN_ID`-scoped Disk key, closes and relaunches the installed
|
|
38
|
+
app with the same ID, compares the stored value with the expected value, and
|
|
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.
|
|
42
|
+
|
|
43
|
+
Each run uses a unique session and an artifact directory under the OS temporary
|
|
44
|
+
directory. `agent-device test` closes each attempt session itself, including
|
|
45
|
+
on failure.
|
|
46
|
+
|
|
47
|
+
A replay passes only when each suite reaches its finished status and every
|
|
48
|
+
required status ID contains its expected public API value. A `fail=0` summary by
|
|
49
|
+
itself is not sufficient. Smoke and integrity rows are asserted through the
|
|
50
|
+
on-screen `smoke-results` and `e2e-integrity-results` labels because
|
|
51
|
+
agent-device `.ad` waits only see on-screen elements. The keychain suite runs one no-prompt Secure
|
|
52
|
+
roundtrip and keeps biometric, lock, corruption, and hardware-backed checks
|
|
53
|
+
explicitly pending. The smoke test's constructed `storage_full` error is read
|
|
54
|
+
through the public `/testing` entrypoint; it proves error-code classification
|
|
55
|
+
at that test adapter boundary only.
|
|
56
|
+
|
|
57
|
+
The native smoke flow requires 39 passing cases out of 41, zero failures, and
|
|
58
|
+
the two expected web-only skips. Update that expected summary when cases change;
|
|
59
|
+
do not accept a new native skip as coverage. The integrity flow also checks that
|
|
60
|
+
a Promise-returning transaction rolls back and closes its context on Memory,
|
|
61
|
+
Disk, and Secure.
|
|
62
|
+
|
|
63
|
+
Native replay rows identify the public runtime platform and backend capability,
|
|
64
|
+
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
|
|
67
|
+
recovery, or a native Disk/Secure write failure. Those checks need controlled
|
|
68
|
+
native or hardware prerequisites and remain pending in the coverage manifest.
|
|
69
|
+
Unit tests for an injected adapter boundary are useful logic evidence, but they
|
|
70
|
+
do not close a native-runtime prerequisite.
|
package/docs/secure-storage.md
CHANGED
|
@@ -206,12 +206,18 @@ refreshTokenItem.set("opaque-refresh-token");
|
|
|
206
206
|
```
|
|
207
207
|
|
|
208
208
|
Coalesced secure item writes remain in a last-write-wins queue until the next
|
|
209
|
-
microtask or an explicit `flushSecureWrites()`.
|
|
210
|
-
failed and unattempted writes queued for
|
|
211
|
-
`
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
209
|
+
microtask or an explicit `flushSecureWrites()`. An explicit flush failure throws
|
|
210
|
+
and keeps failed and unattempted writes queued for retry. Scheduled failures
|
|
211
|
+
propagate unless `storage.setScheduledFlushErrorObserver()` is installed; it
|
|
212
|
+
receives `{ scope, error }` while the failed writes remain queued.
|
|
213
|
+
|
|
214
|
+
`flushSecureWrites()` drains the JavaScript queue into the backend. It does not
|
|
215
|
+
wait for Android `apply()` persistence. Use the default synchronous mode or
|
|
216
|
+
select `storage.setSecureWritesAsync(false)` before writes that need synchronous
|
|
217
|
+
native persistence. Changing the mode is not a barrier for earlier `apply()`
|
|
218
|
+
calls. `storage.clearBiometric()` drains pending Secure writes before clearing
|
|
219
|
+
biometric entries and surfaces native clear failures; it also cannot wait for
|
|
220
|
+
earlier Android `apply()` calls.
|
|
215
221
|
|
|
216
222
|
## iOS Legacy Disk Migration
|
|
217
223
|
|
|
@@ -363,9 +363,9 @@ std::string diskStorePathForTesting() {
|
|
|
363
363
|
// --- Disk ---
|
|
364
364
|
|
|
365
365
|
void IOSStorageAdapterCpp::setDisk(const std::string& key, const std::string& value) {
|
|
366
|
+
NSString* nsKey = nsStringFromStdString(key);
|
|
366
367
|
ensureDiskMigrated();
|
|
367
368
|
NitroSqliteDiskStore().set(key, value);
|
|
368
|
-
NSString* nsKey = nsStringFromStdString(key);
|
|
369
369
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
370
370
|
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
371
371
|
if (defaults != standard && [standard objectForKey:nsKey] != nil) {
|
|
@@ -383,14 +383,17 @@ std::optional<std::string> IOSStorageAdapterCpp::getDisk(const std::string& key)
|
|
|
383
383
|
NSString* result = migrateLegacyDiskValue(nsKey);
|
|
384
384
|
if (!result) return std::nullopt;
|
|
385
385
|
const std::string value = stdStringFromNSString(result);
|
|
386
|
-
|
|
386
|
+
try {
|
|
387
|
+
NitroSqliteDiskStore().set(key, value);
|
|
388
|
+
} catch (const std::exception&) {
|
|
389
|
+
}
|
|
387
390
|
return value;
|
|
388
391
|
}
|
|
389
392
|
|
|
390
393
|
void IOSStorageAdapterCpp::deleteDisk(const std::string& key) {
|
|
394
|
+
NSString* nsKey = nsStringFromStdString(key);
|
|
391
395
|
ensureDiskMigrated();
|
|
392
396
|
NitroSqliteDiskStore().remove(key);
|
|
393
|
-
NSString* nsKey = nsStringFromStdString(key);
|
|
394
397
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
395
398
|
[defaults removeObjectForKey:nsKey];
|
|
396
399
|
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
@@ -445,13 +448,33 @@ std::vector<std::string> IOSStorageAdapterCpp::getAllKeysDisk() {
|
|
|
445
448
|
return keys;
|
|
446
449
|
}
|
|
447
450
|
|
|
451
|
+
static std::vector<std::string> legacyDefaultsDiskKeys() {
|
|
452
|
+
std::vector<std::string> keys;
|
|
453
|
+
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
454
|
+
NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
|
|
455
|
+
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
456
|
+
for (NSString* key in entries) {
|
|
457
|
+
if (!isInternalDiskKey(key)) {
|
|
458
|
+
keys.push_back(stdStringFromNSString(key));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
for (NSString* key in [registeredLegacyDiskKeys() allObjects]) {
|
|
462
|
+
if ([entries objectForKey:key] == nil &&
|
|
463
|
+
[standard stringForKey:key] != nil) {
|
|
464
|
+
keys.push_back(stdStringFromNSString(key));
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return keys;
|
|
468
|
+
}
|
|
469
|
+
|
|
448
470
|
std::vector<std::string> IOSStorageAdapterCpp::getKeysByPrefixDisk(const std::string& prefix) {
|
|
449
471
|
ensureDiskMigrated();
|
|
450
472
|
std::unordered_set<std::string> combined;
|
|
451
473
|
for (const auto& key : NitroSqliteDiskStore().getKeysByPrefix(prefix)) {
|
|
452
474
|
combined.insert(key);
|
|
453
475
|
}
|
|
454
|
-
|
|
476
|
+
const auto remaining = SqliteDiskStore::isValidUtf8(prefix) ? legacyDefaultsDiskKeys() : getAllKeysDisk();
|
|
477
|
+
for (const auto& key : remaining) {
|
|
455
478
|
if (key.rfind(prefix, 0) == 0) {
|
|
456
479
|
combined.insert(key);
|
|
457
480
|
}
|
|
@@ -466,20 +489,31 @@ std::vector<std::string> IOSStorageAdapterCpp::getKeysByPrefixDisk(const std::st
|
|
|
466
489
|
|
|
467
490
|
size_t IOSStorageAdapterCpp::sizeDisk() {
|
|
468
491
|
ensureDiskMigrated();
|
|
469
|
-
|
|
492
|
+
auto& store = NitroSqliteDiskStore();
|
|
493
|
+
size_t count = store.size();
|
|
494
|
+
std::unordered_set<std::string> counted;
|
|
495
|
+
for (const auto& key : legacyDefaultsDiskKeys()) {
|
|
496
|
+
if (counted.insert(key).second && !store.has(key)) {
|
|
497
|
+
count += 1;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return count;
|
|
470
501
|
}
|
|
471
502
|
|
|
472
503
|
void IOSStorageAdapterCpp::setDiskBatch(
|
|
473
504
|
const std::vector<std::string>& keys,
|
|
474
505
|
const std::vector<std::string>& values
|
|
475
506
|
) {
|
|
507
|
+
NSMutableArray<NSString*>* nsKeys = [NSMutableArray arrayWithCapacity:keys.size()];
|
|
508
|
+
for (size_t i = 0; i < keys.size() && i < values.size(); ++i) {
|
|
509
|
+
[nsKeys addObject:nsStringFromStdString(keys[i])];
|
|
510
|
+
}
|
|
476
511
|
ensureDiskMigrated();
|
|
477
512
|
NitroSqliteDiskStore().setBatch(keys, values);
|
|
478
513
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
479
514
|
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
480
515
|
NSMutableArray* legacyKeysToRemove = [NSMutableArray array];
|
|
481
|
-
for (
|
|
482
|
-
NSString* nsKey = nsStringFromStdString(keys[i]);
|
|
516
|
+
for (NSString* nsKey in nsKeys) {
|
|
483
517
|
if (defaults != standard && [standard objectForKey:nsKey] != nil) {
|
|
484
518
|
[legacyKeysToRemove addObject:nsKey];
|
|
485
519
|
}
|
|
@@ -503,10 +537,13 @@ std::vector<std::optional<std::string>> IOSStorageAdapterCpp::getDiskBatch(
|
|
|
503
537
|
}
|
|
504
538
|
|
|
505
539
|
void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys) {
|
|
540
|
+
NSMutableArray<NSString*>* nsKeys = [NSMutableArray arrayWithCapacity:keys.size()];
|
|
541
|
+
for (const auto& key : keys) {
|
|
542
|
+
[nsKeys addObject:nsStringFromStdString(key)];
|
|
543
|
+
}
|
|
506
544
|
ensureDiskMigrated();
|
|
507
545
|
NitroSqliteDiskStore().removeBatch(keys);
|
|
508
|
-
for (
|
|
509
|
-
NSString* nsKey = nsStringFromStdString(key);
|
|
546
|
+
for (NSString* nsKey in nsKeys) {
|
|
510
547
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
511
548
|
[defaults removeObjectForKey:nsKey];
|
|
512
549
|
NSUserDefaults* standard = [NSUserDefaults standardUserDefaults];
|
|
@@ -517,9 +554,13 @@ void IOSStorageAdapterCpp::deleteDiskBatch(const std::vector<std::string>& keys)
|
|
|
517
554
|
}
|
|
518
555
|
}
|
|
519
556
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
557
|
+
static bool isRecoverableByDiskClear(const std::exception& error) {
|
|
558
|
+
const std::string message = error.what();
|
|
559
|
+
return message.rfind("[nitro-error:storage_corruption] NitroStorage: Disk SQLite ", 0) == 0 ||
|
|
560
|
+
message.rfind("[nitro-error:storage_full] NitroStorage: Disk SQLite ", 0) == 0;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
static void clearDiskDefaults() {
|
|
523
564
|
NSUserDefaults* defaults = NitroDiskDefaults();
|
|
524
565
|
NSDictionary<NSString*, id>* entries = [defaults persistentDomainForName:kDiskSuiteName] ?: @{};
|
|
525
566
|
NSMutableSet* legacyKeys = [registeredLegacyDiskKeys() mutableCopy];
|
|
@@ -545,6 +586,25 @@ void IOSStorageAdapterCpp::clearDisk() {
|
|
|
545
586
|
[defaults removeObjectForKey:kLegacyDiskKeysRegistryKey];
|
|
546
587
|
}
|
|
547
588
|
|
|
589
|
+
void IOSStorageAdapterCpp::clearDisk() {
|
|
590
|
+
bool recreate = false;
|
|
591
|
+
try {
|
|
592
|
+
ensureDiskMigrated();
|
|
593
|
+
NitroSqliteDiskStore().clear();
|
|
594
|
+
} catch (const std::exception& error) {
|
|
595
|
+
if (!isRecoverableByDiskClear(error)) {
|
|
596
|
+
throw;
|
|
597
|
+
}
|
|
598
|
+
recreate = true;
|
|
599
|
+
}
|
|
600
|
+
clearDiskDefaults();
|
|
601
|
+
if (recreate) {
|
|
602
|
+
SqliteDiskStore::recreateShared(NitroDiskStorePath());
|
|
603
|
+
std::lock_guard<std::mutex> lock(diskMigrationMutex_);
|
|
604
|
+
diskMigrated_.store(false, std::memory_order_release);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
548
608
|
// --- Secure (Keychain) ---
|
|
549
609
|
|
|
550
610
|
static NSMutableDictionary* baseKeychainQuery(NSString* key, NSString* service, NSString* accessGroup) {
|
|
@@ -695,6 +755,18 @@ static void setSecureValue(
|
|
|
695
755
|
throw keychainStatusError(status, "Secure set");
|
|
696
756
|
}
|
|
697
757
|
|
|
758
|
+
static std::string decodeKeychainText(CFTypeRef result, const std::string& operation) {
|
|
759
|
+
NSData* data = (__bridge_transfer NSData*)result;
|
|
760
|
+
NSString* text = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : nil;
|
|
761
|
+
if (!text) {
|
|
762
|
+
throw taggedStorageError(
|
|
763
|
+
"storage_corruption",
|
|
764
|
+
"NitroStorage: " + operation + " failed: the Keychain item has no data or is not valid UTF-8 text."
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
return stdStringFromNSString(text);
|
|
768
|
+
}
|
|
769
|
+
|
|
698
770
|
static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* group) {
|
|
699
771
|
NSMutableDictionary* query = baseKeychainQuery(nsKey, kKeychainService, group);
|
|
700
772
|
query[(__bridge id)kSecReturnData] = @YES;
|
|
@@ -703,10 +775,8 @@ static std::optional<std::string> getSecureValue(NSString* nsKey, NSString* grou
|
|
|
703
775
|
|
|
704
776
|
CFTypeRef result = NULL;
|
|
705
777
|
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
|
|
706
|
-
if (status == errSecSuccess
|
|
707
|
-
|
|
708
|
-
NSString* str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
|
709
|
-
if (str) return stdStringFromNSString(str);
|
|
778
|
+
if (status == errSecSuccess) {
|
|
779
|
+
return decodeKeychainText(result, "Secure get");
|
|
710
780
|
}
|
|
711
781
|
if (status == errSecInteractionNotAllowed) {
|
|
712
782
|
throw taggedStorageError(
|
|
@@ -1169,10 +1239,8 @@ std::optional<std::string> IOSStorageAdapterCpp::getSecureBiometric(const std::s
|
|
|
1169
1239
|
|
|
1170
1240
|
CFTypeRef result = NULL;
|
|
1171
1241
|
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
|
|
1172
|
-
if (status == errSecSuccess
|
|
1173
|
-
|
|
1174
|
-
NSString* str = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
|
1175
|
-
if (str) return stdStringFromNSString(str);
|
|
1242
|
+
if (status == errSecSuccess) {
|
|
1243
|
+
return decodeKeychainText(result, "Biometric get");
|
|
1176
1244
|
}
|
|
1177
1245
|
if (status == errSecInteractionNotAllowed) {
|
|
1178
1246
|
throw taggedStorageError(
|
|
@@ -1251,8 +1319,7 @@ void IOSStorageAdapterCpp::clearSecureBiometric() {
|
|
|
1251
1319
|
"NitroStorage: Cannot clear biometric storage: keychain is locked (errSecInteractionNotAllowed)"
|
|
1252
1320
|
);
|
|
1253
1321
|
}
|
|
1254
|
-
throw
|
|
1255
|
-
std::string("NitroStorage: clearSecureBiometric failed with status ") + std::to_string(status));
|
|
1322
|
+
throw keychainStatusError(status, "clearSecureBiometric");
|
|
1256
1323
|
}
|
|
1257
1324
|
{
|
|
1258
1325
|
std::lock_guard<std::mutex> lock(secureKeysMutex_);
|
|
@@ -26,9 +26,11 @@ let AccessControl = exports.AccessControl = /*#__PURE__*/function (AccessControl
|
|
|
26
26
|
let BiometricLevel = exports.BiometricLevel = /*#__PURE__*/function (BiometricLevel) {
|
|
27
27
|
/** No biometric requirement (default). */
|
|
28
28
|
BiometricLevel[BiometricLevel["None"] = 0] = "None";
|
|
29
|
-
/**
|
|
29
|
+
/** iOS: Keychain user presence. Android: recent authentication when opening
|
|
30
|
+
* the protected store; later access uses its cached keyset. Web: no biometric enforcement. */
|
|
30
31
|
BiometricLevel[BiometricLevel["BiometryOrPasscode"] = 1] = "BiometryOrPasscode";
|
|
31
|
-
/**
|
|
32
|
+
/** Biometric-only Keychain/Keystore policy, without passcode fallback.
|
|
33
|
+
* Android authenticates when opening the store, not each access. Web: no biometric enforcement. */
|
|
32
34
|
BiometricLevel[BiometricLevel["BiometryOnly"] = 2] = "BiometryOnly";
|
|
33
35
|
return BiometricLevel;
|
|
34
36
|
}({});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["StorageScope","exports","AccessControl","BiometricLevel"],"sourceRoot":"../../src","sources":["Storage.types.ts"],"mappings":";;;;;;IAAYA,YAAY,GAAAC,OAAA,CAAAD,YAAA,0BAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAA,OAAZA,YAAY;AAAA;AAAA,IAMZE,aAAa,GAAAD,OAAA,CAAAC,aAAA,0BAAbA,aAAa;EACvB;EADUA,aAAa,CAAbA,aAAa;EAGvB;EAHUA,aAAa,CAAbA,aAAa;EAKvB;EALUA,aAAa,CAAbA,aAAa;EAOvB;EAPUA,aAAa,CAAbA,aAAa;EASvB;EATUA,aAAa,CAAbA,aAAa;EAAA,OAAbA,aAAa;AAAA;AAAA,IAabC,cAAc,GAAAF,OAAA,CAAAE,cAAA,0BAAdA,cAAc;EACxB;EADUA,cAAc,CAAdA,cAAc;EAGxB;
|
|
1
|
+
{"version":3,"names":["StorageScope","exports","AccessControl","BiometricLevel"],"sourceRoot":"../../src","sources":["Storage.types.ts"],"mappings":";;;;;;IAAYA,YAAY,GAAAC,OAAA,CAAAD,YAAA,0BAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAZA,YAAY,CAAZA,YAAY;EAAA,OAAZA,YAAY;AAAA;AAAA,IAMZE,aAAa,GAAAD,OAAA,CAAAC,aAAA,0BAAbA,aAAa;EACvB;EADUA,aAAa,CAAbA,aAAa;EAGvB;EAHUA,aAAa,CAAbA,aAAa;EAKvB;EALUA,aAAa,CAAbA,aAAa;EAOvB;EAPUA,aAAa,CAAbA,aAAa;EASvB;EATUA,aAAa,CAAbA,aAAa;EAAA,OAAbA,aAAa;AAAA;AAAA,IAabC,cAAc,GAAAF,OAAA,CAAAE,cAAA,0BAAdA,cAAc;EACxB;EADUA,cAAc,CAAdA,cAAc;EAGxB;AACF;EAJYA,cAAc,CAAdA,cAAc;EAMxB;AACF;EAPYA,cAAc,CAAdA,cAAc;EAAA,OAAdA,cAAc;AAAA","ignoreList":[]}
|