@jeanharo98/typed-storage 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -14
- package/dist/create-storage.js +0 -18
- package/dist/heavy-storage.d.ts +19 -0
- package/dist/heavy-storage.js +89 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/storage-signal.js +23 -3
- package/dist/types.d.ts +1 -0
- package/dist/xor.d.ts +3 -0
- package/dist/xor.js +14 -0
- package/index.html +9 -19
- package/package.json +2 -1
- package/src/create-storage.ts +0 -19
- package/src/heavy-storage.test.ts +99 -0
- package/src/heavy-storage.ts +146 -0
- package/src/index.ts +7 -1
- package/src/storage-signal.test.ts +40 -0
- package/src/storage-signal.ts +27 -3
- package/src/types.ts +1 -0
- package/src/xor.ts +18 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +1 -0
package/README.md
CHANGED
|
@@ -160,7 +160,56 @@ createStorage(schema, {
|
|
|
160
160
|
|
|
161
161
|
---
|
|
162
162
|
|
|
163
|
-
##
|
|
163
|
+
## 🗄️ Heavy data with IndexedDB
|
|
164
|
+
|
|
165
|
+
For large datasets that exceed `localStorage`'s ~5MB limit (file lists, extensive history, large collections), use `createHeavyStorage` — a separate async API backed by IndexedDB.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { createHeavyStorage } from 'typed-storage';
|
|
169
|
+
|
|
170
|
+
const heavyStorage = createHeavyStorage({
|
|
171
|
+
documents: [] as Document[],
|
|
172
|
+
userPhotos: [] as Photo[]
|
|
173
|
+
}, {
|
|
174
|
+
dbName: 'myapp-storage',
|
|
175
|
+
ttl: 86400000 // optional — same TTL support as the sync API
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// All operations are async — IndexedDB is asynchronous by nature
|
|
179
|
+
await heavyStorage.documents.set([...manyDocuments]);
|
|
180
|
+
const docs = await heavyStorage.documents.get();
|
|
181
|
+
await heavyStorage.documents.remove();
|
|
182
|
+
|
|
183
|
+
heavyStorage.documents.onChange((newValue) => {
|
|
184
|
+
console.log('documents changed:', newValue);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
await heavyStorage.clear();
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Why a separate API?
|
|
191
|
+
|
|
192
|
+
`createStorage()` uses a synchronous Signal-like API by design — that's the core value of typed-storage. IndexedDB is asynchronous by nature, so mixing it into the same API would break that synchronous contract.
|
|
193
|
+
|
|
194
|
+
```
|
|
195
|
+
createStorage() → sync, Signal-like, for UI preferences and small state
|
|
196
|
+
createHeavyStorage() → async, Promise-based, for large datasets
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
If you only need small values (theme, language, settings), stick with `createStorage()`. Use `createHeavyStorage()` only when you specifically need to store data beyond localStorage's size limits.
|
|
200
|
+
|
|
201
|
+
### `HeavySignal<T>` API
|
|
202
|
+
|
|
203
|
+
| Member | Description |
|
|
204
|
+
|--------|-------------|
|
|
205
|
+
| `signal.get()` | Returns a Promise with the current value |
|
|
206
|
+
| `signal.set(value)` | Stores the value, returns a Promise |
|
|
207
|
+
| `signal.remove()` | Deletes the value, returns a Promise |
|
|
208
|
+
| `signal.onChange(cb)` | Subscribes to value changes (called synchronously after set/remove) |
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
|
|
164
213
|
|
|
165
214
|
```typescript
|
|
166
215
|
const appStorage = createStorage(schema, options);
|
|
@@ -175,7 +224,8 @@ const appStorage = createStorage(schema, options);
|
|
|
175
224
|
| `version` | `number` | — | Current schema version — required for migrations |
|
|
176
225
|
| `migrations` | `Record<number, (data) => data>` | — | Migration functions per version |
|
|
177
226
|
| `compress` | `boolean` | `false` | Compresses data with LZ-string before storing |
|
|
178
|
-
| `encrypt` | `boolean` | `false` |
|
|
227
|
+
| `encrypt` | `boolean` | `false` | Obfuscates data with XOR + Base64 — see security note below |
|
|
228
|
+
| `secret` | `string` | — | Required when `encrypt: true` — the obfuscation key |
|
|
179
229
|
|
|
180
230
|
---
|
|
181
231
|
|
|
@@ -230,27 +280,79 @@ appStorage.theme.remove(); // → 'theme changed to: dark' (initialValue)
|
|
|
230
280
|
|
|
231
281
|
---
|
|
232
282
|
|
|
233
|
-
## 🔒
|
|
283
|
+
## 🔒 Encryption (XOR obfuscation)
|
|
284
|
+
|
|
285
|
+
`typed-storage` can obfuscate values using XOR + Base64 before storing them. This is **not real cryptography** — read this section carefully before using it.
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
const secureStorage = createStorage({
|
|
289
|
+
token: ''
|
|
290
|
+
}, {
|
|
291
|
+
encrypt: true,
|
|
292
|
+
secret: 'your-secret-key', // required when encrypt is true
|
|
293
|
+
ttl: 3600000 // recommended — expire alongside your real token
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
secureStorage.token.set('eyJhbGciOiJIUzI1NiJ9.xxx.yyy');
|
|
297
|
+
// Stored in localStorage as obfuscated text, not the readable JWT
|
|
298
|
+
|
|
299
|
+
const token = secureStorage.token();
|
|
300
|
+
// Automatically decrypted — returns the real JWT
|
|
301
|
+
```
|
|
234
302
|
|
|
235
|
-
|
|
303
|
+
### ⚠️ What this actually protects against
|
|
236
304
|
|
|
237
|
-
|
|
305
|
+
```
|
|
306
|
+
✅ Hides the value from casual inspection in DevTools/Application/Storage
|
|
307
|
+
✅ Discourages non-technical users from reading or copying the value
|
|
308
|
+
✅ Combined with ttl, expires alongside your backend token
|
|
309
|
+
|
|
310
|
+
❌ Does NOT protect against a technical attacker
|
|
311
|
+
❌ Does NOT protect against debugger breakpoints — the secret and
|
|
312
|
+
decrypted value are visible in memory while the app runs
|
|
313
|
+
❌ Is NOT equivalent to real cryptography (AES, etc.)
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### Why this limitation exists — and why no frontend library can fix it
|
|
317
|
+
|
|
318
|
+
The `secret` you pass lives in your JavaScript code, which runs in the user's browser. No matter the algorithm used (XOR, AES, anything), **the key must be present in the frontend to decrypt the value**, which means it's always inspectable:
|
|
319
|
+
|
|
320
|
+
```
|
|
321
|
+
1. The secret travels safely over HTTPS — that's not the problem
|
|
322
|
+
2. Once it reaches the browser, it must be used by your JS to decrypt
|
|
323
|
+
3. Anyone with DevTools open can set a breakpoint where decryption
|
|
324
|
+
happens and read the secret and the decrypted value directly
|
|
325
|
+
4. This is true even with industry-standard encryption (Web Crypto AES) —
|
|
326
|
+
the algorithm's strength doesn't matter if the key is exposed
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
This is a fundamental limitation of any frontend-only encryption — not a flaw specific to typed-storage's XOR implementation.
|
|
330
|
+
|
|
331
|
+
### For real security with auth tokens
|
|
238
332
|
|
|
239
333
|
```typescript
|
|
240
334
|
// In your backend (Express / NestJS):
|
|
241
335
|
res.cookie('authToken', token, {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
336
|
+
httpOnly: true, // JavaScript cannot read this — ever
|
|
337
|
+
secure: true, // HTTPS only
|
|
338
|
+
sameSite: 'strict'
|
|
245
339
|
});
|
|
246
340
|
```
|
|
247
341
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
342
|
+
httpOnly cookies are the only approach where the token never becomes accessible to JavaScript running in the browser — because the browser itself enforces the restriction, not your code.
|
|
343
|
+
|
|
344
|
+
### When `encrypt` is still worth using
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
✅ You understand it's obfuscation, not security
|
|
348
|
+
✅ You want to deter casual inspection, not block determined attackers
|
|
349
|
+
✅ You're combining it with ttl so values expire predictably
|
|
350
|
+
✅ The data isn't critical enough to justify httpOnly cookie infrastructure
|
|
351
|
+
(e.g. you're prototyping, or it's a low-stakes internal tool)
|
|
352
|
+
|
|
353
|
+
❌ Don't rely on this alone for banking, healthcare, or any data where
|
|
354
|
+
a breach has real consequences — use httpOnly cookies on the backend
|
|
355
|
+
```
|
|
254
356
|
|
|
255
357
|
---
|
|
256
358
|
|
package/dist/create-storage.js
CHANGED
|
@@ -17,24 +17,6 @@ export function createStorage(schema, options) {
|
|
|
17
17
|
}
|
|
18
18
|
const sto = options?.storage === 'session' ? sessionStorage : localStorage;
|
|
19
19
|
registerPrefix(options?.prefix ?? '', sto);
|
|
20
|
-
if (options?.encrypt) {
|
|
21
|
-
console.warn(`
|
|
22
|
-
⚠️ typed-storage: la opción encrypt está activada.
|
|
23
|
-
|
|
24
|
-
Encriptar valores en localStorage no es seguro —
|
|
25
|
-
la clave vive en el frontend y cualquier dev puede accederla.
|
|
26
|
-
|
|
27
|
-
Para datos sensibles usa:
|
|
28
|
-
✅ httpOnly cookies (tokens, sesiones)
|
|
29
|
-
✅ Variables de entorno en el servidor
|
|
30
|
-
|
|
31
|
-
typed-storage es ideal para:
|
|
32
|
-
✅ Preferencias de UI (theme, language)
|
|
33
|
-
✅ Estado de navegación
|
|
34
|
-
❌ Tokens de autenticación
|
|
35
|
-
❌ Datos financieros o personales sensibles
|
|
36
|
-
`);
|
|
37
|
-
}
|
|
38
20
|
const result = [];
|
|
39
21
|
let keys = Object.keys(schema);
|
|
40
22
|
for (let key of keys) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
interface HeavyStorageOptions {
|
|
2
|
+
dbName?: string;
|
|
3
|
+
ttl?: number;
|
|
4
|
+
}
|
|
5
|
+
interface HeavySignal<T> {
|
|
6
|
+
get(): Promise<T>;
|
|
7
|
+
set(value: T): Promise<void>;
|
|
8
|
+
remove(): Promise<void>;
|
|
9
|
+
onChange(callback: (value: T) => void): void;
|
|
10
|
+
}
|
|
11
|
+
export declare function createHeavySignal<T>(key: string, initialValue: T, options?: HeavyStorageOptions): HeavySignal<T>;
|
|
12
|
+
type HeavyStorageSchema = Record<string, any>;
|
|
13
|
+
type HeavyStorageResult<T extends HeavyStorageSchema> = {
|
|
14
|
+
[K in keyof T]: HeavySignal<T[K]>;
|
|
15
|
+
} & {
|
|
16
|
+
clear(): Promise<void>;
|
|
17
|
+
};
|
|
18
|
+
export declare function createHeavyStorage<T extends HeavyStorageSchema>(schema: T, options?: HeavyStorageOptions): HeavyStorageResult<T>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
function openDB(dbName) {
|
|
2
|
+
return new Promise((resolve, reject) => {
|
|
3
|
+
const request = indexedDB.open(dbName, 1);
|
|
4
|
+
request.onupgradeneeded = (event) => {
|
|
5
|
+
const db = event.target.result;
|
|
6
|
+
if (!db.objectStoreNames.contains('storage')) {
|
|
7
|
+
db.createObjectStore('storage');
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
request.onsuccess = () => resolve(request.result);
|
|
11
|
+
request.onerror = () => reject(request.error);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function dbGet(db, key) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const transaction = db.transaction('storage', 'readonly');
|
|
17
|
+
const store = transaction.objectStore('storage');
|
|
18
|
+
const request = store.get(key);
|
|
19
|
+
request.onsuccess = () => resolve(request.result);
|
|
20
|
+
request.onerror = () => reject(request.error);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function dbSet(db, key, value) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
26
|
+
const store = transaction.objectStore('storage');
|
|
27
|
+
const request = store.put(value, key);
|
|
28
|
+
request.onsuccess = () => resolve();
|
|
29
|
+
request.onerror = () => reject(request.error);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function dbDelete(db, key) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
35
|
+
const store = transaction.objectStore('storage');
|
|
36
|
+
const request = store.delete(key);
|
|
37
|
+
request.onsuccess = () => resolve();
|
|
38
|
+
request.onerror = () => reject(request.error);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function createHeavySignal(key, initialValue, options) {
|
|
42
|
+
const dbName = options?.dbName ?? 'typed-storage-heavy';
|
|
43
|
+
const listeners = [];
|
|
44
|
+
function notify(value) {
|
|
45
|
+
listeners.forEach(cb => cb(value));
|
|
46
|
+
}
|
|
47
|
+
const signal = {};
|
|
48
|
+
signal.get = async function () {
|
|
49
|
+
const db = await openDB(dbName);
|
|
50
|
+
const stored = await dbGet(db, key);
|
|
51
|
+
if (stored === undefined)
|
|
52
|
+
return initialValue;
|
|
53
|
+
if (stored.expiresAt && Date.now() > stored.expiresAt) {
|
|
54
|
+
await dbDelete(db, key);
|
|
55
|
+
return initialValue;
|
|
56
|
+
}
|
|
57
|
+
return stored.value ?? initialValue;
|
|
58
|
+
};
|
|
59
|
+
signal.set = async function (value) {
|
|
60
|
+
const db = await openDB(dbName);
|
|
61
|
+
await dbSet(db, key, {
|
|
62
|
+
value,
|
|
63
|
+
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
64
|
+
});
|
|
65
|
+
notify(value);
|
|
66
|
+
};
|
|
67
|
+
signal.remove = async function () {
|
|
68
|
+
const db = await openDB(dbName);
|
|
69
|
+
await dbDelete(db, key);
|
|
70
|
+
notify(initialValue);
|
|
71
|
+
};
|
|
72
|
+
signal.onChange = function (callback) {
|
|
73
|
+
listeners.push(callback);
|
|
74
|
+
};
|
|
75
|
+
return signal;
|
|
76
|
+
}
|
|
77
|
+
export function createHeavyStorage(schema, options) {
|
|
78
|
+
const result = {};
|
|
79
|
+
const keys = Object.keys(schema);
|
|
80
|
+
for (const key of keys) {
|
|
81
|
+
result[key] = createHeavySignal(key, schema[key], options);
|
|
82
|
+
}
|
|
83
|
+
result.clear = async () => {
|
|
84
|
+
for (const key of keys) {
|
|
85
|
+
await result[key].remove();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
return result;
|
|
89
|
+
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/storage-signal.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import LZString from 'lz-string';
|
|
2
2
|
import { MemoryStorage } from "./memory-storage.js";
|
|
3
|
+
import { xorEncrypt, xorDecrypt } from './xor.js';
|
|
3
4
|
function safeParseJSON(value, fallback) {
|
|
4
5
|
if (!value)
|
|
5
6
|
return { value: fallback };
|
|
@@ -33,9 +34,17 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
33
34
|
key = `${options.prefix}:${key}`;
|
|
34
35
|
}
|
|
35
36
|
const rawData = sto.getItem(key);
|
|
36
|
-
|
|
37
|
+
let savedData = options?.compress && rawData
|
|
37
38
|
? LZString.decompress(rawData)
|
|
38
39
|
: rawData;
|
|
40
|
+
if (options?.encrypt && options?.secret && savedData) {
|
|
41
|
+
try {
|
|
42
|
+
savedData = xorDecrypt(savedData, options.secret);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
savedData = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
39
48
|
let currentValue;
|
|
40
49
|
const listeners = [];
|
|
41
50
|
function notify(value) {
|
|
@@ -64,9 +73,17 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
64
73
|
notify(initialValue);
|
|
65
74
|
return currentValue = initialValue;
|
|
66
75
|
}
|
|
67
|
-
|
|
76
|
+
let rawNewValue = options?.compress
|
|
68
77
|
? LZString.decompress(event.newValue)
|
|
69
78
|
: event.newValue;
|
|
79
|
+
if (options?.encrypt && options?.secret) {
|
|
80
|
+
try {
|
|
81
|
+
rawNewValue = xorDecrypt(rawNewValue, options.secret);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
rawNewValue = '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
70
87
|
const item = safeParseJSON(rawNewValue, initialValue);
|
|
71
88
|
notify(item.value);
|
|
72
89
|
return currentValue = item.value;
|
|
@@ -80,9 +97,12 @@ export function createStorageSignal(key, initialValue, options) {
|
|
|
80
97
|
value: newValue,
|
|
81
98
|
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
82
99
|
});
|
|
83
|
-
|
|
100
|
+
let finalData = options?.compress
|
|
84
101
|
? LZString.compress(dataToStore)
|
|
85
102
|
: dataToStore;
|
|
103
|
+
if (options?.encrypt && options?.secret) {
|
|
104
|
+
finalData = xorEncrypt(finalData, options.secret);
|
|
105
|
+
}
|
|
86
106
|
sto.setItem(key, finalData);
|
|
87
107
|
};
|
|
88
108
|
signalBase.reset = function () {
|
package/dist/types.d.ts
CHANGED
package/dist/xor.d.ts
ADDED
package/dist/xor.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function xorTransform(text, secret) {
|
|
2
|
+
return text.split('').map((char, i) => {
|
|
3
|
+
const keyChar = secret[i % secret.length];
|
|
4
|
+
return String.fromCharCode(char.charCodeAt(0) ^ keyChar.charCodeAt(0));
|
|
5
|
+
}).join('');
|
|
6
|
+
}
|
|
7
|
+
export function xorEncrypt(text, secret) {
|
|
8
|
+
const xored = xorTransform(text, secret);
|
|
9
|
+
return btoa(xored);
|
|
10
|
+
}
|
|
11
|
+
export function xorDecrypt(text, secret) {
|
|
12
|
+
const xored = atob(text);
|
|
13
|
+
return xorTransform(xored, secret);
|
|
14
|
+
}
|
package/index.html
CHANGED
|
@@ -7,28 +7,18 @@
|
|
|
7
7
|
<script type="module">
|
|
8
8
|
import { createStorage } from './dist/index.js';
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
|
|
10
|
+
const secureStorage = createStorage({
|
|
11
|
+
token: ''
|
|
12
12
|
}, {
|
|
13
|
-
prefix: '
|
|
14
|
-
|
|
13
|
+
prefix: 'secure',
|
|
14
|
+
encrypt: true,
|
|
15
|
+
secret: 'mi-clave-secreta',
|
|
16
|
+
ttl: 3600000
|
|
15
17
|
});
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
name: `Item ${i}`,
|
|
21
|
-
description: 'Lorem ipsum dolor sit amet consectetur'
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
compressedStorage.bigData.set({ items: bigArray });
|
|
25
|
-
|
|
26
|
-
console.log('Valor leído:', compressedStorage.bigData());
|
|
27
|
-
console.log('Tamaño en localStorage:', localStorage.getItem('compressed:bigData')?.length, 'caracteres');
|
|
28
|
-
|
|
29
|
-
// Compara con la versión sin comprimir
|
|
30
|
-
const uncompressedSize = JSON.stringify({ value: { items: bigArray } }).length;
|
|
31
|
-
console.log('Tamaño sin comprimir sería:', uncompressedSize, 'caracteres');
|
|
19
|
+
secureStorage.token.set('eyJhbGciOiJIUzI1NiJ9.test.jwt');
|
|
20
|
+
console.log('Valor leído:', secureStorage.token());
|
|
21
|
+
console.log('Valor en localStorage (debe estar ofuscado):', localStorage.getItem('secure:token'));
|
|
32
22
|
</script>
|
|
33
23
|
</body>
|
|
34
24
|
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jeanharo98/typed-storage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Type-safe localStorage with reactive signals",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@vitest/coverage-v8": "^4.1.8",
|
|
19
|
+
"fake-indexeddb": "^6.2.5",
|
|
19
20
|
"jsdom": "^29.1.1",
|
|
20
21
|
"ts-node": "^10.9.2",
|
|
21
22
|
"typescript": "^6.0.3",
|
package/src/create-storage.ts
CHANGED
|
@@ -42,25 +42,6 @@ export function createStorage<T extends StorageSchema>(
|
|
|
42
42
|
const sto = options?.storage === 'session' ? sessionStorage : localStorage;
|
|
43
43
|
registerPrefix(options?.prefix ?? '', sto);
|
|
44
44
|
|
|
45
|
-
if ( options?.encrypt ) {
|
|
46
|
-
console.warn(`
|
|
47
|
-
⚠️ typed-storage: la opción encrypt está activada.
|
|
48
|
-
|
|
49
|
-
Encriptar valores en localStorage no es seguro —
|
|
50
|
-
la clave vive en el frontend y cualquier dev puede accederla.
|
|
51
|
-
|
|
52
|
-
Para datos sensibles usa:
|
|
53
|
-
✅ httpOnly cookies (tokens, sesiones)
|
|
54
|
-
✅ Variables de entorno en el servidor
|
|
55
|
-
|
|
56
|
-
typed-storage es ideal para:
|
|
57
|
-
✅ Preferencias de UI (theme, language)
|
|
58
|
-
✅ Estado de navegación
|
|
59
|
-
❌ Tokens de autenticación
|
|
60
|
-
❌ Datos financieros o personales sensibles
|
|
61
|
-
`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
45
|
const result: any = [];
|
|
65
46
|
|
|
66
47
|
let keys = Object.keys(schema);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createHeavyStorage } from './heavy-storage.js';
|
|
3
|
+
|
|
4
|
+
describe('createHeavyStorage', () => {
|
|
5
|
+
|
|
6
|
+
it('debe retornar el initialValue si no hay datos guardados', async () => {
|
|
7
|
+
// crea heavyStorage con dbName único para este test
|
|
8
|
+
const heavyStorage = createHeavyStorage({
|
|
9
|
+
documents: [],
|
|
10
|
+
userPhotos: []
|
|
11
|
+
}, {
|
|
12
|
+
dbName: 'test-heavy-storage'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// verifica que get() retorna el initialValue
|
|
16
|
+
const result = await heavyStorage.documents.get();
|
|
17
|
+
expect(result).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('debe guardar y leer un valor con set/get', async () => {
|
|
21
|
+
// crea heavyStorage
|
|
22
|
+
const heavyStorage = createHeavyStorage({
|
|
23
|
+
documents: [] as any[]
|
|
24
|
+
}, {
|
|
25
|
+
dbName: 'test-db-2'
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
// llama set() con un valor
|
|
30
|
+
const newDocs = [{ id: 1, name: 'Doc 1' }];
|
|
31
|
+
await heavyStorage.documents.set(newDocs);
|
|
32
|
+
|
|
33
|
+
// verifica que get() retorna ese valor
|
|
34
|
+
const result = await heavyStorage.documents.get();
|
|
35
|
+
expect(result).toEqual(newDocs);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('debe eliminar un valor con remove()', async () => {
|
|
39
|
+
// crea heavyStorage, set() un valor
|
|
40
|
+
const heavyStorage = createHeavyStorage({
|
|
41
|
+
documents: [] as any[]
|
|
42
|
+
}, {
|
|
43
|
+
dbName: 'test-db-3'
|
|
44
|
+
});
|
|
45
|
+
await heavyStorage.documents.set([{ id: 1, name: 'Doc 1' }]);
|
|
46
|
+
|
|
47
|
+
// llama remove()
|
|
48
|
+
await heavyStorage.documents.remove();
|
|
49
|
+
|
|
50
|
+
// verifica que get() retorna el initialValue
|
|
51
|
+
const result = await heavyStorage.documents.get();
|
|
52
|
+
expect(result).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('debe notificar a onChange cuando se llama set()', async () => {
|
|
56
|
+
// usa vi.fn() como callback
|
|
57
|
+
const heavyStorage = createHeavyStorage({
|
|
58
|
+
documents: [] as any[]
|
|
59
|
+
}, {
|
|
60
|
+
dbName: 'test-db-4'
|
|
61
|
+
});
|
|
62
|
+
const callback = vi.fn();
|
|
63
|
+
|
|
64
|
+
// registra onChange
|
|
65
|
+
heavyStorage.documents.onChange(callback);
|
|
66
|
+
|
|
67
|
+
// llama set()
|
|
68
|
+
const newDocs = [{ id: 1, name: 'Doc 1' }];
|
|
69
|
+
await heavyStorage.documents.set(newDocs);
|
|
70
|
+
|
|
71
|
+
// verifica que el callback fue llamado con el valor correcto
|
|
72
|
+
expect(callback).toHaveBeenCalledWith(newDocs);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('clear() debe resetear todas las keys del schema', async () => {
|
|
76
|
+
// crea heavyStorage con 2 keys
|
|
77
|
+
const heavyStorage = createHeavyStorage({
|
|
78
|
+
documents: [] as any[],
|
|
79
|
+
userPhotos: [] as any[]
|
|
80
|
+
}, {
|
|
81
|
+
dbName: 'test-db-5'
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// set() en ambas
|
|
85
|
+
await heavyStorage.documents.set([{ id: 1 }]);
|
|
86
|
+
await heavyStorage.userPhotos.set([{ url: 'photo.jpg' }]);
|
|
87
|
+
|
|
88
|
+
// llama clear()
|
|
89
|
+
await heavyStorage.clear();
|
|
90
|
+
|
|
91
|
+
// verifica que ambas vuelven a su initialValue
|
|
92
|
+
const docs = await heavyStorage.documents.get();
|
|
93
|
+
const photos = await heavyStorage.userPhotos.get();
|
|
94
|
+
|
|
95
|
+
expect(docs).toEqual([]);
|
|
96
|
+
expect(photos).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Abrir/crear la base de datos
|
|
2
|
+
function openDB(dbName: string): Promise<IDBDatabase> {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const request = indexedDB.open(dbName, 1);
|
|
5
|
+
|
|
6
|
+
request.onupgradeneeded = (event) => {
|
|
7
|
+
const db = (event.target as IDBOpenDBRequest).result;
|
|
8
|
+
// Crea el "object store" — equivalente a una tabla
|
|
9
|
+
if (!db.objectStoreNames.contains('storage')) {
|
|
10
|
+
db.createObjectStore('storage');
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
request.onsuccess = () => resolve(request.result);
|
|
15
|
+
request.onerror = () => reject(request.error);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dbGet(db: IDBDatabase, key: string): Promise<any> {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const transaction = db.transaction('storage', 'readonly');
|
|
22
|
+
const store = transaction.objectStore('storage');
|
|
23
|
+
const request = store.get(key);
|
|
24
|
+
|
|
25
|
+
request.onsuccess = () => resolve(request.result);
|
|
26
|
+
request.onerror = () => reject(request.error);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function dbSet(db: IDBDatabase, key: string, value: any): Promise<void> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
33
|
+
const store = transaction.objectStore('storage');
|
|
34
|
+
const request = store.put(value, key);
|
|
35
|
+
|
|
36
|
+
request.onsuccess = () => resolve();
|
|
37
|
+
request.onerror = () => reject(request.error);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function dbDelete(db: IDBDatabase, key: string): Promise<void> {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const transaction = db.transaction('storage', 'readwrite');
|
|
44
|
+
const store = transaction.objectStore('storage');
|
|
45
|
+
const request = store.delete(key);
|
|
46
|
+
|
|
47
|
+
request.onsuccess = () => resolve();
|
|
48
|
+
request.onerror = () => reject(request.error);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ===========================================
|
|
53
|
+
|
|
54
|
+
interface HeavyStorageOptions {
|
|
55
|
+
dbName?: string;
|
|
56
|
+
ttl?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface HeavySignal<T> {
|
|
60
|
+
get(): Promise<T>;
|
|
61
|
+
set(value: T): Promise<void>;
|
|
62
|
+
remove(): Promise<void>;
|
|
63
|
+
onChange(callback: (value: T) => void): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createHeavySignal<T>(
|
|
67
|
+
key: string,
|
|
68
|
+
initialValue: T,
|
|
69
|
+
options?: HeavyStorageOptions
|
|
70
|
+
): HeavySignal<T> {
|
|
71
|
+
const dbName = options?.dbName ?? 'typed-storage-heavy';
|
|
72
|
+
const listeners: Array<(value: T) => void> = [];
|
|
73
|
+
|
|
74
|
+
function notify(value: T): void {
|
|
75
|
+
listeners.forEach(cb => cb(value));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const signal: any = {};
|
|
79
|
+
|
|
80
|
+
signal.get = async function(): Promise<T> {
|
|
81
|
+
const db = await openDB(dbName);
|
|
82
|
+
const stored = await dbGet(db, key);
|
|
83
|
+
|
|
84
|
+
if (stored === undefined) return initialValue;
|
|
85
|
+
|
|
86
|
+
// Verifica TTL si existe
|
|
87
|
+
if (stored.expiresAt && Date.now() > stored.expiresAt) {
|
|
88
|
+
await dbDelete(db, key);
|
|
89
|
+
return initialValue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return stored.value ?? initialValue;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
signal.set = async function(value: T): Promise<void> {
|
|
96
|
+
const db = await openDB(dbName);
|
|
97
|
+
await dbSet(db, key, {
|
|
98
|
+
value,
|
|
99
|
+
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
100
|
+
});
|
|
101
|
+
notify(value);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
signal.remove = async function(): Promise<void> {
|
|
105
|
+
const db = await openDB(dbName);
|
|
106
|
+
await dbDelete(db, key);
|
|
107
|
+
notify(initialValue);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
signal.onChange = function(callback: (value: T) => void): void {
|
|
111
|
+
listeners.push(callback);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return signal as HeavySignal<T>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ===========================================
|
|
118
|
+
// Iteramos el schema completo
|
|
119
|
+
|
|
120
|
+
type HeavyStorageSchema = Record<string, any>;
|
|
121
|
+
|
|
122
|
+
type HeavyStorageResult<T extends HeavyStorageSchema> = {
|
|
123
|
+
[K in keyof T]: HeavySignal<T[K]>;
|
|
124
|
+
} & {
|
|
125
|
+
clear(): Promise<void>;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function createHeavyStorage<T extends HeavyStorageSchema>(
|
|
129
|
+
schema: T,
|
|
130
|
+
options?: HeavyStorageOptions
|
|
131
|
+
): HeavyStorageResult<T> {
|
|
132
|
+
const result: any = {};
|
|
133
|
+
|
|
134
|
+
const keys = Object.keys(schema);
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
result[key] = createHeavySignal(key, schema[key], options);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result.clear = async () => {
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
await result[key].remove();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return result as HeavyStorageResult<T>;
|
|
146
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export { createStorage } from './create-storage.js';
|
|
2
|
-
export
|
|
2
|
+
export { createHeavyStorage } from './heavy-storage.js';
|
|
3
|
+
export type {
|
|
4
|
+
StorageSignal,
|
|
5
|
+
StorageSchema,
|
|
6
|
+
StorageResult,
|
|
7
|
+
StorageSignalOptions
|
|
8
|
+
} from './types.js';
|
|
@@ -118,4 +118,44 @@ describe('compress option', () => {
|
|
|
118
118
|
|
|
119
119
|
expect(compressedSize).toBeLessThan(uncompressedSize);
|
|
120
120
|
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('encrypt option', () => {
|
|
124
|
+
beforeEach(() => localStorage.clear());
|
|
125
|
+
|
|
126
|
+
it('debe ofuscar el valor guardado en localStorage', () => {
|
|
127
|
+
const signal = createStorageSignal('token', '', {
|
|
128
|
+
encrypt: true,
|
|
129
|
+
secret: 'mi-clave'
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
signal.set('eyJhbGciOiJIUzI1NiJ9.test.jwt');
|
|
133
|
+
|
|
134
|
+
const rawStored = localStorage.getItem('token');
|
|
135
|
+
// El valor crudo NO debe contener el JWT en texto plano
|
|
136
|
+
expect(rawStored).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('debe desencriptar correctamente al leer', () => {
|
|
140
|
+
const signal = createStorageSignal('token', '', {
|
|
141
|
+
encrypt: true,
|
|
142
|
+
secret: 'mi-clave'
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const originalValue = 'eyJhbGciOiJIUzI1NiJ9.test.jwt';
|
|
146
|
+
signal.set(originalValue);
|
|
147
|
+
|
|
148
|
+
expect(signal()).toBe(originalValue);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('debe funcionar junto con TTL', () => {
|
|
152
|
+
const signal = createStorageSignal('token', '', {
|
|
153
|
+
encrypt: true,
|
|
154
|
+
secret: 'mi-clave',
|
|
155
|
+
ttl: 1000
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
signal.set('mi-token');
|
|
159
|
+
expect(signal()).toBe('mi-token');
|
|
160
|
+
});
|
|
121
161
|
});
|
package/src/storage-signal.ts
CHANGED
|
@@ -6,6 +6,9 @@ import { StorageSignal, StorageSignalOptions } from "./types.js";
|
|
|
6
6
|
// Memory
|
|
7
7
|
import { MemoryStorage } from "./memory-storage.js";
|
|
8
8
|
|
|
9
|
+
// Xor
|
|
10
|
+
import { xorEncrypt, xorDecrypt } from './xor.js';
|
|
11
|
+
|
|
9
12
|
// Interface
|
|
10
13
|
interface StoredValue<T> {
|
|
11
14
|
value: T;
|
|
@@ -66,9 +69,18 @@ export function createStorageSignal<T>(
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
const rawData = sto.getItem(key);
|
|
69
|
-
|
|
72
|
+
let savedData = options?.compress && rawData
|
|
70
73
|
? LZString.decompress(rawData)
|
|
71
74
|
: rawData;
|
|
75
|
+
|
|
76
|
+
// Desencripta si encrypt está activo
|
|
77
|
+
if ( options?.encrypt && options?.secret && savedData ) {
|
|
78
|
+
try {
|
|
79
|
+
savedData = xorDecrypt(savedData, options.secret);
|
|
80
|
+
} catch {
|
|
81
|
+
savedData = null; // si falla desencriptar, trata como vacío
|
|
82
|
+
}
|
|
83
|
+
}
|
|
72
84
|
let currentValue: T;
|
|
73
85
|
|
|
74
86
|
const listeners: Array<(value: T) => void> = [];
|
|
@@ -106,9 +118,17 @@ export function createStorageSignal<T>(
|
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
// Parseamos el nuevo valor
|
|
109
|
-
|
|
121
|
+
let rawNewValue = options?.compress
|
|
110
122
|
? LZString.decompress(event.newValue)
|
|
111
123
|
: event.newValue;
|
|
124
|
+
|
|
125
|
+
if ( options?.encrypt && options?.secret ) {
|
|
126
|
+
try {
|
|
127
|
+
rawNewValue = xorDecrypt(rawNewValue, options.secret);
|
|
128
|
+
} catch {
|
|
129
|
+
rawNewValue = '';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
112
132
|
const item = safeParseJSON(rawNewValue, initialValue);
|
|
113
133
|
|
|
114
134
|
notify(item.value as T);
|
|
@@ -126,10 +146,14 @@ export function createStorageSignal<T>(
|
|
|
126
146
|
expiresAt: options?.ttl ? Date.now() + options.ttl : undefined
|
|
127
147
|
});
|
|
128
148
|
|
|
129
|
-
|
|
149
|
+
let finalData = options?.compress
|
|
130
150
|
? LZString.compress(dataToStore)
|
|
131
151
|
: dataToStore;
|
|
132
152
|
|
|
153
|
+
if ( options?.encrypt && options?.secret ) {
|
|
154
|
+
finalData = xorEncrypt(finalData, options.secret);
|
|
155
|
+
}
|
|
156
|
+
|
|
133
157
|
sto.setItem( key, finalData );
|
|
134
158
|
}
|
|
135
159
|
|
package/src/types.ts
CHANGED
package/src/xor.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function xorTransform ( text: string, secret: string ): string {
|
|
2
|
+
return text.split('').map((char, i) => {
|
|
3
|
+
const keyChar = secret[i % secret.length];
|
|
4
|
+
return String.fromCharCode(
|
|
5
|
+
char.charCodeAt(0) ^ keyChar.charCodeAt(0)
|
|
6
|
+
);
|
|
7
|
+
}).join('');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function xorEncrypt ( text: string, secret: string ): string {
|
|
11
|
+
const xored = xorTransform(text, secret);
|
|
12
|
+
return btoa(xored);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function xorDecrypt ( text: string, secret: string ): string {
|
|
16
|
+
const xored = atob(text);
|
|
17
|
+
return xorTransform(xored, secret);
|
|
18
|
+
}
|
package/vitest.config.ts
CHANGED
package/vitest.setup.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import 'fake-indexeddb/auto';
|