diff --git a/README.md b/README.md
index b0b2e49..b533b4a 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/apps/web/package.json b/apps/web/package.json
index 140d498..eac6aec 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@repo/core-api": "workspace:*",
+ "@repo/core-storage": "workspace:*",
"@repo/ui": "workspace:*",
"@repo/utils": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
diff --git a/apps/web/src/apps/modules/example/example.page.tsx b/apps/web/src/apps/modules/example/example.page.tsx
index af334a7..2711f00 100644
--- a/apps/web/src/apps/modules/example/example.page.tsx
+++ b/apps/web/src/apps/modules/example/example.page.tsx
@@ -1,4 +1,5 @@
import BookingSample from "./features/booking/presentation/BookingSample";
+import StorageSample from "./features/storage/presentation/StorageSample";
export default function ExamplePage() {
return
example
@@ -6,5 +7,9 @@ export default function ExamplePage() {
Enterprise Web App
+
+
Enterprise Web App
+
+
;
}
diff --git a/apps/web/src/apps/modules/example/features/storage/presentation/StorageSample.tsx b/apps/web/src/apps/modules/example/features/storage/presentation/StorageSample.tsx
new file mode 100644
index 0000000..a88f6ae
--- /dev/null
+++ b/apps/web/src/apps/modules/example/features/storage/presentation/StorageSample.tsx
@@ -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 (
+
+ {actions.map(({ label, handler, color }) => (
+
+ ))}
+
+ );
+}
+
+// ─── 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('(no data read yet)');
+ const [idbResult, setIdbResult] = useState('(no data read yet)');
+ const [log, setLog] = useState([]);
+
+ 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(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(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(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(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 (
+
+
🔐 @repo/core-storage — Dual Backend CRUD Demo
+
+
+ {/* ── Left: localStorage ─────────────────────────────────── */}
+
+
📦 localStorage (AES Encrypted)
+
+ Key: {LS_KEY} — stored encrypted at rest
+
+ Verify: DevTools → Application → Local Storage
+
+
+
+
+
{lsResult}
+
+
+ {/* ── Right: IndexedDB ───────────────────────────────────── */}
+
+
🗃️ IndexedDB (app_db / kv_store)
+
+ Key: {IDB_KEY} — plain JSON (not in ENCRYPTED_KEYS)
+
+ Verify: DevTools → Application → IndexedDB → app_db
+
+
+
+
+
{idbResult}
+
+
+
+ {/* ── Shared Action Log ────────────────────────────────────── */}
+
📋 Action Log
+
+ {log.length === 0 ? (
+
(no actions yet)
+ ) : (
+ log.map((entry, i) =>
{entry}
)
+ )}
+
+
+ );
+}
diff --git a/packages/core-api/src/observability/otel.adapter.test.ts b/packages/core-api/src/observability/otel.adapter.test.ts
index c1de9c7..0b1db20 100644
--- a/packages/core-api/src/observability/otel.adapter.test.ts
+++ b/packages/core-api/src/observability/otel.adapter.test.ts
@@ -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';
diff --git a/packages/core-storage/README.md b/packages/core-storage/README.md
new file mode 100644
index 0000000..347f967
--- /dev/null
+++ b/packages/core-storage/README.md
@@ -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('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(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('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 = new Set([
+ 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.
diff --git a/packages/core-storage/package.json b/packages/core-storage/package.json
new file mode 100644
index 0000000..3fb198d
--- /dev/null
+++ b/packages/core-storage/package.json
@@ -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"
+ }
+}
diff --git a/packages/core-storage/src/index.ts b/packages/core-storage/src/index.ts
new file mode 100644
index 0000000..b8c1efb
--- /dev/null
+++ b/packages/core-storage/src/index.ts
@@ -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(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('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' });
diff --git a/packages/core-storage/src/indexed-db.service.ts b/packages/core-storage/src/indexed-db.service.ts
new file mode 100644
index 0000000..7e0789c
--- /dev/null
+++ b/packages/core-storage/src/indexed-db.service.ts
@@ -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 {
+ 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(
+ db: IDBDatabase,
+ storeName: string,
+ mode: IDBTransactionMode,
+ operation: (store: IDBObjectStore) => IDBRequest,
+): Promise {
+ 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('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 | 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 {
+ 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(key: string, value: T): Promise {
+ 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(key: string): Promise {
+ const db = await this.getDB();
+
+ const raw = await withTransaction(
+ db,
+ this.storeName,
+ 'readonly',
+ (store) => store.get(key) as IDBRequest,
+ );
+
+ 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 {
+ const db = await this.getDB();
+ await withTransaction(db, this.storeName, 'readwrite', (store) =>
+ store.delete(key),
+ );
+ }
+
+ async clear(): Promise {
+ const db = await this.getDB();
+ await withTransaction(db, this.storeName, 'readwrite', (store) =>
+ store.clear(),
+ );
+ }
+
+ async hasItem(key: string): Promise {
+ const value = await this.getItem(key);
+ return value !== null;
+ }
+
+ async keys(): Promise {
+ const db = await this.getDB();
+ return withTransaction(
+ db,
+ this.storeName,
+ 'readonly',
+ (store) => store.getAllKeys() as IDBRequest,
+ );
+ }
+}
diff --git a/packages/core-storage/src/local-storage.service.ts b/packages/core-storage/src/local-storage.service.ts
new file mode 100644
index 0000000..7c319b4
--- /dev/null
+++ b/packages/core-storage/src/local-storage.service.ts
@@ -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(key: string, value: T): Promise {
+ 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(key: string): Promise {
+ 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 {
+ localStorage.removeItem(key);
+ }
+
+ async clear(): Promise {
+ localStorage.clear();
+ }
+
+ async hasItem(key: string): Promise {
+ return localStorage.getItem(key) !== null;
+ }
+
+ async keys(): Promise {
+ const result: string[] = [];
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key !== null) result.push(key);
+ }
+ return result;
+ }
+}
diff --git a/packages/core-storage/src/storage.interface.ts b/packages/core-storage/src/storage.interface.ts
new file mode 100644
index 0000000..702623e
--- /dev/null
+++ b/packages/core-storage/src/storage.interface.ts
@@ -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(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(key: string, value: T): Promise;
+
+ /**
+ * Retrieve and deserialize a value by key.
+ * Returns `null` if the key does not exist or decryption/parsing fails.
+ */
+ getItem(key: string): Promise;
+
+ /** Remove a single key from storage. */
+ removeItem(key: string): Promise;
+
+ /** Remove all keys managed by this storage instance. */
+ clear(): Promise;
+
+ /** Check if a key exists in storage. */
+ hasItem(key: string): Promise;
+
+ /** Get all keys currently in storage. */
+ keys(): Promise;
+}
diff --git a/packages/core-storage/src/storage.key.ts b/packages/core-storage/src/storage.key.ts
new file mode 100644
index 0000000..7f85573
--- /dev/null
+++ b/packages/core-storage/src/storage.key.ts
@@ -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 = new Set([
+ StorageKey.ACCESS_TOKEN,
+ StorageKey.REFRESH_TOKEN,
+ StorageKey.USER_PROFILE,
+ StorageKey.USER_PERMISSIONS,
+]);
diff --git a/packages/core-storage/src/storage.test.ts b/packages/core-storage/src/storage.test.ts
new file mode 100644
index 0000000..0c2542f
--- /dev/null
+++ b/packages/core-storage/src/storage.test.ts
@@ -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 = {};
+
+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(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(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(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(StorageKey.USER_PROFILE);
+ expect(result).toEqual(testUser);
+ });
+
+ it('returns null for non-existent keys', async () => {
+ const result = await storage.getItem('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(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(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(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);
+ });
+ });
+});
diff --git a/packages/core-storage/tsconfig.json b/packages/core-storage/tsconfig.json
new file mode 100644
index 0000000..65866cf
--- /dev/null
+++ b/packages/core-storage/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "@repo/typescript-config/library.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "strict": true,
+ "declaration": true,
+ "declarationMap": true
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 16d6eb4..4222c32 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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':