react-native-nitro-storage 0.4.5 → 0.5.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/README.md +235 -960
- package/SECURITY.md +26 -0
- package/docs/api-reference.md +217 -0
- package/docs/batch-transactions-migrations.md +186 -0
- package/docs/benchmarks.md +37 -0
- package/docs/mmkv-migration.md +80 -0
- package/docs/react-hooks.md +113 -0
- package/docs/recipes.md +281 -0
- package/docs/secure-storage.md +171 -0
- package/docs/web-backends.md +141 -0
- package/lib/commonjs/index.js +51 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.web.js +54 -0
- package/lib/commonjs/index.web.js.map +1 -1
- package/lib/commonjs/storage-runtime.js.map +1 -1
- package/lib/module/index.js +51 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.web.js +54 -0
- package/lib/module/index.web.js.map +1 -1
- package/lib/module/storage-runtime.js.map +1 -1
- package/lib/typescript/index.d.ts +5 -2
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/index.web.d.ts +5 -2
- package/lib/typescript/index.web.d.ts.map +1 -1
- package/lib/typescript/storage-runtime.d.ts +32 -0
- package/lib/typescript/storage-runtime.d.ts.map +1 -1
- package/package.json +21 -8
- package/src/index.ts +67 -1
- package/src/index.web.ts +76 -0
- package/src/storage-runtime.ts +35 -0
package/docs/recipes.md
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Recipes
|
|
2
|
+
|
|
3
|
+
These examples cover the public features most apps reach for first.
|
|
4
|
+
|
|
5
|
+
## Persisted Preferences
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { createStorageItem, StorageScope } from "react-native-nitro-storage";
|
|
9
|
+
|
|
10
|
+
type Preferences = {
|
|
11
|
+
theme: "system" | "light" | "dark";
|
|
12
|
+
reduceMotion: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const preferencesItem = createStorageItem<Preferences>({
|
|
16
|
+
key: "preferences",
|
|
17
|
+
scope: StorageScope.Disk,
|
|
18
|
+
defaultValue: { theme: "system", reduceMotion: false },
|
|
19
|
+
validate: (value): value is Preferences =>
|
|
20
|
+
typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
"theme" in value &&
|
|
23
|
+
"reduceMotion" in value,
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Auth Tokens
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {
|
|
31
|
+
AccessControl,
|
|
32
|
+
createSecureAuthStorage,
|
|
33
|
+
} from "react-native-nitro-storage";
|
|
34
|
+
|
|
35
|
+
export const auth = createSecureAuthStorage(
|
|
36
|
+
{
|
|
37
|
+
accessToken: { ttlMs: 15 * 60 * 1000 },
|
|
38
|
+
refreshToken: {
|
|
39
|
+
accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{ namespace: "auth" },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
auth.refreshToken.set("opaque-refresh-token");
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Feature Flags with Validation
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
type Flags = {
|
|
52
|
+
newCheckout: boolean;
|
|
53
|
+
paywallVariant: "control" | "variant-a" | "variant-b";
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const flagsItem = createStorageItem<Flags>({
|
|
57
|
+
key: "remoteFlags",
|
|
58
|
+
scope: StorageScope.Disk,
|
|
59
|
+
defaultValue: { newCheckout: false, paywallVariant: "control" },
|
|
60
|
+
validate: (value): value is Flags => {
|
|
61
|
+
if (typeof value !== "object" || value === null) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const flags = value as Partial<Flags>;
|
|
66
|
+
return (
|
|
67
|
+
typeof flags.newCheckout === "boolean" &&
|
|
68
|
+
(flags.paywallVariant === "control" ||
|
|
69
|
+
flags.paywallVariant === "variant-a" ||
|
|
70
|
+
flags.paywallVariant === "variant-b")
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
onValidationError: () => ({ newCheckout: false, paywallVariant: "control" }),
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Biometric Secret
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import {
|
|
81
|
+
BiometricLevel,
|
|
82
|
+
createStorageItem,
|
|
83
|
+
StorageScope,
|
|
84
|
+
} from "react-native-nitro-storage";
|
|
85
|
+
|
|
86
|
+
export const vaultKeyItem = createStorageItem<string>({
|
|
87
|
+
key: "vaultKey",
|
|
88
|
+
scope: StorageScope.Secure,
|
|
89
|
+
defaultValue: "",
|
|
90
|
+
biometric: true,
|
|
91
|
+
biometricLevel: BiometricLevel.BiometryOrPasscode,
|
|
92
|
+
});
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Multi-tenant State
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
function createTenantThemeItem(tenantId: string) {
|
|
99
|
+
return createStorageItem({
|
|
100
|
+
key: "theme",
|
|
101
|
+
namespace: `tenant:${tenantId}`,
|
|
102
|
+
scope: StorageScope.Disk,
|
|
103
|
+
defaultValue: "system",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
storage.clearNamespace("tenant:42", StorageScope.Disk);
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Temporary OTP
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export const otpItem = createStorageItem<string>({
|
|
114
|
+
key: "otp",
|
|
115
|
+
scope: StorageScope.Memory,
|
|
116
|
+
defaultValue: "",
|
|
117
|
+
expiration: { ttlMs: 2 * 60 * 1000 },
|
|
118
|
+
onExpired: () => {
|
|
119
|
+
showOtpExpiredMessage();
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Bulk Bootstrap
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
const [preferences, flags] = getBatch(
|
|
128
|
+
[preferencesItem, flagsItem],
|
|
129
|
+
StorageScope.Disk,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
setBatch(
|
|
133
|
+
[
|
|
134
|
+
{ item: preferencesItem, value: { theme: "dark", reduceMotion: false } },
|
|
135
|
+
{
|
|
136
|
+
item: flagsItem,
|
|
137
|
+
value: { newCheckout: true, paywallVariant: "variant-a" },
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
StorageScope.Disk,
|
|
141
|
+
);
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Transactional Balance Transfer
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
runTransaction(StorageScope.Disk, (tx) => {
|
|
148
|
+
const from = tx.getItem(fromBalanceItem);
|
|
149
|
+
|
|
150
|
+
if (from < 25) {
|
|
151
|
+
throw new Error("Insufficient balance");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
tx.setItem(fromBalanceItem, from - 25);
|
|
155
|
+
tx.setItem(toBalanceItem, tx.getItem(toBalanceItem) + 25);
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Custom Codec
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
type CompactFlag = {
|
|
163
|
+
id: number;
|
|
164
|
+
active: boolean;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const compactFlagItem = createStorageItem<CompactFlag>({
|
|
168
|
+
key: "compactFlag",
|
|
169
|
+
scope: StorageScope.Disk,
|
|
170
|
+
defaultValue: { id: 0, active: false },
|
|
171
|
+
serialize: (value) => `${value.id}|${value.active ? "1" : "0"}`,
|
|
172
|
+
deserialize: (value) => {
|
|
173
|
+
const [id, active] = value.split("|");
|
|
174
|
+
return { id: Number(id), active: active === "1" };
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Coalesced Writes
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const draftItem = createStorageItem({
|
|
183
|
+
key: "draft",
|
|
184
|
+
scope: StorageScope.Disk,
|
|
185
|
+
defaultValue: "",
|
|
186
|
+
coalesceDiskWrites: true,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
draftItem.set("first edit");
|
|
190
|
+
draftItem.set("second edit");
|
|
191
|
+
storage.flushDiskWrites();
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
For Secure scope:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
const tokenItem = createStorageItem({
|
|
198
|
+
key: "token",
|
|
199
|
+
scope: StorageScope.Secure,
|
|
200
|
+
defaultValue: "",
|
|
201
|
+
coalesceSecureWrites: true,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
tokenItem.set("token");
|
|
205
|
+
storage.flushSecureWrites();
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Raw Import
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
storage.import(
|
|
212
|
+
{
|
|
213
|
+
"settings:theme": "dark",
|
|
214
|
+
"settings:locale": "en-US",
|
|
215
|
+
},
|
|
216
|
+
StorageScope.Disk,
|
|
217
|
+
);
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Raw import writes strings exactly as provided. It does not run item serializers.
|
|
221
|
+
|
|
222
|
+
## Snapshot and Cleanup
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
const allDiskValues = storage.getAll(StorageScope.Disk);
|
|
226
|
+
const allDiskKeys = storage.getAllKeys(StorageScope.Disk);
|
|
227
|
+
|
|
228
|
+
storage.clearNamespace("settings", StorageScope.Disk);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Prefix Inspection
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
const flagKeys = storage.getKeysByPrefix("flags:", StorageScope.Disk);
|
|
235
|
+
const flagValues = storage.getByPrefix("flags:", StorageScope.Disk);
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Optimistic Writes
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const current = preferencesItem.getWithVersion();
|
|
242
|
+
|
|
243
|
+
const didWrite = preferencesItem.setIfVersion(current.version, {
|
|
244
|
+
...current.value,
|
|
245
|
+
theme: "dark",
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Metrics
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
storage.setMetricsObserver((event) => {
|
|
253
|
+
console.log(event.operation, event.scope, event.durationMs);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
preferencesItem.get();
|
|
257
|
+
|
|
258
|
+
const snapshot = storage.getMetricsSnapshot();
|
|
259
|
+
storage.resetMetrics();
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Capability Checks
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
const capabilities = storage.getCapabilities();
|
|
266
|
+
const security = storage.getSecurityCapabilities();
|
|
267
|
+
|
|
268
|
+
if (security.secureStorage !== "available") {
|
|
269
|
+
console.warn("Secure storage is not available on this runtime");
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Low-level Raw API
|
|
274
|
+
|
|
275
|
+
```ts
|
|
276
|
+
storage.setString("raw-key", "raw-value", StorageScope.Disk);
|
|
277
|
+
const rawValue = storage.getString("raw-key", StorageScope.Disk);
|
|
278
|
+
storage.deleteString("raw-key", StorageScope.Disk);
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Use raw APIs for migrations and integrations. Use `createStorageItem` for app state.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Secure Storage
|
|
2
|
+
|
|
3
|
+
Secure scope is for secrets: refresh tokens, credentials, API tokens, and device-bound keys. It uses iOS Keychain on iOS and Android Keystore-backed EncryptedSharedPreferences on Android.
|
|
4
|
+
|
|
5
|
+
Use Disk scope for non-secret persisted state. Secure storage has stronger boundaries but more platform rules, especially around biometric prompts, device lock state, and backup/restore behavior.
|
|
6
|
+
|
|
7
|
+
## Store a Secure Token
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import {
|
|
11
|
+
AccessControl,
|
|
12
|
+
createStorageItem,
|
|
13
|
+
StorageScope,
|
|
14
|
+
} from "react-native-nitro-storage";
|
|
15
|
+
|
|
16
|
+
export const refreshTokenItem = createStorageItem<string>({
|
|
17
|
+
key: "refreshToken",
|
|
18
|
+
namespace: "auth",
|
|
19
|
+
scope: StorageScope.Secure,
|
|
20
|
+
defaultValue: "",
|
|
21
|
+
accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
refreshTokenItem.set("opaque-refresh-token");
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Biometric Secrets
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import {
|
|
31
|
+
BiometricLevel,
|
|
32
|
+
createStorageItem,
|
|
33
|
+
StorageScope,
|
|
34
|
+
} from "react-native-nitro-storage";
|
|
35
|
+
|
|
36
|
+
export const recoveryCodeItem = createStorageItem<string>({
|
|
37
|
+
key: "recoveryCode",
|
|
38
|
+
namespace: "vault",
|
|
39
|
+
scope: StorageScope.Secure,
|
|
40
|
+
defaultValue: "",
|
|
41
|
+
biometric: true,
|
|
42
|
+
biometricLevel: BiometricLevel.BiometryOnly,
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`BiometricLevel.BiometryOnly` does not allow passcode fallback. Use `BiometricLevel.BiometryOrPasscode` when passcode fallback is acceptable.
|
|
47
|
+
|
|
48
|
+
## Access Control
|
|
49
|
+
|
|
50
|
+
`accessControl` maps to platform accessibility rules where available.
|
|
51
|
+
|
|
52
|
+
| Value | Use when |
|
|
53
|
+
| ---------------------------------------------- | ----------------------------------------------------------------- |
|
|
54
|
+
| `AccessControl.WhenUnlocked` | The secret should be readable only after the device is unlocked. |
|
|
55
|
+
| `AccessControl.AfterFirstUnlock` | Background refresh needs access after first unlock until restart. |
|
|
56
|
+
| `AccessControl.WhenPasscodeSetThisDeviceOnly` | The secret must stay on this device and require a passcode. |
|
|
57
|
+
| `AccessControl.WhenUnlockedThisDeviceOnly` | The secret should not migrate through backup/restore. |
|
|
58
|
+
| `AccessControl.AfterFirstUnlockThisDeviceOnly` | Background refresh is needed, but migration is not allowed. |
|
|
59
|
+
|
|
60
|
+
## Secure Auth Item Map
|
|
61
|
+
|
|
62
|
+
`createSecureAuthStorage()` creates a namespaced map of secure string items.
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import {
|
|
66
|
+
AccessControl,
|
|
67
|
+
BiometricLevel,
|
|
68
|
+
createSecureAuthStorage,
|
|
69
|
+
} from "react-native-nitro-storage";
|
|
70
|
+
|
|
71
|
+
export const authStorage = createSecureAuthStorage(
|
|
72
|
+
{
|
|
73
|
+
accessToken: { ttlMs: 15 * 60 * 1000 },
|
|
74
|
+
refreshToken: {
|
|
75
|
+
accessControl: AccessControl.AfterFirstUnlockThisDeviceOnly,
|
|
76
|
+
},
|
|
77
|
+
recoveryCode: {
|
|
78
|
+
biometric: true,
|
|
79
|
+
biometricLevel: BiometricLevel.BiometryOrPasscode,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{ namespace: "auth" },
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
authStorage.refreshToken.set("opaque-refresh-token");
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Runtime Capabilities
|
|
89
|
+
|
|
90
|
+
Use capability APIs to decide which support messages or diagnostics to show.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { storage } from "react-native-nitro-storage";
|
|
94
|
+
|
|
95
|
+
const capabilities = storage.getSecurityCapabilities();
|
|
96
|
+
|
|
97
|
+
if (capabilities.secureStorage === "available") {
|
|
98
|
+
// Secure scope is backed by the configured native or web secure backend.
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Capability fields are status values, not guarantees beyond the active backend. Hardware-backed storage is reported as `unknown` unless the platform can prove it.
|
|
103
|
+
|
|
104
|
+
## Metadata Without Values
|
|
105
|
+
|
|
106
|
+
Use metadata APIs when rendering diagnostics or support dumps where secret values must stay out of memory.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { storage } from "react-native-nitro-storage";
|
|
110
|
+
|
|
111
|
+
const oneKey = storage.getSecureMetadata("auth:refreshToken");
|
|
112
|
+
const allKeys = storage.getAllSecureMetadata();
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`getSecureMetadata()` and `getAllSecureMetadata()` never return stored secret values. They report key existence, storage kind, backend name, access-control metadata, and whether a metadata path accidentally exposed a value.
|
|
116
|
+
|
|
117
|
+
## Locked Keychain Errors
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { isKeychainLockedError } from "react-native-nitro-storage";
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
refreshTokenItem.get();
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (isKeychainLockedError(error)) {
|
|
126
|
+
// Defer token refresh until the device is unlocked.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The helper recognizes iOS locked Keychain cases and Android invalidated/locked key cases surfaced by the native bridge.
|
|
132
|
+
|
|
133
|
+
## Android Secure Write Mode
|
|
134
|
+
|
|
135
|
+
Android secure writes default to synchronous persistence. Enable async writes when write throughput is more important than immediate durability:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { storage } from "react-native-nitro-storage";
|
|
139
|
+
|
|
140
|
+
storage.setSecureWritesAsync(true);
|
|
141
|
+
refreshTokenItem.set("opaque-refresh-token");
|
|
142
|
+
storage.flushSecureWrites();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Call `flushSecureWrites()` before assertions, namespace clears, or any boundary that requires deterministic persistence.
|
|
146
|
+
|
|
147
|
+
## Web Secure Backend
|
|
148
|
+
|
|
149
|
+
Browsers cannot provide iOS Keychain or Android Keystore guarantees. On web, Secure scope is only as strong as the backend you configure.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { setWebSecureStorageBackend } from "react-native-nitro-storage";
|
|
153
|
+
import { createIndexedDBBackend } from "react-native-nitro-storage/indexeddb-backend";
|
|
154
|
+
|
|
155
|
+
const backend = await createIndexedDBBackend();
|
|
156
|
+
setWebSecureStorageBackend(backend);
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
See [web-backends.md](web-backends.md) for backend contracts and IndexedDB setup.
|
|
160
|
+
|
|
161
|
+
## Release Checks
|
|
162
|
+
|
|
163
|
+
Before releasing secure-storage changes, run:
|
|
164
|
+
|
|
165
|
+
```sh
|
|
166
|
+
bun run test -- --filter=react-native-nitro-storage
|
|
167
|
+
bun run test:cpp -- --filter=react-native-nitro-storage
|
|
168
|
+
bun run --cwd packages/react-native-nitro-storage check:pack
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Also run an end-to-end auth flow on a locked/unlocked real device when changing biometric or Keychain behavior.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Web Backends
|
|
2
|
+
|
|
3
|
+
Nitro Storage runs on web through synchronous backend contracts. Disk and Secure scopes can use different backends.
|
|
4
|
+
|
|
5
|
+
The default web backend is localStorage-style. Configure custom backends when you need IndexedDB persistence, tests with isolated storage, cross-tab sync, or a platform-specific secret wrapper.
|
|
6
|
+
|
|
7
|
+
## Backend Contract
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { WebStorageBackend } from "react-native-nitro-storage";
|
|
11
|
+
|
|
12
|
+
const backend: WebStorageBackend = {
|
|
13
|
+
name: "memory-test-backend",
|
|
14
|
+
getItem: (key) => map.get(key) ?? null,
|
|
15
|
+
setItem: (key, value) => {
|
|
16
|
+
map.set(key, value);
|
|
17
|
+
},
|
|
18
|
+
removeItem: (key) => {
|
|
19
|
+
map.delete(key);
|
|
20
|
+
},
|
|
21
|
+
clear: () => {
|
|
22
|
+
map.clear();
|
|
23
|
+
},
|
|
24
|
+
getAllKeys: () => Array.from(map.keys()),
|
|
25
|
+
};
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Optional methods improve performance and observability:
|
|
29
|
+
|
|
30
|
+
- `getMany(keys)`
|
|
31
|
+
- `setMany(entries)`
|
|
32
|
+
- `removeMany(keys)`
|
|
33
|
+
- `size()`
|
|
34
|
+
- `subscribe(listener)`
|
|
35
|
+
- `flush()`
|
|
36
|
+
- `name`
|
|
37
|
+
|
|
38
|
+
`subscribe(listener)` should report `{ key, newValue }` changes. Use `key: null` when the whole backend is cleared.
|
|
39
|
+
|
|
40
|
+
## Disk Backend
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import {
|
|
44
|
+
setWebDiskStorageBackend,
|
|
45
|
+
storage,
|
|
46
|
+
StorageScope,
|
|
47
|
+
} from "react-native-nitro-storage";
|
|
48
|
+
|
|
49
|
+
setWebDiskStorageBackend(backend);
|
|
50
|
+
storage.setString("theme", "dark", StorageScope.Disk);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Secure Backend
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import {
|
|
57
|
+
setWebSecureStorageBackend,
|
|
58
|
+
storage,
|
|
59
|
+
StorageScope,
|
|
60
|
+
} from "react-native-nitro-storage";
|
|
61
|
+
|
|
62
|
+
setWebSecureStorageBackend(backend);
|
|
63
|
+
storage.setString("auth:refreshToken", "opaque-token", StorageScope.Secure);
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Web Secure storage is only as strong as the configured backend. Browser storage does not provide iOS Keychain or Android Keystore guarantees.
|
|
67
|
+
|
|
68
|
+
## Flush Pending Web Writes
|
|
69
|
+
|
|
70
|
+
Backends may persist asynchronously while serving reads synchronously from memory. Use `flushWebStorageBackends()` before assertions or page lifecycle boundaries.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { flushWebStorageBackends } from "react-native-nitro-storage";
|
|
74
|
+
|
|
75
|
+
await flushWebStorageBackends();
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## IndexedDB Secure Backend
|
|
79
|
+
|
|
80
|
+
`createIndexedDBBackend()` returns a `WebSecureStorageBackend` with a synchronous in-memory cache and asynchronous IndexedDB persistence.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { setWebSecureStorageBackend } from "react-native-nitro-storage";
|
|
84
|
+
import { createIndexedDBBackend } from "react-native-nitro-storage/indexeddb-backend";
|
|
85
|
+
|
|
86
|
+
const backend = await createIndexedDBBackend("app-secure", "keyvalue", {
|
|
87
|
+
channelName: "app-secure-sync",
|
|
88
|
+
onError: (error) => {
|
|
89
|
+
console.error("IndexedDB secure storage failed", error);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
setWebSecureStorageBackend(backend);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Reads are synchronous because they are served from memory after initial load. Writes update memory first and persist to IndexedDB in the background.
|
|
97
|
+
|
|
98
|
+
## Cross-tab Updates
|
|
99
|
+
|
|
100
|
+
The IndexedDB backend uses `BroadcastChannel` when available. Other tabs receive cache invalidation events and update their in-memory copy.
|
|
101
|
+
|
|
102
|
+
If you provide your own backend, implement `subscribe(listener)` to keep Nitro Storage caches aligned with external writes.
|
|
103
|
+
|
|
104
|
+
## Testing Backend
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import type { WebStorageBackend } from "react-native-nitro-storage";
|
|
108
|
+
|
|
109
|
+
export function createMemoryBackend(): WebStorageBackend {
|
|
110
|
+
const values = new Map<string, string>();
|
|
111
|
+
const listeners = new Set<
|
|
112
|
+
(event: { key: string | null; newValue: string | null }) => void
|
|
113
|
+
>();
|
|
114
|
+
|
|
115
|
+
function emit(key: string | null, newValue: string | null) {
|
|
116
|
+
listeners.forEach((listener) => listener({ key, newValue }));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
name: "memory",
|
|
121
|
+
getItem: (key) => values.get(key) ?? null,
|
|
122
|
+
setItem: (key, value) => {
|
|
123
|
+
values.set(key, value);
|
|
124
|
+
emit(key, value);
|
|
125
|
+
},
|
|
126
|
+
removeItem: (key) => {
|
|
127
|
+
values.delete(key);
|
|
128
|
+
emit(key, null);
|
|
129
|
+
},
|
|
130
|
+
clear: () => {
|
|
131
|
+
values.clear();
|
|
132
|
+
emit(null, null);
|
|
133
|
+
},
|
|
134
|
+
getAllKeys: () => Array.from(values.keys()),
|
|
135
|
+
subscribe: (listener) => {
|
|
136
|
+
listeners.add(listener);
|
|
137
|
+
return () => listeners.delete(listener);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
```
|
package/lib/commonjs/index.js
CHANGED
|
@@ -113,6 +113,7 @@ let secureFlushScheduled = false;
|
|
|
113
113
|
let secureDefaultAccessControl = _Storage.AccessControl.WhenUnlocked;
|
|
114
114
|
let metricsObserver;
|
|
115
115
|
const metricsCounters = new Map();
|
|
116
|
+
const nativeSecureBackend = "platform-secure-storage";
|
|
116
117
|
function recordMetric(operation, scope, durationMs, keysCount = 1) {
|
|
117
118
|
const existing = metricsCounters.get(operation);
|
|
118
119
|
if (!existing) {
|
|
@@ -654,7 +655,7 @@ const storage = exports.storage = {
|
|
|
654
655
|
platform: "native",
|
|
655
656
|
backend: {
|
|
656
657
|
disk: "platform-preferences",
|
|
657
|
-
secure:
|
|
658
|
+
secure: nativeSecureBackend
|
|
658
659
|
},
|
|
659
660
|
writeBuffering: {
|
|
660
661
|
disk: true,
|
|
@@ -662,6 +663,55 @@ const storage = exports.storage = {
|
|
|
662
663
|
},
|
|
663
664
|
errorClassification: true
|
|
664
665
|
}),
|
|
666
|
+
getSecurityCapabilities: () => ({
|
|
667
|
+
platform: "native",
|
|
668
|
+
secureStorage: {
|
|
669
|
+
backend: nativeSecureBackend,
|
|
670
|
+
encrypted: "available",
|
|
671
|
+
accessControl: "unknown",
|
|
672
|
+
keychainAccessGroup: "unknown",
|
|
673
|
+
hardwareBacked: "unknown"
|
|
674
|
+
},
|
|
675
|
+
biometric: {
|
|
676
|
+
storage: "unknown",
|
|
677
|
+
prompt: "unknown",
|
|
678
|
+
biometryOnly: "unknown",
|
|
679
|
+
biometryOrPasscode: "unknown"
|
|
680
|
+
},
|
|
681
|
+
metadata: {
|
|
682
|
+
perKey: true,
|
|
683
|
+
listsWithoutValues: true,
|
|
684
|
+
persistsTimestamps: false
|
|
685
|
+
}
|
|
686
|
+
}),
|
|
687
|
+
getSecureMetadata: key => {
|
|
688
|
+
return measureOperation("storage:getSecureMetadata", _Storage.StorageScope.Secure, () => {
|
|
689
|
+
flushSecureWrites();
|
|
690
|
+
const storageModule = getStorageModule();
|
|
691
|
+
const biometricProtected = storageModule.hasSecureBiometric(key);
|
|
692
|
+
const exists = biometricProtected || storageModule.has(key, _Storage.StorageScope.Secure);
|
|
693
|
+
let kind = "missing";
|
|
694
|
+
if (exists) {
|
|
695
|
+
kind = biometricProtected ? "biometric" : "secure";
|
|
696
|
+
}
|
|
697
|
+
return {
|
|
698
|
+
key,
|
|
699
|
+
exists,
|
|
700
|
+
kind,
|
|
701
|
+
backend: nativeSecureBackend,
|
|
702
|
+
encrypted: "available",
|
|
703
|
+
hardwareBacked: "unknown",
|
|
704
|
+
biometricProtected,
|
|
705
|
+
valueExposed: false
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
},
|
|
709
|
+
getAllSecureMetadata: () => {
|
|
710
|
+
return measureOperation("storage:getAllSecureMetadata", _Storage.StorageScope.Secure, () => {
|
|
711
|
+
flushSecureWrites();
|
|
712
|
+
return getStorageModule().getAllKeys(_Storage.StorageScope.Secure).map(key => storage.getSecureMetadata(key));
|
|
713
|
+
});
|
|
714
|
+
},
|
|
665
715
|
getString: (key, scope) => {
|
|
666
716
|
return measureOperation("storage:getString", scope, () => {
|
|
667
717
|
return getRawValue(key, scope);
|