@ursalock/zustand 0.2.8 → 0.3.1

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/dist/index.d.ts CHANGED
@@ -142,6 +142,21 @@ interface SyncOptions {
142
142
  onStatusChange?: (status: SyncStatus) => void;
143
143
  /** HTTP client for making requests (default: FetchHttpClient) */
144
144
  httpClient?: IHttpClient;
145
+ /** Storage provider for offline queue (default: localStorage) */
146
+ storageProvider?: {
147
+ getItem(key: string): string | null;
148
+ setItem(key: string, value: string): void;
149
+ };
150
+ /**
151
+ * HMAC key for sync integrity verification (Encrypt-then-MAC).
152
+ * When provided, every push includes an HMAC-SHA256 tag over the
153
+ * ciphertext; every pull verifies it before passing data to the
154
+ * decryption layer. This detects server-side tampering.
155
+ *
156
+ * Should be derived from the user's master key via a separate
157
+ * derivation path (key separation principle).
158
+ */
159
+ hmacKey?: Uint8Array;
145
160
  }
146
161
  /**
147
162
  * Create a sync engine instance
@@ -254,6 +269,8 @@ type StoreVault<S, Ps> = S extends {
254
269
  hasPendingChanges: () => boolean;
255
270
  /** Clear all stored data (local + server) */
256
271
  clearStorage: () => Promise<void>;
272
+ /** Clean up sync interval and timers */
273
+ destroy: () => void;
257
274
  /** Subscribe to hydration start */
258
275
  onHydrate: (fn: VaultListener<T>) => () => void;
259
276
  /** Subscribe to hydration complete */
@@ -349,5 +366,33 @@ declare function useSyncStatus<T extends {
349
366
  getSyncStatus?: () => SyncStatus;
350
367
  };
351
368
  }>(useStore: () => T): SyncStatus;
369
+ /**
370
+ * Hook to check if store has been hydrated
371
+ *
372
+ * @example
373
+ * ```tsx
374
+ * const hydrated = useHydrated(useStore)
375
+ * if (!hydrated) return <Loading />
376
+ * ```
377
+ */
378
+ declare function useHydrated<T extends {
379
+ vault?: {
380
+ hasHydrated?: () => boolean;
381
+ };
382
+ }>(useStore: () => T): boolean;
383
+ /**
384
+ * Hook to check if there are pending offline changes
385
+ *
386
+ * @example
387
+ * ```tsx
388
+ * const hasPending = usePendingChanges(useStore)
389
+ * if (hasPending) return <PendingBadge />
390
+ * ```
391
+ */
392
+ declare function usePendingChanges<T extends {
393
+ vault?: {
394
+ hasPendingChanges?: () => boolean;
395
+ };
396
+ }>(useStore: () => T): boolean;
352
397
 
353
- export { type EncryptedStorageOptions, FetchHttpClient, type IHttpClient, type IHttpRequest, type IHttpResponse, type IStorageProvider, type IVaultStorage, type JwkEncryptedStorageOptions, type LegacyEncryptedStorageOptions, LocalStorageProvider, type SyncEngine, type SyncState, type SyncStatus, type VaultOptions, type VaultOptionsJwk, type VaultOptionsLegacy, type VaultStorage, createSyncEngine, createVaultStorage, useSyncStatus, vault };
398
+ export { type EncryptedStorageOptions, FetchHttpClient, type IHttpClient, type IHttpRequest, type IHttpResponse, type IStorageProvider, type IVaultStorage, type JwkEncryptedStorageOptions, type LegacyEncryptedStorageOptions, LocalStorageProvider, type SyncEngine, type SyncState, type SyncStatus, type VaultOptions, type VaultOptionsJwk, type VaultOptionsLegacy, type VaultStorage, createSyncEngine, createVaultStorage, useHydrated, usePendingChanges, useSyncStatus, vault };
package/dist/index.js CHANGED
@@ -4,8 +4,10 @@ import {
4
4
  decrypt,
5
5
  deriveKey,
6
6
  recoveryKeyToBytes,
7
+ validateRecoveryKey,
7
8
  encryptWithJwk,
8
- decryptWithJwk
9
+ decryptWithJwk,
10
+ constantTimeEqual
9
11
  } from "@ursalock/crypto";
10
12
 
11
13
  // src/providers/local-storage.ts
@@ -77,6 +79,9 @@ function createJwkStorage(cipherJwk, storage, prefix) {
77
79
  };
78
80
  }
79
81
  function createLegacyStorage(recoveryKey, storage, prefix) {
82
+ if (!validateRecoveryKey(recoveryKey)) {
83
+ throw new Error("[ursalock] Invalid recovery key format");
84
+ }
80
85
  let cachedKey = null;
81
86
  let cachedSalt = null;
82
87
  async function getOrDeriveKey(salt) {
@@ -151,13 +156,7 @@ function base64ToBytes(base64) {
151
156
  }
152
157
  return bytes;
153
158
  }
154
- function arrayEqual(a, b) {
155
- if (a.length !== b.length) return false;
156
- for (let i = 0; i < a.length; i++) {
157
- if (a[i] !== b[i]) return false;
158
- }
159
- return true;
160
- }
159
+ var arrayEqual = constantTimeEqual;
161
160
 
162
161
  // src/providers/fetch-http.ts
163
162
  var FetchHttpClient = class {
@@ -177,6 +176,7 @@ var FetchHttpClient = class {
177
176
  };
178
177
 
179
178
  // src/sync.ts
179
+ import { computeHmac, verifyHmac } from "@ursalock/crypto";
180
180
  var QUEUE_KEY = "ursalock:offline-queue";
181
181
  function createSyncEngine(options) {
182
182
  const {
@@ -186,29 +186,34 @@ function createSyncEngine(options) {
186
186
  onServerData,
187
187
  getLocalData,
188
188
  onStatusChange,
189
- httpClient = new FetchHttpClient()
189
+ httpClient = new FetchHttpClient(),
190
+ storageProvider,
191
+ hmacKey
190
192
  } = options;
193
+ const textEncoder = new TextEncoder();
194
+ const queueStorage = storageProvider ?? (typeof localStorage !== "undefined" ? localStorage : null);
191
195
  let status = "idle";
192
196
  let lastSyncAt = null;
193
197
  let error = null;
198
+ let knownServerVersion = null;
194
199
  const setStatus = (newStatus, newError) => {
195
200
  status = newStatus;
196
201
  error = newError ?? null;
197
202
  onStatusChange?.(newStatus);
198
203
  };
199
204
  const loadQueue = () => {
200
- if (typeof localStorage === "undefined") return { pending: [] };
205
+ if (!queueStorage) return { pending: [] };
201
206
  try {
202
- const stored = localStorage.getItem(`${QUEUE_KEY}:${name}`);
207
+ const stored = queueStorage.getItem(`${QUEUE_KEY}:${name}`);
203
208
  return stored ? JSON.parse(stored) : { pending: [] };
204
209
  } catch {
205
210
  return { pending: [] };
206
211
  }
207
212
  };
208
213
  const saveQueue = (queue) => {
209
- if (typeof localStorage === "undefined") return;
214
+ if (!queueStorage) return;
210
215
  try {
211
- localStorage.setItem(`${QUEUE_KEY}:${name}`, JSON.stringify(queue));
216
+ queueStorage.setItem(`${QUEUE_KEY}:${name}`, JSON.stringify(queue));
212
217
  } catch {
213
218
  }
214
219
  };
@@ -228,7 +233,10 @@ function createSyncEngine(options) {
228
233
  };
229
234
  const fetchServer = async () => {
230
235
  const token = getToken();
231
- if (!token) return null;
236
+ if (!token) {
237
+ console.warn("[ursalock] fetchServer: no auth token available, skipping");
238
+ return null;
239
+ }
232
240
  const res = await httpClient.request({
233
241
  url: `${serverUrl}/vault/by-name/${encodeURIComponent(name)}`,
234
242
  method: "GET",
@@ -239,17 +247,47 @@ function createSyncEngine(options) {
239
247
  });
240
248
  if (res.status === 404) return null;
241
249
  if (!res.ok) throw new Error(`Server error: ${res.status}`);
242
- return res.json();
250
+ const vault2 = await res.json();
251
+ knownServerVersion = vault2.version;
252
+ return vault2;
253
+ };
254
+ const computeTag = async (data) => {
255
+ if (!hmacKey) return void 0;
256
+ return computeHmac(textEncoder.encode(data), hmacKey);
257
+ };
258
+ const verifyTag = async (vault2) => {
259
+ if (!hmacKey) return;
260
+ if (!vault2.hmac) {
261
+ console.warn(
262
+ "[ursalock] Server vault has no HMAC tag. This is expected for vaults created before integrity verification was enabled. The vault will be re-signed on next push."
263
+ );
264
+ return;
265
+ }
266
+ const valid = await verifyHmac(
267
+ textEncoder.encode(vault2.data),
268
+ hmacKey,
269
+ vault2.hmac
270
+ );
271
+ if (!valid) {
272
+ throw new Error(
273
+ "[ursalock] HMAC verification failed: server data has been tampered with or the integrity key is wrong"
274
+ );
275
+ }
243
276
  };
244
277
  const pushServer = async (data, salt) => {
245
278
  const token = getToken();
246
279
  if (!token) throw new Error("Not authenticated");
280
+ const hmac = await computeTag(data);
247
281
  let existing = null;
248
282
  try {
249
283
  existing = await fetchServer();
250
284
  } catch {
251
285
  }
252
286
  if (existing) {
287
+ const body = { data, salt, ...hmac != null && { hmac } };
288
+ if (knownServerVersion != null) {
289
+ body.version = knownServerVersion;
290
+ }
253
291
  const res = await httpClient.request({
254
292
  url: `${serverUrl}/vault/${existing.uid}`,
255
293
  method: "PUT",
@@ -257,13 +295,48 @@ function createSyncEngine(options) {
257
295
  "Authorization": `Bearer ${token}`,
258
296
  "Content-Type": "application/json"
259
297
  },
260
- body: JSON.stringify({ data, salt })
298
+ body: JSON.stringify(body)
261
299
  });
300
+ if (res.status === 409) {
301
+ const latest = await fetchServer();
302
+ if (latest) {
303
+ await verifyTag(latest);
304
+ onServerData(latest.data, latest.salt, latest.updatedAt);
305
+ const retryLocal = getLocalData();
306
+ const retryHmac = await computeTag(retryLocal.data);
307
+ const retryBody = {
308
+ data: retryLocal.data,
309
+ salt: retryLocal.salt,
310
+ ...retryHmac != null && { hmac: retryHmac }
311
+ };
312
+ if (knownServerVersion != null) {
313
+ retryBody.version = knownServerVersion;
314
+ }
315
+ const retryRes = await httpClient.request({
316
+ url: `${serverUrl}/vault/${existing.uid}`,
317
+ method: "PUT",
318
+ headers: {
319
+ "Authorization": `Bearer ${token}`,
320
+ "Content-Type": "application/json"
321
+ },
322
+ body: JSON.stringify(retryBody)
323
+ });
324
+ if (!retryRes.ok) {
325
+ const errorText = await retryRes.text().catch(() => "");
326
+ throw new Error(`Server error: ${retryRes.status} ${errorText}`);
327
+ }
328
+ const result3 = await retryRes.json();
329
+ knownServerVersion = result3.version;
330
+ return result3;
331
+ }
332
+ }
262
333
  if (!res.ok) {
263
334
  const errorText = await res.text().catch(() => "");
264
335
  throw new Error(`Server error: ${res.status} ${errorText}`);
265
336
  }
266
- return res.json();
337
+ const result2 = await res.json();
338
+ knownServerVersion = result2.version;
339
+ return result2;
267
340
  }
268
341
  const createRes = await httpClient.request({
269
342
  url: `${serverUrl}/vault`,
@@ -272,13 +345,17 @@ function createSyncEngine(options) {
272
345
  "Authorization": `Bearer ${token}`,
273
346
  "Content-Type": "application/json"
274
347
  },
275
- body: JSON.stringify({ name, data, salt })
348
+ body: JSON.stringify({ name, data, salt, ...hmac != null && { hmac } })
276
349
  });
277
350
  if (createRes.status === 409) {
278
351
  const nowExisting = await fetchServer();
279
352
  if (!nowExisting) {
280
353
  throw new Error("Vault conflict but not found on retry");
281
354
  }
355
+ const retryBody = { data, salt, ...hmac != null && { hmac } };
356
+ if (knownServerVersion != null) {
357
+ retryBody.version = knownServerVersion;
358
+ }
282
359
  const retryRes = await httpClient.request({
283
360
  url: `${serverUrl}/vault/${nowExisting.uid}`,
284
361
  method: "PUT",
@@ -286,19 +363,23 @@ function createSyncEngine(options) {
286
363
  "Authorization": `Bearer ${token}`,
287
364
  "Content-Type": "application/json"
288
365
  },
289
- body: JSON.stringify({ data, salt })
366
+ body: JSON.stringify(retryBody)
290
367
  });
291
368
  if (!retryRes.ok) {
292
369
  const errorText = await retryRes.text().catch(() => "");
293
370
  throw new Error(`Server error: ${retryRes.status} ${errorText}`);
294
371
  }
295
- return retryRes.json();
372
+ const result2 = await retryRes.json();
373
+ knownServerVersion = result2.version;
374
+ return result2;
296
375
  }
297
376
  if (!createRes.ok) {
298
377
  const errorText = await createRes.text().catch(() => "");
299
378
  throw new Error(`Server error: ${createRes.status} ${errorText}`);
300
379
  }
301
- return createRes.json();
380
+ const result = await createRes.json();
381
+ knownServerVersion = result.version;
382
+ return result;
302
383
  };
303
384
  const sync = async () => {
304
385
  if (!isOnline()) {
@@ -322,6 +403,7 @@ function createSyncEngine(options) {
322
403
  await pushServer(local.data, local.salt);
323
404
  }
324
405
  } else if (server.updatedAt > local.updatedAt) {
406
+ await verifyTag(server);
325
407
  onServerData(server.data, server.salt, server.updatedAt);
326
408
  } else if (local.updatedAt > server.updatedAt) {
327
409
  await pushServer(local.data, local.salt);
@@ -370,6 +452,7 @@ function createSyncEngine(options) {
370
452
  if (server) {
371
453
  const local = getLocalData();
372
454
  if (server.updatedAt > local.updatedAt) {
455
+ await verifyTag(server);
373
456
  onServerData(server.data, server.salt, server.updatedAt);
374
457
  lastSyncAt = Date.now();
375
458
  setStatus("synced");
@@ -401,6 +484,7 @@ function createSyncEngine(options) {
401
484
  }
402
485
 
403
486
  // src/vault.ts
487
+ var logCatch = (context) => (err) => console.error(`[ursalock] ${context}:`, err);
404
488
  function isJwkMode2(options) {
405
489
  return "cipherJwk" in options;
406
490
  }
@@ -441,7 +525,7 @@ var vaultImpl = (config, baseOptions) => (set, get, api) => {
441
525
  getToken,
442
526
  onServerData: (data, _salt, updatedAt) => {
443
527
  if (localUpdatedAt > updatedAt) {
444
- void syncEngine?.push();
528
+ void syncEngine?.push().catch(logCatch("Push after local-newer conflict"));
445
529
  return;
446
530
  }
447
531
  try {
@@ -449,7 +533,7 @@ var vaultImpl = (config, baseOptions) => (set, get, api) => {
449
533
  const merged = merge(parsed, get());
450
534
  set(merged, true);
451
535
  localUpdatedAt = updatedAt;
452
- void storage.setItem(name, JSON.stringify(partialize({ ...get() })));
536
+ void storage.setItem(name, JSON.stringify(partialize({ ...get() }))).catch(logCatch("Persist server data to local storage"));
453
537
  } catch (err) {
454
538
  console.error("[ursalock] Failed to parse server data:", err);
455
539
  }
@@ -475,7 +559,7 @@ var vaultImpl = (config, baseOptions) => (set, get, api) => {
475
559
  }
476
560
  syncDebounceTimer = setTimeout(() => {
477
561
  syncDebounceTimer = null;
478
- void syncEngine?.sync();
562
+ void syncEngine?.sync().catch(logCatch("Debounced sync"));
479
563
  }, 3e3);
480
564
  }
481
565
  };
@@ -529,7 +613,7 @@ var vaultImpl = (config, baseOptions) => (set, get, api) => {
529
613
  } else {
530
614
  savedSetState(state);
531
615
  }
532
- void persistState();
616
+ void persistState().catch(logCatch("Persist state"));
533
617
  });
534
618
  const configResult = config(
535
619
  ((partial, replace) => {
@@ -538,7 +622,7 @@ var vaultImpl = (config, baseOptions) => (set, get, api) => {
538
622
  } else {
539
623
  set(partial);
540
624
  }
541
- void persistState();
625
+ void persistState().catch(logCatch("Persist state"));
542
626
  }),
543
627
  get,
544
628
  api
@@ -565,15 +649,27 @@ var vaultImpl = (config, baseOptions) => (set, get, api) => {
565
649
  if (!skipHydration) {
566
650
  void rehydrate().then(() => {
567
651
  if (syncEngine) {
568
- void syncEngine.sync();
652
+ void syncEngine.sync().catch(logCatch("Initial sync after hydration"));
569
653
  }
570
- });
654
+ }).catch(logCatch("Auto-rehydration"));
571
655
  } else {
572
656
  hasHydrated = true;
573
657
  }
658
+ let syncIntervalId = null;
574
659
  if (server && syncInterval > 0) {
575
- setInterval(() => void sync(), syncInterval);
660
+ syncIntervalId = setInterval(() => void sync().catch(logCatch("Periodic sync")), syncInterval);
576
661
  }
662
+ const vaultApi = storeWithVault.vault;
663
+ vaultApi.destroy = () => {
664
+ if (syncIntervalId) {
665
+ clearInterval(syncIntervalId);
666
+ syncIntervalId = null;
667
+ }
668
+ if (syncDebounceTimer) {
669
+ clearTimeout(syncDebounceTimer);
670
+ syncDebounceTimer = null;
671
+ }
672
+ };
577
673
  return configResult;
578
674
  };
579
675
  var vault = vaultImpl;
@@ -583,11 +679,21 @@ function useSyncStatus(useStore) {
583
679
  const store = useStore();
584
680
  return store.vault?.getSyncStatus?.() ?? "idle";
585
681
  }
682
+ function useHydrated(useStore) {
683
+ const store = useStore();
684
+ return store.vault?.hasHydrated?.() ?? false;
685
+ }
686
+ function usePendingChanges(useStore) {
687
+ const store = useStore();
688
+ return store.vault?.hasPendingChanges?.() ?? false;
689
+ }
586
690
  export {
587
691
  FetchHttpClient,
588
692
  LocalStorageProvider,
589
693
  createSyncEngine,
590
694
  createVaultStorage,
695
+ useHydrated,
696
+ usePendingChanges,
591
697
  useSyncStatus,
592
698
  vault
593
699
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ursalock/zustand",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
4
4
  "description": "Encrypted persistence middleware for Zustand with passkey E2EE",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "dependencies": {
25
- "@ursalock/crypto": "^0.2.0"
25
+ "@ursalock/crypto": "^0.3.1"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "zustand": ">=4.0.0"