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
+14 -3
View File
@@ -34,6 +34,7 @@ The monorepo is organized into **Apps** (deployable applications) and **Packages
├── packages/
│ ├── core-api/ # Shared HTTP Client, Observability & Data Services Engine
│ ├── core-storage/ # Enterprise Storage Engine (IndexedDB/localStorage + Encryption)
│ ├── ui/ # Shared UI Component Library
│ ├── utils/ # Shared Utilities (Date, Encryption, Core Logic, etc)
│ └── configs/ # Shared Tooling Configurations
@@ -238,7 +239,17 @@ The **platform-agnostic API engine** for the monorepo. Provides an isolated HTTP
---
### 6. `packages/utils`
### 6. `packages/core-storage`
The **Enterprise-grade storage engine** for the monorepo.
Provides a unified, Promise-based interface for interacting with browser storage (`localStorage` and `IndexedDB`). Enforces strict type safety, prevents key collisions via a centralized registry, and automatically provides **AES encryption at rest** for sensitive payloads using `@repo/utils`.
**Documentation**: [README.md](packages/core-storage/README.md)
---
### 7. `packages/utils`
Shared business logic and reusable utility modules that can be consumed across multiple applications. Fully tested using Vitest.
@@ -246,7 +257,7 @@ This package is intended to hold non-UI, cross-cutting logic such as date/time h
---
### 7. `packages/ui`
### 8. `packages/ui`
Shared UI component library (Buttons, Inputs, Cards, Layouts).
@@ -255,7 +266,7 @@ Shared UI component library (Buttons, Inputs, Cards, Layouts).
---
### 8. `packages/configs`
### 9. `packages/configs`
Single source of truth for tooling configuration.
+1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
"@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
@@ -1,4 +1,5 @@
import BookingSample from "./features/booking/presentation/BookingSample";
import StorageSample from "./features/storage/presentation/StorageSample";
export default function ExamplePage() {
return <div className="bg-amber-200">example
@@ -6,5 +7,9 @@ export default function ExamplePage() {
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<BookingSample />
</div>
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Enterprise Web App</h1>
<StorageSample />
</div>
</div>;
}
@@ -0,0 +1,275 @@
import { useState, useCallback } from 'react';
import { demoSecureStorage, demoIndexedDB, StorageKey } from '@repo/core-storage';
// ─── Demo Data ──────────────────────────────────────────────────
interface DemoUser {
id: number;
user: string;
role: string;
}
interface DemoDraft {
id: number;
type: string;
content: string;
}
const DEMO_USER: DemoUser = { id: 1, user: 'Firman', role: 'admin' };
const DEMO_DRAFT: DemoDraft = { id: 101, type: 'offline_draft', content: 'Draft data saved offline' };
const LS_KEY = StorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS)
const IDB_KEY = 'offline_draft'; // Plain key for IndexedDB demo
// ─── Shared Styles ──────────────────────────────────────────────
const btnStyle = (color: string) => ({
padding: '8px 16px',
fontSize: 14,
fontWeight: 600 as const,
cursor: 'pointer' as const,
background: color,
color: '#fff',
border: 'none',
borderRadius: 6,
});
const preStyle = {
marginTop: 16,
background: '#1e1e2e',
color: '#a6e3a1',
padding: 16,
borderRadius: 8,
minHeight: 60,
overflow: 'auto' as const,
fontSize: 13,
};
const logContainerStyle = {
background: '#0f0f17',
color: '#94a3b8',
padding: 12,
borderRadius: 8,
maxHeight: 200,
overflow: 'auto' as const,
fontSize: 12,
};
// ─── Reusable CRUD Button Row ───────────────────────────────────
interface CRUDAction {
label: string;
handler: () => void;
color: string;
}
function CRUDButtons({ actions }: { actions: CRUDAction[] }) {
return (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{actions.map(({ label, handler, color }) => (
<button key={label} onClick={handler} style={btnStyle(color)}>
{label}
</button>
))}
</div>
);
}
// ─── Component ──────────────────────────────────────────────────
/**
* Interactive demo for `@repo/core-storage`.
*
* Demonstrates the full CRUD lifecycle for BOTH storage backends:
* - **localStorage** (encrypted via AES for sensitive keys)
* - **IndexedDB** (Promise-wrapped, suitable for large payloads)
*
* Open the browser's DevTools:
* - **Application → Local Storage** to see AES-encrypted payloads
* - **Application → IndexedDB → app_db → kv_store** to see IDB entries
*/
export default function StorageSample() {
const [lsResult, setLsResult] = useState<string>('(no data read yet)');
const [idbResult, setIdbResult] = useState<string>('(no data read yet)');
const [log, setLog] = useState<string[]>([]);
const pushLog = useCallback((msg: string) => {
setLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
}, []);
// ═══════════════════════════════════════════════════════════════
// ── localStorage CRUD ─────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
const lsCreate = useCallback(async () => {
await demoSecureStorage.setItem(LS_KEY, DEMO_USER);
pushLog(`[LS] CREATE → Stored encrypted: ${JSON.stringify(DEMO_USER)}`);
}, [pushLog]);
const lsRead = useCallback(async () => {
const result = await demoSecureStorage.getItem<DemoUser>(LS_KEY);
if (result) {
setLsResult(JSON.stringify(result, null, 2));
pushLog(`[LS] READ → Decrypted: ${JSON.stringify(result)}`);
} else {
setLsResult('(null — no data found)');
pushLog('[LS] READ → null (key does not exist)');
}
}, [pushLog]);
const lsUpdate = useCallback(async () => {
const existing = await demoSecureStorage.getItem<DemoUser>(LS_KEY);
if (!existing) {
pushLog('[LS] UPDATE → Failed: key does not exist. Create first.');
return;
}
const updated: DemoUser = { ...existing, role: 'superadmin', id: existing.id + 1 };
await demoSecureStorage.setItem(LS_KEY, updated);
pushLog(`[LS] UPDATE → Re-encrypted: ${JSON.stringify(updated)}`);
}, [pushLog]);
const lsDelete = useCallback(async () => {
await demoSecureStorage.removeItem(LS_KEY);
setLsResult('(deleted)');
pushLog(`[LS] DELETE → Removed key "${LS_KEY}"`);
}, [pushLog]);
const lsClear = useCallback(async () => {
await demoSecureStorage.clear();
setLsResult('(cleared)');
pushLog('[LS] CLEAR → All localStorage keys removed');
}, [pushLog]);
// ═══════════════════════════════════════════════════════════════
// ── IndexedDB CRUD ────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
const idbCreate = useCallback(async () => {
try {
await demoIndexedDB.setItem(IDB_KEY, DEMO_DRAFT);
pushLog(`[IDB] CREATE → Stored: ${JSON.stringify(DEMO_DRAFT)}`);
} catch (err) {
pushLog(`[IDB] CREATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbRead = useCallback(async () => {
try {
const result = await demoIndexedDB.getItem<DemoDraft>(IDB_KEY);
if (result) {
setIdbResult(JSON.stringify(result, null, 2));
pushLog(`[IDB] READ → Retrieved: ${JSON.stringify(result)}`);
} else {
setIdbResult('(null — no data found)');
pushLog('[IDB] READ → null (key does not exist)');
}
} catch (err) {
pushLog(`[IDB] READ → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbUpdate = useCallback(async () => {
try {
const existing = await demoIndexedDB.getItem<DemoDraft>(IDB_KEY);
if (!existing) {
pushLog('[IDB] UPDATE → Failed: key does not exist. Create first.');
return;
}
const updated: DemoDraft = {
...existing,
id: existing.id + 1,
content: `Updated at ${new Date().toLocaleTimeString()}`,
};
await demoIndexedDB.setItem(IDB_KEY, updated);
pushLog(`[IDB] UPDATE → Persisted: ${JSON.stringify(updated)}`);
} catch (err) {
pushLog(`[IDB] UPDATE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbDelete = useCallback(async () => {
try {
await demoIndexedDB.removeItem(IDB_KEY);
setIdbResult('(deleted)');
pushLog(`[IDB] DELETE → Removed key "${IDB_KEY}"`);
} catch (err) {
pushLog(`[IDB] DELETE → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
const idbClear = useCallback(async () => {
try {
await demoIndexedDB.clear();
setIdbResult('(cleared)');
pushLog('[IDB] CLEAR → All IndexedDB entries removed');
} catch (err) {
pushLog(`[IDB] CLEAR → ERROR: ${err instanceof Error ? err.message : String(err)}`);
}
}, [pushLog]);
// ═══════════════════════════════════════════════════════════════
// ── Render ────────────────────────────────────────────────────
// ═══════════════════════════════════════════════════════════════
return (
<div style={{ padding: 24, fontFamily: 'monospace', maxWidth: 900 }}>
<h2>🔐 @repo/core-storage Dual Backend CRUD Demo</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginTop: 16 }}>
{/* ── Left: localStorage ─────────────────────────────────── */}
<div>
<h3 style={{ color: '#22c55e' }}>📦 localStorage (AES Encrypted)</h3>
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
Key: <code>{LS_KEY}</code> stored encrypted at rest
<br />
Verify: <strong>DevTools Application Local Storage</strong>
</p>
<CRUDButtons
actions={[
{ label: ' Create', handler: lsCreate, color: '#22c55e' },
{ label: '📖 Read', handler: lsRead, color: '#3b82f6' },
{ label: '✏️ Update', handler: lsUpdate, color: '#f59e0b' },
{ label: '🗑️ Delete', handler: lsDelete, color: '#ef4444' },
{ label: '💣 Clear', handler: lsClear, color: '#6b7280' },
]}
/>
<pre style={preStyle}>{lsResult}</pre>
</div>
{/* ── Right: IndexedDB ───────────────────────────────────── */}
<div>
<h3 style={{ color: '#8b5cf6' }}>🗃 IndexedDB (app_db / kv_store)</h3>
<p style={{ color: '#888', fontSize: 12, marginBottom: 12 }}>
Key: <code>{IDB_KEY}</code> plain JSON (not in ENCRYPTED_KEYS)
<br />
Verify: <strong>DevTools Application IndexedDB app_db</strong>
</p>
<CRUDButtons
actions={[
{ label: ' Create', handler: idbCreate, color: '#8b5cf6' },
{ label: '📖 Read', handler: idbRead, color: '#06b6d4' },
{ label: '✏️ Update', handler: idbUpdate, color: '#f59e0b' },
{ label: '🗑️ Delete', handler: idbDelete, color: '#ef4444' },
{ label: '💣 Clear', handler: idbClear, color: '#6b7280' },
]}
/>
<pre style={preStyle}>{idbResult}</pre>
</div>
</div>
{/* ── Shared Action Log ────────────────────────────────────── */}
<h3 style={{ marginTop: 24 }}>📋 Action Log</h3>
<div style={logContainerStyle}>
{log.length === 0 ? (
<span style={{ color: '#475569' }}>(no actions yet)</span>
) : (
log.map((entry, i) => <div key={i}>{entry}</div>)
)}
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SpanStatusCode } from '@opentelemetry/api';
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError, AxiosHeaders } from 'axios';
+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
}
}
+25
View File
@@ -152,6 +152,9 @@ importers:
'@repo/core-api':
specifier: workspace:*
version: link:../../packages/core-api
'@repo/core-storage':
specifier: workspace:*
version: link:../../packages/core-storage
'@repo/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -282,6 +285,28 @@ importers:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/core-storage:
dependencies:
'@repo/utils':
specifier: workspace:*
version: link:../utils
devDependencies:
'@repo/eslint-config':
specifier: workspace:*
version: link:../configs/eslint
'@repo/typescript-config':
specifier: workspace:*
version: link:../configs/typescript
eslint:
specifier: ^8.57.1
version: 8.57.1
typescript:
specifier: 5.5.4
version: 5.5.4
vitest:
specifier: ^4.0.17
version: 4.0.17(@opentelemetry/api@1.9.1)
packages/ui:
dependencies:
'@mantine/core':