@ursalock/zustand 0.2.8

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,353 @@
1
+ import { StoreMutatorIdentifier, StateCreator } from 'zustand';
2
+ import { CipherJWK } from '@ursalock/crypto';
3
+
4
+ /**
5
+ * Storage interfaces for vault middleware
6
+ * Follows Dependency Inversion Principle - depend on abstractions not concretions
7
+ */
8
+ /**
9
+ * Base storage provider interface
10
+ * Abstracts away the underlying storage mechanism (localStorage, AsyncStorage, etc.)
11
+ */
12
+ interface IStorageProvider {
13
+ /** Get item from storage */
14
+ getItem(key: string): Promise<string | null>;
15
+ /** Set item in storage */
16
+ setItem(key: string, value: string): Promise<void>;
17
+ /** Remove item from storage */
18
+ removeItem(key: string): Promise<void>;
19
+ }
20
+ /**
21
+ * Encrypted vault storage interface
22
+ * Higher-level abstraction that handles encryption/decryption
23
+ */
24
+ interface IVaultStorage {
25
+ /** Get decrypted state from storage */
26
+ getItem(name: string): Promise<string | null>;
27
+ /** Set encrypted state in storage */
28
+ setItem(name: string, value: string): Promise<void>;
29
+ /** Remove state from storage */
30
+ removeItem(name: string): Promise<void>;
31
+ }
32
+
33
+ /**
34
+ * Encrypted storage layer for vault middleware
35
+ * Supports both legacy recovery key and new CipherJWK encryption
36
+ *
37
+ * Refactored to follow SOLID principles:
38
+ * - Uses IStorageProvider interface (Dependency Inversion)
39
+ * - Separates encryption concerns from storage access (Single Responsibility)
40
+ * - Injectable storage provider for testing
41
+ */
42
+
43
+ interface VaultStorage extends IVaultStorage {
44
+ }
45
+
46
+ /** Legacy options using recovery key string */
47
+ interface LegacyEncryptedStorageOptions {
48
+ /** Recovery key for encryption (legacy mode) */
49
+ recoveryKey: string;
50
+ /** Underlying storage provider (default: LocalStorageProvider) */
51
+ storageProvider?: IStorageProvider;
52
+ /** Key prefix in storage */
53
+ prefix?: string;
54
+ /** @deprecated Use storageProvider instead. For backward compatibility with VaultStorage */
55
+ storage?: VaultStorage;
56
+ }
57
+ /** New options using CipherJWK directly */
58
+ interface JwkEncryptedStorageOptions {
59
+ /** CipherJWK for encryption (from ZKCredentials) */
60
+ cipherJwk: CipherJWK;
61
+ /** Underlying storage provider (default: LocalStorageProvider) */
62
+ storageProvider?: IStorageProvider;
63
+ /** Key prefix in storage */
64
+ prefix?: string;
65
+ /** @deprecated Use storageProvider instead. For backward compatibility with VaultStorage */
66
+ storage?: VaultStorage;
67
+ }
68
+ type EncryptedStorageOptions = LegacyEncryptedStorageOptions | JwkEncryptedStorageOptions;
69
+ /**
70
+ * Create an encrypted storage wrapper
71
+ * Supports both legacy recovery key and new CipherJWK modes
72
+ *
73
+ * Uses dependency injection for storage provider (Dependency Inversion Principle)
74
+ */
75
+ declare function createVaultStorage(options: EncryptedStorageOptions): VaultStorage;
76
+
77
+ /**
78
+ * HTTP client interface for sync engine
79
+ * Follows Dependency Inversion Principle - allows mocking and alternative implementations
80
+ */
81
+ /** HTTP request options */
82
+ interface IHttpRequest {
83
+ url: string;
84
+ method: "GET" | "POST" | "PUT" | "DELETE";
85
+ headers?: Record<string, string>;
86
+ body?: string;
87
+ }
88
+ /** HTTP response */
89
+ interface IHttpResponse {
90
+ ok: boolean;
91
+ status: number;
92
+ json<T = unknown>(): Promise<T>;
93
+ text(): Promise<string>;
94
+ }
95
+ /** HTTP client interface */
96
+ interface IHttpClient {
97
+ /**
98
+ * Make an HTTP request
99
+ * @param request Request options
100
+ * @returns Response
101
+ */
102
+ request(request: IHttpRequest): Promise<IHttpResponse>;
103
+ }
104
+
105
+ /**
106
+ * Sync engine for vault middleware
107
+ * Handles bidirectional sync with server + offline queue
108
+ *
109
+ * Refactored to follow SOLID principles:
110
+ * - IHttpClient interface (Dependency Inversion)
111
+ * - Separated offline queue logic (Single Responsibility)
112
+ * - Injectable HTTP client for testing
113
+ */
114
+
115
+ type SyncStatus = "idle" | "syncing" | "synced" | "error" | "offline";
116
+ interface SyncState {
117
+ /** Last successful sync timestamp */
118
+ lastSyncAt: number | null;
119
+ /** Current sync status */
120
+ status: SyncStatus;
121
+ /** Pending changes waiting to be synced */
122
+ pendingChanges: boolean;
123
+ /** Last error message */
124
+ error: string | null;
125
+ }
126
+ interface SyncOptions {
127
+ /** Server base URL */
128
+ serverUrl: string;
129
+ /** Vault name */
130
+ name: string;
131
+ /** Auth token getter */
132
+ getToken: () => string | null;
133
+ /** Called when server has newer data */
134
+ onServerData: (data: string, salt: string, updatedAt: number) => void;
135
+ /** Get current local data */
136
+ getLocalData: () => {
137
+ data: string;
138
+ salt: string;
139
+ updatedAt: number;
140
+ };
141
+ /** Called on sync status change */
142
+ onStatusChange?: (status: SyncStatus) => void;
143
+ /** HTTP client for making requests (default: FetchHttpClient) */
144
+ httpClient?: IHttpClient;
145
+ }
146
+ /**
147
+ * Create a sync engine instance
148
+ * Uses dependency injection for HTTP client (Dependency Inversion Principle)
149
+ */
150
+ declare function createSyncEngine(options: SyncOptions): {
151
+ sync: () => Promise<void>;
152
+ push: () => Promise<void>;
153
+ pull: () => Promise<boolean>;
154
+ getState: () => SyncState;
155
+ clearQueue: () => void;
156
+ };
157
+ type SyncEngine = ReturnType<typeof createSyncEngine>;
158
+
159
+ /**
160
+ * vault() middleware - Drop-in replacement for persist()
161
+ * Adds E2EE encryption and cloud sync to Zustand stores
162
+ *
163
+ * Supports two encryption modes:
164
+ * - Legacy: recoveryKey string (Argon2id key derivation)
165
+ * - New: cipherJwk from ZKCredentials (PRF-derived)
166
+ *
167
+ * Type pattern follows zustand's persist middleware:
168
+ * - Simple internal implementation type (VaultImpl)
169
+ * - Complex public API type (Vault)
170
+ * - Cast at export: `vaultImpl as unknown as Vault`
171
+ */
172
+
173
+ /** Base vault middleware options */
174
+ interface VaultOptionsBase<S, PersistedState = S> {
175
+ /** Unique name for this vault (used as storage key) */
176
+ name: string;
177
+ /**
178
+ * Server URL for sync (optional)
179
+ * If not provided, only local encrypted storage is used
180
+ */
181
+ server?: string;
182
+ /**
183
+ * Auth token getter for server sync
184
+ * Required if server is provided
185
+ */
186
+ getToken?: () => string | null;
187
+ /** Custom storage implementation */
188
+ storage?: VaultStorage;
189
+ /** Storage key prefix (default: 'ursalock:') */
190
+ prefix?: string;
191
+ /**
192
+ * Partial state to persist
193
+ * @default (state) => state (persist everything)
194
+ */
195
+ partialize?: (state: S) => PersistedState;
196
+ /**
197
+ * Merge function for rehydration
198
+ * @default Object.assign
199
+ */
200
+ merge?: (persistedState: unknown, currentState: S) => S;
201
+ /**
202
+ * Called when state is loaded from storage
203
+ */
204
+ onRehydrateStorage?: (state: S) => ((state?: S, error?: unknown) => void) | void;
205
+ /**
206
+ * Skip hydration on init (useful for SSR)
207
+ * Call rehydrate() manually when ready
208
+ */
209
+ skipHydration?: boolean;
210
+ /**
211
+ * Sync interval in ms (default: 30000 = 30s)
212
+ * Set to 0 to disable auto-sync
213
+ */
214
+ syncInterval?: number;
215
+ }
216
+ /** Legacy options using recovery key string */
217
+ interface VaultOptionsLegacy<S, PersistedState = S> extends VaultOptionsBase<S, PersistedState> {
218
+ /** Recovery key for E2EE encryption (legacy mode) */
219
+ recoveryKey: string;
220
+ }
221
+ /** New options using CipherJWK from ZKCredentials */
222
+ interface VaultOptionsJwk<S, PersistedState = S> extends VaultOptionsBase<S, PersistedState> {
223
+ /** CipherJWK for E2EE encryption (from ZKCredentials) */
224
+ cipherJwk: CipherJWK;
225
+ }
226
+ type VaultOptions<S, PersistedState = S> = VaultOptionsLegacy<S, PersistedState> | VaultOptionsJwk<S, PersistedState>;
227
+
228
+ type VaultListener<S> = (state: S) => void;
229
+
230
+ /** Store shape extended with vault API */
231
+ type StoreVault<S, Ps> = S extends {
232
+ getState: () => infer T;
233
+ setState: {
234
+ (...args: infer Sa1): infer Sr1;
235
+ (...args: infer Sa2): infer Sr2;
236
+ };
237
+ } ? {
238
+ setState(...args: Sa1): Sr1 | Promise<void>;
239
+ setState(...args: Sa2): Sr2 | Promise<void>;
240
+ vault: {
241
+ /** Full bidirectional sync with server */
242
+ sync: () => Promise<void>;
243
+ /** Push local changes to server */
244
+ push: () => Promise<void>;
245
+ /** Pull latest from server */
246
+ pull: () => Promise<boolean>;
247
+ /** Rehydrate from local storage */
248
+ rehydrate: () => Promise<void>;
249
+ /** Check if store has been hydrated */
250
+ hasHydrated: () => boolean;
251
+ /** Get current sync status */
252
+ getSyncStatus: () => SyncStatus;
253
+ /** Check if there are pending offline changes */
254
+ hasPendingChanges: () => boolean;
255
+ /** Clear all stored data (local + server) */
256
+ clearStorage: () => Promise<void>;
257
+ /** Subscribe to hydration start */
258
+ onHydrate: (fn: VaultListener<T>) => () => void;
259
+ /** Subscribe to hydration complete */
260
+ onFinishHydration: (fn: VaultListener<T>) => () => void;
261
+ };
262
+ } : never;
263
+ type Write<T, U> = Omit<T, keyof U> & U;
264
+ type WithVault<S, A> = Write<S, StoreVault<S, A>>;
265
+ /** Public API type with complex mutator support */
266
+ type Vault = <T, Mps extends [StoreMutatorIdentifier, unknown][] = [], Mcs extends [StoreMutatorIdentifier, unknown][] = [], U = T>(initializer: StateCreator<T, [...Mps, ["vault", unknown]], Mcs>, options: VaultOptions<T, U>) => StateCreator<T, Mps, [["vault", U], ...Mcs]>;
267
+ declare module "zustand" {
268
+ interface StoreMutators<S, A> {
269
+ vault: WithVault<S, A>;
270
+ }
271
+ }
272
+ /**
273
+ * vault() middleware - Add E2EE encrypted persistence to Zustand
274
+ *
275
+ * @example Using CipherJWK from ZKCredentials (recommended)
276
+ * ```ts
277
+ * const useStore = create(
278
+ * vault(
279
+ * (set) => ({
280
+ * count: 0,
281
+ * increment: () => set((s) => ({ count: s.count + 1 })),
282
+ * }),
283
+ * {
284
+ * name: 'my-store',
285
+ * cipherJwk: credential.cipherJwk, // From ZKCredentials
286
+ * server: 'https://vault.example.com', // optional
287
+ * }
288
+ * )
289
+ * )
290
+ * ```
291
+ *
292
+ * @example Using recovery key (legacy)
293
+ * ```ts
294
+ * const useStore = create(
295
+ * vault(
296
+ * (set) => ({ ... }),
297
+ * {
298
+ * name: 'my-store',
299
+ * recoveryKey: 'ABCD-EFGH-...',
300
+ * }
301
+ * )
302
+ * )
303
+ * ```
304
+ */
305
+ declare const vault: Vault;
306
+
307
+ /**
308
+ * LocalStorage implementation of IStorageProvider
309
+ * Concrete implementation following Dependency Inversion Principle
310
+ */
311
+
312
+ /**
313
+ * LocalStorage provider with async interface
314
+ * Wraps synchronous localStorage with async API for consistency
315
+ */
316
+ declare class LocalStorageProvider implements IStorageProvider {
317
+ getItem(key: string): Promise<string | null>;
318
+ setItem(key: string, value: string): Promise<void>;
319
+ removeItem(key: string): Promise<void>;
320
+ }
321
+
322
+ /**
323
+ * Fetch-based HTTP client implementation
324
+ * Concrete implementation of IHttpClient using browser fetch API
325
+ */
326
+
327
+ /**
328
+ * HTTP client using native fetch API
329
+ */
330
+ declare class FetchHttpClient implements IHttpClient {
331
+ request(request: IHttpRequest): Promise<IHttpResponse>;
332
+ }
333
+
334
+ /**
335
+ * React hooks for vault status
336
+ */
337
+
338
+ /**
339
+ * Hook to get sync status from a vault store
340
+ *
341
+ * @example
342
+ * ```tsx
343
+ * const status = useSyncStatus(useStore)
344
+ * // 'idle' | 'syncing' | 'synced' | 'error' | 'offline'
345
+ * ```
346
+ */
347
+ declare function useSyncStatus<T extends {
348
+ vault?: {
349
+ getSyncStatus?: () => SyncStatus;
350
+ };
351
+ }>(useStore: () => T): SyncStatus;
352
+
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 };
package/dist/index.js ADDED
@@ -0,0 +1,593 @@
1
+ // src/storage.ts
2
+ import {
3
+ encrypt,
4
+ decrypt,
5
+ deriveKey,
6
+ recoveryKeyToBytes,
7
+ encryptWithJwk,
8
+ decryptWithJwk
9
+ } from "@ursalock/crypto";
10
+
11
+ // src/providers/local-storage.ts
12
+ var LocalStorageProvider = class {
13
+ async getItem(key) {
14
+ if (typeof window === "undefined") return null;
15
+ return localStorage.getItem(key);
16
+ }
17
+ async setItem(key, value) {
18
+ if (typeof window === "undefined") return;
19
+ localStorage.setItem(key, value);
20
+ }
21
+ async removeItem(key) {
22
+ if (typeof window === "undefined") return;
23
+ localStorage.removeItem(key);
24
+ }
25
+ };
26
+
27
+ // src/storage.ts
28
+ function isJwkMode(options) {
29
+ return "cipherJwk" in options;
30
+ }
31
+ function isJwkStoredData(data) {
32
+ return "mode" in data && data.mode === "jwk";
33
+ }
34
+ function createVaultStorage(options) {
35
+ const prefix = options.prefix ?? "ursalock:";
36
+ const storageProvider = options.storageProvider ?? options.storage ?? new LocalStorageProvider();
37
+ if (isJwkMode(options)) {
38
+ return createJwkStorage(options.cipherJwk, storageProvider, prefix);
39
+ } else {
40
+ return createLegacyStorage(options.recoveryKey, storageProvider, prefix);
41
+ }
42
+ }
43
+ function createJwkStorage(cipherJwk, storage, prefix) {
44
+ return {
45
+ async getItem(name) {
46
+ const raw = await storage.getItem(prefix + name);
47
+ if (!raw) return null;
48
+ try {
49
+ const stored = JSON.parse(raw);
50
+ const encrypted = base64ToBytes(stored.data);
51
+ const decrypted = await decryptWithJwk(encrypted, cipherJwk);
52
+ return new TextDecoder().decode(decrypted);
53
+ } catch (error) {
54
+ console.error("[ursalock] Failed to decrypt stored data:", error);
55
+ return null;
56
+ }
57
+ },
58
+ async setItem(name, value) {
59
+ try {
60
+ const plaintext = new TextEncoder().encode(value);
61
+ const encrypted = await encryptWithJwk(plaintext, cipherJwk);
62
+ const stored = {
63
+ data: bytesToBase64(encrypted.combined),
64
+ version: 2,
65
+ updatedAt: Date.now(),
66
+ mode: "jwk"
67
+ };
68
+ await storage.setItem(prefix + name, JSON.stringify(stored));
69
+ } catch (error) {
70
+ console.error("[ursalock] Failed to encrypt data:", error);
71
+ throw error;
72
+ }
73
+ },
74
+ async removeItem(name) {
75
+ await storage.removeItem(prefix + name);
76
+ }
77
+ };
78
+ }
79
+ function createLegacyStorage(recoveryKey, storage, prefix) {
80
+ let cachedKey = null;
81
+ let cachedSalt = null;
82
+ async function getOrDeriveKey(salt) {
83
+ if (cachedKey && cachedSalt && (!salt || arrayEqual(salt, cachedSalt))) {
84
+ return { key: cachedKey, salt: cachedSalt };
85
+ }
86
+ const keyBytes = recoveryKeyToBytes(recoveryKey);
87
+ const result = await deriveKey({
88
+ password: keyBytes,
89
+ salt
90
+ });
91
+ cachedKey = result.key;
92
+ cachedSalt = result.salt;
93
+ return result;
94
+ }
95
+ return {
96
+ async getItem(name) {
97
+ const raw = await storage.getItem(prefix + name);
98
+ if (!raw) return null;
99
+ try {
100
+ const stored = JSON.parse(raw);
101
+ if (isJwkStoredData(stored)) {
102
+ console.error("[ursalock] Cannot decrypt JWK data with recovery key");
103
+ return null;
104
+ }
105
+ const salt = base64ToBytes(stored.salt);
106
+ const { key } = await getOrDeriveKey(salt);
107
+ const encrypted = base64ToBytes(stored.data);
108
+ const decrypted = await decrypt(encrypted, key);
109
+ return new TextDecoder().decode(decrypted);
110
+ } catch (error) {
111
+ console.error("[ursalock] Failed to decrypt stored data:", error);
112
+ return null;
113
+ }
114
+ },
115
+ async setItem(name, value) {
116
+ try {
117
+ const { key, salt } = await getOrDeriveKey();
118
+ const plaintext = new TextEncoder().encode(value);
119
+ const encrypted = await encrypt(plaintext, key);
120
+ const stored = {
121
+ data: bytesToBase64(encrypted.combined),
122
+ salt: bytesToBase64(salt),
123
+ version: 1,
124
+ updatedAt: Date.now()
125
+ };
126
+ await storage.setItem(prefix + name, JSON.stringify(stored));
127
+ } catch (error) {
128
+ console.error("[ursalock] Failed to encrypt data:", error);
129
+ throw error;
130
+ }
131
+ },
132
+ async removeItem(name) {
133
+ await storage.removeItem(prefix + name);
134
+ }
135
+ };
136
+ }
137
+ function bytesToBase64(bytes) {
138
+ const CHUNK_SIZE = 32768;
139
+ let result = "";
140
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
141
+ const chunk = bytes.subarray(i, i + CHUNK_SIZE);
142
+ result += String.fromCharCode.apply(null, chunk);
143
+ }
144
+ return btoa(result);
145
+ }
146
+ function base64ToBytes(base64) {
147
+ const binary = atob(base64);
148
+ const bytes = new Uint8Array(binary.length);
149
+ for (let i = 0; i < binary.length; i++) {
150
+ bytes[i] = binary.charCodeAt(i);
151
+ }
152
+ return bytes;
153
+ }
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
+ }
161
+
162
+ // src/providers/fetch-http.ts
163
+ var FetchHttpClient = class {
164
+ async request(request) {
165
+ const response = await fetch(request.url, {
166
+ method: request.method,
167
+ headers: request.headers,
168
+ body: request.body
169
+ });
170
+ return {
171
+ ok: response.ok,
172
+ status: response.status,
173
+ json: () => response.json(),
174
+ text: () => response.text()
175
+ };
176
+ }
177
+ };
178
+
179
+ // src/sync.ts
180
+ var QUEUE_KEY = "ursalock:offline-queue";
181
+ function createSyncEngine(options) {
182
+ const {
183
+ serverUrl,
184
+ name,
185
+ getToken,
186
+ onServerData,
187
+ getLocalData,
188
+ onStatusChange,
189
+ httpClient = new FetchHttpClient()
190
+ } = options;
191
+ let status = "idle";
192
+ let lastSyncAt = null;
193
+ let error = null;
194
+ const setStatus = (newStatus, newError) => {
195
+ status = newStatus;
196
+ error = newError ?? null;
197
+ onStatusChange?.(newStatus);
198
+ };
199
+ const loadQueue = () => {
200
+ if (typeof localStorage === "undefined") return { pending: [] };
201
+ try {
202
+ const stored = localStorage.getItem(`${QUEUE_KEY}:${name}`);
203
+ return stored ? JSON.parse(stored) : { pending: [] };
204
+ } catch {
205
+ return { pending: [] };
206
+ }
207
+ };
208
+ const saveQueue = (queue) => {
209
+ if (typeof localStorage === "undefined") return;
210
+ try {
211
+ localStorage.setItem(`${QUEUE_KEY}:${name}`, JSON.stringify(queue));
212
+ } catch {
213
+ }
214
+ };
215
+ const enqueue = (data, salt) => {
216
+ const queue = loadQueue();
217
+ queue.pending.push({ data, salt, timestamp: Date.now() });
218
+ if (queue.pending.length > 10) {
219
+ queue.pending = queue.pending.slice(-10);
220
+ }
221
+ saveQueue(queue);
222
+ };
223
+ const clearQueue = () => {
224
+ saveQueue({ pending: [] });
225
+ };
226
+ const isOnline = () => {
227
+ return typeof navigator === "undefined" || navigator.onLine;
228
+ };
229
+ const fetchServer = async () => {
230
+ const token = getToken();
231
+ if (!token) return null;
232
+ const res = await httpClient.request({
233
+ url: `${serverUrl}/vault/by-name/${encodeURIComponent(name)}`,
234
+ method: "GET",
235
+ headers: {
236
+ "Authorization": `Bearer ${token}`,
237
+ "Content-Type": "application/json"
238
+ }
239
+ });
240
+ if (res.status === 404) return null;
241
+ if (!res.ok) throw new Error(`Server error: ${res.status}`);
242
+ return res.json();
243
+ };
244
+ const pushServer = async (data, salt) => {
245
+ const token = getToken();
246
+ if (!token) throw new Error("Not authenticated");
247
+ let existing = null;
248
+ try {
249
+ existing = await fetchServer();
250
+ } catch {
251
+ }
252
+ if (existing) {
253
+ const res = await httpClient.request({
254
+ url: `${serverUrl}/vault/${existing.uid}`,
255
+ method: "PUT",
256
+ headers: {
257
+ "Authorization": `Bearer ${token}`,
258
+ "Content-Type": "application/json"
259
+ },
260
+ body: JSON.stringify({ data, salt })
261
+ });
262
+ if (!res.ok) {
263
+ const errorText = await res.text().catch(() => "");
264
+ throw new Error(`Server error: ${res.status} ${errorText}`);
265
+ }
266
+ return res.json();
267
+ }
268
+ const createRes = await httpClient.request({
269
+ url: `${serverUrl}/vault`,
270
+ method: "POST",
271
+ headers: {
272
+ "Authorization": `Bearer ${token}`,
273
+ "Content-Type": "application/json"
274
+ },
275
+ body: JSON.stringify({ name, data, salt })
276
+ });
277
+ if (createRes.status === 409) {
278
+ const nowExisting = await fetchServer();
279
+ if (!nowExisting) {
280
+ throw new Error("Vault conflict but not found on retry");
281
+ }
282
+ const retryRes = await httpClient.request({
283
+ url: `${serverUrl}/vault/${nowExisting.uid}`,
284
+ method: "PUT",
285
+ headers: {
286
+ "Authorization": `Bearer ${token}`,
287
+ "Content-Type": "application/json"
288
+ },
289
+ body: JSON.stringify({ data, salt })
290
+ });
291
+ if (!retryRes.ok) {
292
+ const errorText = await retryRes.text().catch(() => "");
293
+ throw new Error(`Server error: ${retryRes.status} ${errorText}`);
294
+ }
295
+ return retryRes.json();
296
+ }
297
+ if (!createRes.ok) {
298
+ const errorText = await createRes.text().catch(() => "");
299
+ throw new Error(`Server error: ${createRes.status} ${errorText}`);
300
+ }
301
+ return createRes.json();
302
+ };
303
+ const sync = async () => {
304
+ if (!isOnline()) {
305
+ setStatus("offline");
306
+ return;
307
+ }
308
+ setStatus("syncing");
309
+ try {
310
+ const queue = loadQueue();
311
+ if (queue.pending.length > 0) {
312
+ const latest = queue.pending[queue.pending.length - 1];
313
+ if (latest) {
314
+ await pushServer(latest.data, latest.salt);
315
+ clearQueue();
316
+ }
317
+ }
318
+ const local = getLocalData();
319
+ const server = await fetchServer();
320
+ if (!server) {
321
+ if (local.data) {
322
+ await pushServer(local.data, local.salt);
323
+ }
324
+ } else if (server.updatedAt > local.updatedAt) {
325
+ onServerData(server.data, server.salt, server.updatedAt);
326
+ } else if (local.updatedAt > server.updatedAt) {
327
+ await pushServer(local.data, local.salt);
328
+ }
329
+ lastSyncAt = Date.now();
330
+ setStatus("synced");
331
+ } catch (err) {
332
+ const message = err instanceof Error ? err.message : "Sync failed";
333
+ console.error("[ursalock] Sync error:", message);
334
+ setStatus("error", message);
335
+ if (message.includes("Server error")) {
336
+ const local = getLocalData();
337
+ enqueue(local.data, local.salt);
338
+ }
339
+ }
340
+ };
341
+ const push = async () => {
342
+ if (!isOnline()) {
343
+ const local = getLocalData();
344
+ enqueue(local.data, local.salt);
345
+ setStatus("offline");
346
+ return;
347
+ }
348
+ setStatus("syncing");
349
+ try {
350
+ const local = getLocalData();
351
+ await pushServer(local.data, local.salt);
352
+ lastSyncAt = Date.now();
353
+ setStatus("synced");
354
+ } catch (err) {
355
+ const message = err instanceof Error ? err.message : "Push failed";
356
+ console.error("[ursalock] Push error:", message);
357
+ const local = getLocalData();
358
+ enqueue(local.data, local.salt);
359
+ setStatus("error", message);
360
+ }
361
+ };
362
+ const pull = async () => {
363
+ if (!isOnline()) {
364
+ setStatus("offline");
365
+ return false;
366
+ }
367
+ setStatus("syncing");
368
+ try {
369
+ const server = await fetchServer();
370
+ if (server) {
371
+ const local = getLocalData();
372
+ if (server.updatedAt > local.updatedAt) {
373
+ onServerData(server.data, server.salt, server.updatedAt);
374
+ lastSyncAt = Date.now();
375
+ setStatus("synced");
376
+ return true;
377
+ }
378
+ }
379
+ setStatus("synced");
380
+ return false;
381
+ } catch (err) {
382
+ const message = err instanceof Error ? err.message : "Pull failed";
383
+ console.error("[ursalock] Pull error:", message);
384
+ setStatus("error", message);
385
+ return false;
386
+ }
387
+ };
388
+ const getState = () => ({
389
+ lastSyncAt,
390
+ status,
391
+ pendingChanges: loadQueue().pending.length > 0,
392
+ error
393
+ });
394
+ return {
395
+ sync,
396
+ push,
397
+ pull,
398
+ getState,
399
+ clearQueue
400
+ };
401
+ }
402
+
403
+ // src/vault.ts
404
+ function isJwkMode2(options) {
405
+ return "cipherJwk" in options;
406
+ }
407
+ var vaultImpl = (config, baseOptions) => (set, get, api) => {
408
+ const options = {
409
+ partialize: (state) => state,
410
+ merge: (persistedState, currentState) => ({
411
+ ...currentState,
412
+ ...persistedState
413
+ }),
414
+ syncInterval: 3e4,
415
+ ...baseOptions
416
+ };
417
+ const {
418
+ name,
419
+ server,
420
+ getToken,
421
+ partialize,
422
+ merge,
423
+ onRehydrateStorage,
424
+ skipHydration = false,
425
+ syncInterval
426
+ } = options;
427
+ const storage = options.storage ?? createVaultStorage(
428
+ isJwkMode2(options) ? { cipherJwk: options.cipherJwk, prefix: options.prefix } : { recoveryKey: options.recoveryKey, prefix: options.prefix }
429
+ );
430
+ let hasHydrated = false;
431
+ let localUpdatedAt = 0;
432
+ let hasLocalData = false;
433
+ let syncDebounceTimer = null;
434
+ const hydrationListeners = /* @__PURE__ */ new Set();
435
+ const finishHydrationListeners = /* @__PURE__ */ new Set();
436
+ let syncEngine = null;
437
+ if (server && getToken) {
438
+ syncEngine = createSyncEngine({
439
+ serverUrl: server,
440
+ name,
441
+ getToken,
442
+ onServerData: (data, _salt, updatedAt) => {
443
+ if (localUpdatedAt > updatedAt) {
444
+ void syncEngine?.push();
445
+ return;
446
+ }
447
+ try {
448
+ const parsed = JSON.parse(data);
449
+ const merged = merge(parsed, get());
450
+ set(merged, true);
451
+ localUpdatedAt = updatedAt;
452
+ void storage.setItem(name, JSON.stringify(partialize({ ...get() })));
453
+ } catch (err) {
454
+ console.error("[ursalock] Failed to parse server data:", err);
455
+ }
456
+ },
457
+ getLocalData: () => {
458
+ const state = partialize({ ...get() });
459
+ return {
460
+ data: JSON.stringify(state),
461
+ salt: "",
462
+ // Salt is handled by storage layer
463
+ updatedAt: localUpdatedAt
464
+ };
465
+ }
466
+ });
467
+ }
468
+ const persistState = async () => {
469
+ const state = partialize({ ...get() });
470
+ await storage.setItem(name, JSON.stringify(state));
471
+ localUpdatedAt = Date.now();
472
+ if (syncEngine) {
473
+ if (syncDebounceTimer) {
474
+ clearTimeout(syncDebounceTimer);
475
+ }
476
+ syncDebounceTimer = setTimeout(() => {
477
+ syncDebounceTimer = null;
478
+ void syncEngine?.sync();
479
+ }, 3e3);
480
+ }
481
+ };
482
+ const rehydrate = async () => {
483
+ hasHydrated = false;
484
+ hydrationListeners.forEach((cb) => cb(get()));
485
+ const postRehydrationCallback = onRehydrateStorage?.(get()) || void 0;
486
+ try {
487
+ const stored = await storage.getItem(name);
488
+ if (stored) {
489
+ const parsed = JSON.parse(stored);
490
+ const merged = merge(parsed, get());
491
+ set(merged, true);
492
+ hasLocalData = true;
493
+ }
494
+ hasHydrated = true;
495
+ postRehydrationCallback?.(get(), void 0);
496
+ finishHydrationListeners.forEach((cb) => cb(get()));
497
+ } catch (error) {
498
+ postRehydrationCallback?.(void 0, error);
499
+ }
500
+ };
501
+ const sync = async () => {
502
+ if (!syncEngine) return;
503
+ await syncEngine.sync();
504
+ };
505
+ const push = async () => {
506
+ if (!syncEngine) return;
507
+ await syncEngine.push();
508
+ };
509
+ const pull = async () => {
510
+ if (!syncEngine) return false;
511
+ return syncEngine.pull();
512
+ };
513
+ const getSyncStatus = () => {
514
+ if (!syncEngine) return "idle";
515
+ return syncEngine.getState().status;
516
+ };
517
+ const hasPendingChanges = () => {
518
+ if (!syncEngine) return false;
519
+ return syncEngine.getState().pendingChanges;
520
+ };
521
+ const clearStorage = async () => {
522
+ await storage.removeItem(name);
523
+ syncEngine?.clearQueue();
524
+ };
525
+ const savedSetState = api.setState;
526
+ api.setState = ((state, replace) => {
527
+ if (replace) {
528
+ savedSetState(state, true);
529
+ } else {
530
+ savedSetState(state);
531
+ }
532
+ void persistState();
533
+ });
534
+ const configResult = config(
535
+ ((partial, replace) => {
536
+ if (replace) {
537
+ set(partial, true);
538
+ } else {
539
+ set(partial);
540
+ }
541
+ void persistState();
542
+ }),
543
+ get,
544
+ api
545
+ );
546
+ const storeWithVault = api;
547
+ storeWithVault.vault = {
548
+ sync,
549
+ push,
550
+ pull,
551
+ rehydrate,
552
+ hasHydrated: () => hasHydrated,
553
+ getSyncStatus,
554
+ hasPendingChanges,
555
+ clearStorage,
556
+ onHydrate: (cb) => {
557
+ hydrationListeners.add(cb);
558
+ return () => hydrationListeners.delete(cb);
559
+ },
560
+ onFinishHydration: (cb) => {
561
+ finishHydrationListeners.add(cb);
562
+ return () => finishHydrationListeners.delete(cb);
563
+ }
564
+ };
565
+ if (!skipHydration) {
566
+ void rehydrate().then(() => {
567
+ if (syncEngine) {
568
+ void syncEngine.sync();
569
+ }
570
+ });
571
+ } else {
572
+ hasHydrated = true;
573
+ }
574
+ if (server && syncInterval > 0) {
575
+ setInterval(() => void sync(), syncInterval);
576
+ }
577
+ return configResult;
578
+ };
579
+ var vault = vaultImpl;
580
+
581
+ // src/hooks.ts
582
+ function useSyncStatus(useStore) {
583
+ const store = useStore();
584
+ return store.vault?.getSyncStatus?.() ?? "idle";
585
+ }
586
+ export {
587
+ FetchHttpClient,
588
+ LocalStorageProvider,
589
+ createSyncEngine,
590
+ createVaultStorage,
591
+ useSyncStatus,
592
+ vault
593
+ };
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@ursalock/zustand",
3
+ "version": "0.2.8",
4
+ "description": "Encrypted persistence middleware for Zustand with passkey E2EE",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@ursalock/crypto": "^0.2.0"
26
+ },
27
+ "peerDependencies": {
28
+ "zustand": ">=4.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "tsup": "^8.0.0",
32
+ "typescript": "^5.4.0",
33
+ "vitest": "^1.6.0",
34
+ "zustand": "^5.0.0"
35
+ },
36
+ "keywords": [
37
+ "zustand",
38
+ "middleware",
39
+ "encryption",
40
+ "e2ee",
41
+ "passkey",
42
+ "prf",
43
+ "sync",
44
+ "persist"
45
+ ],
46
+ "license": "MIT",
47
+ "author": "Nicolas de Luz <ndlz@pm.me>",
48
+ "homepage": "https://github.com/nicodlz/ursalock#readme",
49
+ "bugs": {
50
+ "url": "https://github.com/nicodlz/ursalock/issues"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "https://github.com/nicodlz/ursalock.git",
55
+ "directory": "packages/zustand"
56
+ }
57
+ }