@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.
- package/dist/index.d.ts +353 -0
- package/dist/index.js +593 -0
- package/package.json +57 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|