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.
@@ -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
+ ```
@@ -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: "platform-secure-storage"
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);