feat: implement core-storage package with unified promise-based interface and encrypted-at-rest support

This commit is contained in:
Firman Ramdhani
2026-05-22 22:23:02 +07:00
parent 9b265b8f46
commit 255704f867
15 changed files with 1091 additions and 4 deletions
+114
View File
@@ -0,0 +1,114 @@
# @repo/core-storage
The **Enterprise-grade storage engine** for the monorepo.
This package provides a unified, Promise-based interface for interacting with browser storage (`localStorage` and `IndexedDB`). It enforces strict type safety, prevents key collisions, and automatically provides **AES encryption at rest** for sensitive payloads (like access tokens) using `@repo/utils`.
---
## 🎯 Primary Goals & Separation of Concerns
* **Separation from `@repo/core-api`**: Storage is a fundamental primitive. While the API client uses storage (to retrieve tokens), storage itself does not need to know about HTTP requests.
* **Dual Backend Strategy**:
* `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences).
* `IndexedDBService`: Built for large, asynchronous data (offline drafts, cached API responses, blobs) without the 5MB size limit.
* **Security by Default**: Developers don't need to manually encrypt/decrypt data. If a key is marked as sensitive, the library handles AES encryption transparently.
---
## ✨ Key Features
| Feature | Description |
|---|---|
| πŸ”’ **Selective Encryption** | Uses `@repo/utils` `EncryptionUtils` to automatically AES-encrypt payloads whose keys are listed in `ENCRYPTED_KEYS`. |
| πŸ›‘οΈ **Type-Safe Keys** | All keys must be registered in `storage.key.ts`. Prevents typos and key collisions across the monorepo. |
| πŸ”„ **Unified Promise API** | Both `localStorage` and `IndexedDB` implement the same async `IStorageService` interface. |
| 🧬 **Strict Generics** | Read and write operations enforce payload types via generics (e.g., `getItem<UserProfile>('user_profile')`). |
| 🩹 **Corrupt Data Resilience** | If parsing or decryption fails (e.g., tampered data), the corrupt entry is safely removed and returns `null`. |
---
## πŸš€ Usage Examples
### 1. Secure Local Storage (Tokens, Profile)
Use `demoSecureStorage` (or your app's configured instance) for small payloads. If the key is in `ENCRYPTED_KEYS`, it will be encrypted at rest.
```typescript
import { demoSecureStorage, StorageKey } from '@repo/core-storage';
import type { UserProfile } from '@/types';
// CREATE / UPDATE
// If StorageKey.USER_PROFILE is in ENCRYPTED_KEYS, this is AES-encrypted automatically.
await demoSecureStorage.setItem(StorageKey.USER_PROFILE, {
id: 1,
name: 'Firman',
role: 'admin'
});
// READ
const profile = await demoSecureStorage.getItem<UserProfile>(StorageKey.USER_PROFILE);
if (profile) {
console.log('Welcome back,', profile.name);
}
// DELETE
await demoSecureStorage.removeItem(StorageKey.USER_PROFILE);
```
### 2. IndexedDB (Offline Data, Large Payloads)
Use the pre-configured `demoIndexedDB` (or instantiate your own) for large, asynchronous data. It uses the exact same API and encryption pipeline.
```typescript
import { demoIndexedDB } from '@repo/core-storage';
interface DraftData {
id: string;
content: string;
lastModified: number;
}
// Save a large draft offline
await demoIndexedDB.setItem('offline_draft_123', {
id: '123',
content: 'Huge text content...',
lastModified: Date.now()
});
// Retrieve the draft
const draft = await demoIndexedDB.getItem<DraftData>('offline_draft_123');
```
---
## πŸ”‘ Adding New Keys
To maintain type safety and avoid collisions, **all** `localStorage` keys must be registered in `packages/core-storage/src/storage.key.ts`.
### 1. Register the Key
Add your key to the `StorageKey` object:
```typescript
export const StorageKey = {
// ... existing keys
MY_NEW_FEATURE: 'my_new_feature_key',
} as const;
```
### 2. Define Encryption (If Needed)
If the data stored under this key is sensitive (e.g., PII, tokens, financials), add it to the `ENCRYPTED_KEYS` set.
```typescript
export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
StorageKey.ACCESS_TOKEN,
StorageKey.REFRESH_TOKEN,
StorageKey.USER_PROFILE,
StorageKey.MY_NEW_FEATURE, // <--- Now encrypted at rest!
]);
```
> [!WARNING]
> If you add an existing plain-text key to `ENCRYPTED_KEYS`, existing users will experience a parsing failure on their next session (because the library expects encrypted data but finds plain text). The resilient parser will catch this and clear the key, effectively logging them out or resetting the preference.
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@repo/core-storage",
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"license": "MIT",
"scripts": {
"lint": "eslint \"**/*.ts\"",
"test": "vitest run",
"test:watch": "vitest --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@repo/utils": "workspace:*"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"eslint": "^8.57.1",
"typescript": "5.5.4",
"vitest": "^4.0.17"
}
}
+49
View File
@@ -0,0 +1,49 @@
// ─── Interfaces ─────────────────────────────────────────────────
export type { IStorageService } from './storage.interface';
// ─── Key Registry ───────────────────────────────────────────────
export { StorageKey, ENCRYPTED_KEYS } from './storage.key';
export type { StorageKeyValue } from './storage.key';
// ─── Service Classes ────────────────────────────────────────────
export { LocalStorageService } from './local-storage.service';
export { IndexedDBService } from './indexed-db.service';
// ─── Pre-configured Instances ───────────────────────────────────
import { LocalStorageService } from './local-storage.service';
import { IndexedDBService } from './indexed-db.service';
/**
* Default secure localStorage instance.
*
* Keys listed in `ENCRYPTED_KEYS` are automatically encrypted via
* `@repo/utils` `EncryptionUtils`. All other keys are plain JSON.
*
* @example
* ```ts
* import { demoSecureStorage, StorageKey } from '@repo/core-storage';
*
* await demoSecureStorage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
* const token = await demoSecureStorage.getItem<string>(StorageKey.ACCESS_TOKEN);
* ```
*/
export const demoSecureStorage = new LocalStorageService();
/**
* Default IndexedDB instance.
*
* Uses `app_db` database with a `kv_store` object store.
* Sensitive keys are encrypted at rest using the same
* `EncryptionUtils` pipeline as `demoSecureStorage`.
*
* @example
* ```ts
* import { demoIndexedDB } from '@repo/core-storage';
*
* await demoIndexedDB.setItem('offline_draft', { content: '...' });
* const draft = await demoIndexedDB.getItem<Draft>('offline_draft');
* ```
*/
export const demoIndexedDB = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store' });
export const demoIndexedDB2 = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store_2' });
@@ -0,0 +1,174 @@
import { EncryptionUtils } from '@repo/utils';
import type { IStorageService } from './storage.interface';
import { ENCRYPTED_KEYS } from './storage.key';
// ─── Types ──────────────────────────────────────────────────────
interface IndexedDBConfig {
/** Database name. @default 'app_db' */
dbName?: string;
/** Object store name. @default 'kv_store' */
storeName?: string;
/** Database version. @default 1 */
version?: number;
}
// ─── Helpers ────────────────────────────────────────────────────
/**
* Open (or create) an IndexedDB database with a simple key-value store.
* Returns a Promise that resolves with the IDBDatabase instance.
*/
function openDatabase(
dbName: string,
storeName: string,
version: number,
): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* Execute a single IndexedDB transaction and return the result.
* Handles open β†’ transaction β†’ request β†’ close lifecycle cleanly.
*/
function withTransaction<R>(
db: IDBDatabase,
storeName: string,
mode: IDBTransactionMode,
operation: (store: IDBObjectStore) => IDBRequest<R>,
): Promise<R> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
const request = operation(store);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// ─── Service ────────────────────────────────────────────────────
/**
* Enterprise-grade IndexedDB wrapper with optional AES encryption.
*
* Uses a simple key-value object store pattern. Keys listed in
* `ENCRYPTED_KEYS` are automatically encrypted/decrypted using
* `@repo/utils` `EncryptionUtils`.
*
* Unlike localStorage, IndexedDB has no 5MB size limit β€” making
* it suitable for large payloads like cached API responses, offline
* data, or file blobs.
*
* @example
* ```ts
* const idb = new IndexedDBService({ dbName: 'my_app' });
* await idb.setItem('large_dataset', hugePayload);
* const data = await idb.getItem<HugePayload>('large_dataset');
* ```
*/
export class IndexedDBService implements IStorageService {
private readonly encryption: EncryptionUtils;
private readonly dbName: string;
private readonly storeName: string;
private readonly version: number;
private dbPromise: Promise<IDBDatabase> | null = null;
constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) {
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
this.dbName = config?.dbName ?? 'app_db';
this.storeName = config?.storeName ?? 'kv_store';
this.version = config?.version ?? 1;
}
/** Lazy-open the database connection (cached). */
private getDB(): Promise<IDBDatabase> {
if (!this.dbPromise) {
this.dbPromise = openDatabase(this.dbName, this.storeName, this.version);
}
return this.dbPromise;
}
private shouldEncrypt(key: string): boolean {
return ENCRYPTED_KEYS.has(key);
}
async setItem<T>(key: string, value: T): Promise<void> {
const db = await this.getDB();
const serialized = JSON.stringify(value);
const payload = this.shouldEncrypt(key)
? this.encryption.encrypt(serialized)
: serialized;
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.put(payload, key),
);
}
async getItem<T>(key: string): Promise<T | null> {
const db = await this.getDB();
const raw = await withTransaction<string | undefined>(
db,
this.storeName,
'readonly',
(store) => store.get(key) as IDBRequest<string | undefined>,
);
if (raw === undefined || raw === null) return null;
try {
if (this.shouldEncrypt(key)) {
const decrypted = this.encryption.decrypt(raw);
if (!decrypted) return null;
return JSON.parse(decrypted) as T;
}
return JSON.parse(raw) as T;
} catch {
console.warn(`[core-storage] Failed to parse IndexedDB key "${key}". Removing corrupt entry.`);
await this.removeItem(key);
return null;
}
}
async removeItem(key: string): Promise<void> {
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.delete(key),
);
}
async clear(): Promise<void> {
const db = await this.getDB();
await withTransaction(db, this.storeName, 'readwrite', (store) =>
store.clear(),
);
}
async hasItem(key: string): Promise<boolean> {
const value = await this.getItem(key);
return value !== null;
}
async keys(): Promise<string[]> {
const db = await this.getDB();
return withTransaction<string[]>(
db,
this.storeName,
'readonly',
(store) => store.getAllKeys() as IDBRequest<string[]>,
);
}
}
@@ -0,0 +1,88 @@
import { EncryptionUtils } from '@repo/utils';
import type { IStorageService } from './storage.interface';
import { ENCRYPTED_KEYS } from './storage.key';
/**
* Enterprise-grade localStorage wrapper with optional AES encryption.
*
* Keys listed in `ENCRYPTED_KEYS` are automatically encrypted before
* writing and decrypted on read using `@repo/utils` `EncryptionUtils`.
* All other keys are stored as plain JSON.
*
* All methods are async (returning Promises) to conform to the
* `IStorageService` interface, ensuring consumers can swap between
* localStorage and IndexedDB without code changes.
*
* @example
* ```ts
* const storage = new LocalStorageService();
*
* // Encrypted at rest (ACCESS_TOKEN is in ENCRYPTED_KEYS)
* await storage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...');
*
* // Plain JSON (THEME is NOT in ENCRYPTED_KEYS)
* await storage.setItem(StorageKey.THEME, 'dark');
* ```
*/
export class LocalStorageService implements IStorageService {
private readonly encryption: EncryptionUtils;
constructor(encryptionUtils?: EncryptionUtils) {
this.encryption = encryptionUtils ?? EncryptionUtils.getInstance();
}
/** Check if a key should be encrypted. */
private shouldEncrypt(key: string): boolean {
return ENCRYPTED_KEYS.has(key);
}
async setItem<T>(key: string, value: T): Promise<void> {
const serialized = JSON.stringify(value);
if (this.shouldEncrypt(key)) {
const encrypted = this.encryption.encrypt(serialized);
localStorage.setItem(key, encrypted);
} else {
localStorage.setItem(key, serialized);
}
}
async getItem<T>(key: string): Promise<T | null> {
const raw = localStorage.getItem(key);
if (raw === null) return null;
try {
if (this.shouldEncrypt(key)) {
const decrypted = this.encryption.decrypt(raw);
if (!decrypted) return null;
return JSON.parse(decrypted) as T;
}
return JSON.parse(raw) as T;
} catch {
console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`);
localStorage.removeItem(key);
return null;
}
}
async removeItem(key: string): Promise<void> {
localStorage.removeItem(key);
}
async clear(): Promise<void> {
localStorage.clear();
}
async hasItem(key: string): Promise<boolean> {
return localStorage.getItem(key) !== null;
}
async keys(): Promise<string[]> {
const result: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key !== null) result.push(key);
}
return result;
}
}
@@ -0,0 +1,38 @@
/**
* Generic storage interface contract.
*
* All storage implementations (localStorage, IndexedDB) must
* conform to this interface. Methods use generics to enforce
* type-safe serialization/deserialization at the consumer level.
*
* @example
* ```ts
* const user = await storage.getItem<UserProfile>(StorageKey.USER_PROFILE);
* ```
*/
export interface IStorageService {
/**
* Persist a value under the given key.
* The value is JSON-serialized before storage.
* If encryption is enabled, the serialized payload is encrypted at rest.
*/
setItem<T>(key: string, value: T): Promise<void>;
/**
* Retrieve and deserialize a value by key.
* Returns `null` if the key does not exist or decryption/parsing fails.
*/
getItem<T>(key: string): Promise<T | null>;
/** Remove a single key from storage. */
removeItem(key: string): Promise<void>;
/** Remove all keys managed by this storage instance. */
clear(): Promise<void>;
/** Check if a key exists in storage. */
hasItem(key: string): Promise<boolean>;
/** Get all keys currently in storage. */
keys(): Promise<string[]>;
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Centralized storage key registry.
*
* ALL keys used across the application MUST be registered here
* as string literal constants. This prevents key collisions,
* enables grep-ability, and provides a single source of truth
* for what data is persisted in the browser.
*
* Convention: `SCREAMING_SNAKE_CASE` for the constant,
* `kebab-case` or `snake_case` for the actual string value.
*
* @example
* ```ts
* await secureStorage.setItem(StorageKey.ACCESS_TOKEN, token);
* ```
*/
export const StorageKey = {
// ── Auth ─────────────────────────────────────────────────────
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
USER_PERMISSIONS: 'user_permissions',
// ── App Preferences ──────────────────────────────────────────
THEME: 'app_theme',
LOCALE: 'app_locale',
SIDEBAR_COLLAPSED: 'sidebar_collapsed',
// ── Session ──────────────────────────────────────────────────
FARO_SESSION: 'faroSession',
LAST_ACTIVE_ROUTE: 'last_active_route',
// ── Feature Flags / Cache ────────────────────────────────────
FEATURE_FLAGS: 'feature_flags',
CACHE_VERSION: 'cache_version',
} as const;
/** Union type of all registered storage key values. */
export type StorageKeyValue = (typeof StorageKey)[keyof typeof StorageKey];
/**
* Keys that require encryption at rest.
*
* Any key listed here will be automatically encrypted before
* writing to storage and decrypted on read. All other keys
* are stored as plain JSON.
*/
export const ENCRYPTED_KEYS: ReadonlySet<string> = new Set<string>([
StorageKey.ACCESS_TOKEN,
StorageKey.REFRESH_TOKEN,
StorageKey.USER_PROFILE,
StorageKey.USER_PERMISSIONS,
]);
+220
View File
@@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LocalStorageService } from './local-storage.service';
import { StorageKey, ENCRYPTED_KEYS } from './storage.key';
// ─── Mock @repo/utils EncryptionUtils ───────────────────────────
const mockEncrypt = vi.fn((data: string) => `ENC[${data}]`);
const mockDecrypt = vi.fn((data: string) => {
// Strip the ENC[] wrapper
const match = data.match(/^ENC\[(.+)\]$/);
return match ? match[1] : '';
});
const mockEncryptionUtils = {
encrypt: mockEncrypt,
decrypt: mockDecrypt,
};
// ─── Mock browser localStorage ──────────────────────────────────
const store: Record<string, string> = {};
const mockLocalStorage: Storage = {
getItem: vi.fn((key: string): string | null => store[key] ?? null),
setItem: vi.fn((key: string, value: string): void => { store[key] = value; }),
removeItem: vi.fn((key: string): void => { delete store[key]; }),
clear: vi.fn((): void => { for (const key of Object.keys(store)) delete store[key]; }),
get length() { return Object.keys(store).length; },
key(index: number): string | null { return Object.keys(store)[index] ?? null; },
};
// Install mock
Object.defineProperty(globalThis, 'localStorage', {
value: mockLocalStorage,
writable: true,
});
// ─── Test Data ──────────────────────────────────────────────────
interface TestUser {
id: number;
name: string;
role: string;
}
const testUser: TestUser = { id: 1, name: 'Firman', role: 'admin' };
// ─── Tests ──────────────────────────────────────────────────────
describe('LocalStorageService', () => {
let storage: LocalStorageService;
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(store)) delete store[key];
// Pass mock encryption utils to avoid importing real crypto-js
storage = new LocalStorageService(mockEncryptionUtils as never);
});
// ── setItem / getItem ─────────────────────────────────────────
describe('setItem / getItem', () => {
it('stores and retrieves a plain object (non-encrypted key)', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
const result = await storage.getItem<string>(StorageKey.THEME);
expect(result).toBe('dark');
});
it('stores plain JSON without encryption for non-sensitive keys', async () => {
await storage.setItem(StorageKey.LOCALE, 'en-US');
expect(mockEncrypt).not.toHaveBeenCalled();
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
StorageKey.LOCALE,
'"en-US"',
);
});
it('encrypts sensitive keys (ACCESS_TOKEN)', async () => {
const token = 'eyJhbGciOiJIUzI1NiJ9.test';
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(token));
// The stored value should be the encrypted payload
expect(store[StorageKey.ACCESS_TOKEN]).toBe(`ENC[${JSON.stringify(token)}]`);
});
it('decrypts sensitive keys on read', async () => {
const token = 'secret_token_123';
await storage.setItem(StorageKey.ACCESS_TOKEN, token);
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
expect(mockDecrypt).toHaveBeenCalled();
expect(result).toBe(token);
});
it('stores and retrieves complex objects with generics', async () => {
await storage.setItem(StorageKey.THEME, testUser);
const result = await storage.getItem<TestUser>(StorageKey.THEME);
expect(result).toEqual(testUser);
});
it('stores complex objects encrypted for sensitive keys', async () => {
await storage.setItem(StorageKey.USER_PROFILE, testUser);
expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(testUser));
const result = await storage.getItem<TestUser>(StorageKey.USER_PROFILE);
expect(result).toEqual(testUser);
});
it('returns null for non-existent keys', async () => {
const result = await storage.getItem<string>('nonexistent');
expect(result).toBeNull();
});
it('handles corrupt/invalid JSON gracefully', async () => {
store[StorageKey.THEME] = '{invalid json';
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await storage.getItem<string>(StorageKey.THEME);
expect(result).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to parse key'),
);
// Corrupt entry should be cleaned up
expect(store[StorageKey.THEME]).toBeUndefined();
warnSpy.mockRestore();
});
it('handles failed decryption gracefully', async () => {
// Write raw garbage to an encrypted key
store[StorageKey.ACCESS_TOKEN] = 'not-encrypted-data';
mockDecrypt.mockReturnValueOnce('');
const result = await storage.getItem<string>(StorageKey.ACCESS_TOKEN);
expect(result).toBeNull();
});
});
// ── removeItem ────────────────────────────────────────────────
describe('removeItem', () => {
it('removes a key from storage', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
await storage.removeItem(StorageKey.THEME);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(StorageKey.THEME);
const result = await storage.getItem<string>(StorageKey.THEME);
expect(result).toBeNull();
});
});
// ── clear ─────────────────────────────────────────────────────
describe('clear', () => {
it('clears all keys from storage', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
await storage.setItem(StorageKey.LOCALE, 'en');
await storage.clear();
expect(mockLocalStorage.clear).toHaveBeenCalled();
expect(Object.keys(store)).toHaveLength(0);
});
});
// ── hasItem ───────────────────────────────────────────────────
describe('hasItem', () => {
it('returns true for existing keys', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
expect(await storage.hasItem(StorageKey.THEME)).toBe(true);
});
it('returns false for non-existent keys', async () => {
expect(await storage.hasItem('ghost_key')).toBe(false);
});
});
// ── keys ──────────────────────────────────────────────────────
describe('keys', () => {
it('returns all stored keys', async () => {
await storage.setItem(StorageKey.THEME, 'dark');
await storage.setItem(StorageKey.LOCALE, 'en');
const allKeys = await storage.keys();
expect(allKeys).toContain(StorageKey.THEME);
expect(allKeys).toContain(StorageKey.LOCALE);
expect(allKeys).toHaveLength(2);
});
});
// ── Encryption Key Classification ─────────────────────────────
describe('encryption key classification', () => {
it('ACCESS_TOKEN is in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.ACCESS_TOKEN)).toBe(true);
});
it('REFRESH_TOKEN is in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.REFRESH_TOKEN)).toBe(true);
});
it('USER_PROFILE is in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.USER_PROFILE)).toBe(true);
});
it('THEME is NOT in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.THEME)).toBe(false);
});
it('LOCALE is NOT in ENCRYPTED_KEYS', () => {
expect(ENCRYPTED_KEYS.has(StorageKey.LOCALE)).toBe(false);
});
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@repo/typescript-config/library.json",
"include": ["src"],
"compilerOptions": {
"strict": true,
"declaration": true,
"declarationMap": true
}
}