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
+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);
});
});
});