diff --git a/apps/landing/package.json b/apps/landing/package.json index 1d78f4c..2ca4eef 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -13,6 +13,7 @@ "dependencies": { "@repo/core-api": "workspace:*", "@repo/core-i18n": "workspace:*", + "@repo/core-storage": "workspace:*", "@repo/ui": "workspace:*", "@repo/utils": "workspace:*", "@tailwindcss/vite": "^4.1.18", diff --git a/apps/landing/src/core/storage/index.ts b/apps/landing/src/core/storage/index.ts new file mode 100644 index 0000000..0cae5eb --- /dev/null +++ b/apps/landing/src/core/storage/index.ts @@ -0,0 +1,15 @@ +import { createLocalStorage } from '@repo/core-storage'; + +export const AppStorageKey = { + LOCALE: 'app_locale', +} as const; + +export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; + +export const PLAIN_KEYS = new Set([ + AppStorageKey.LOCALE, +]); + +export const secureStorage = createLocalStorage({ + plainTextKeys: PLAIN_KEYS +}); diff --git a/apps/landing/src/main.tsx b/apps/landing/src/main.tsx index 2bfdd13..7bc17af 100644 --- a/apps/landing/src/main.tsx +++ b/apps/landing/src/main.tsx @@ -16,10 +16,16 @@ import './main.css'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { setupI18n } from '@repo/core-i18n'; +import { secureStorage, AppStorageKey } from './core/storage'; import App from './app'; async function bootstrap() { - await setupI18n(); + await setupI18n({ + storageAdapter: { + getLanguage: async () => await secureStorage.getItem(AppStorageKey.LOCALE), + setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng), + }, + }); createRoot(document.getElementById('app')!).render( diff --git a/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx index e1fa14f..7ed0ca5 100644 --- a/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx +++ b/apps/web/src/apps/showcase/events-demo/auth-sync/storage-sync.listener.tsx @@ -1,6 +1,6 @@ import { useAppEvent } from '@repo/core-events'; import type { ProfileUpdatedPayload } from '@repo/core-events'; -import { secureIndexedDB } from '@repo/core-storage'; +import { secureIndexedDB, AppStorageKey } from '../../../../core/storage'; // ─── Props ────────────────────────────────────────────────────── @@ -14,45 +14,16 @@ interface StorageSyncListenerProps { /** * Headless component that listens to `AUTH:PROFILE_UPDATED` events * and persists the profile data to IndexedDB via `@repo/core-storage`. - * - * This component renders nothing — it is purely a side-effect listener. - * Mount it anywhere in the React tree; it will auto-cleanup on unmount. - * - * **Architecture notes for production use:** - * - * 1. The `StorageKey` registry in `@repo/core-storage` should include - * a `USER_PROFILE` key (which it already does — see `storage.key.ts`). - * This means `secureIndexedDB.setItem('user_profile', payload)` will - * automatically encrypt the data at rest because `user_profile` is - * listed in `ENCRYPTED_KEYS`. - * - * 2. If you need to store additional event-driven data, extend `StorageKey`: - * ```ts - * // In packages/core-storage/src/storage.key.ts: - * export const StorageKey = { - * ...existing, - * LAST_PROFILE_SYNC: 'last_profile_sync', - * } as const; - * ``` - * - * 3. For bidirectional sync (storage → event), consider adding a - * `STORAGE:PROFILE_LOADED` event to `AppEventRegistry` that fires when - * the app reads the profile from IndexedDB on boot. - * - * 4. Error handling: In production, wrap the `setItem` call in a - * retry mechanism or queue failed writes to a dead-letter store. */ export function StorageSyncListener({ onLog }: StorageSyncListenerProps) { useAppEvent('AUTH:PROFILE_UPDATED', (payload: ProfileUpdatedPayload) => { onLog(`Received AUTH:PROFILE_UPDATED for "${payload.name}" (${payload.email})`); - // Persist to IndexedDB via @repo/core-storage. - // Uses StorageKey.USER_PROFILE ('user_profile') which is in ENCRYPTED_KEYS, - // so the data will be AES-encrypted at rest automatically. + // Persist to IndexedDB via local AppStorageKey. secureIndexedDB - .setItem('user_profile', payload) + .setItem(AppStorageKey.USER_PROFILE, payload) .then(() => { - onLog(`✅ Profile persisted to IndexedDB (key: "user_profile", encrypted: true)`); + onLog(`✅ Profile persisted to IndexedDB (key: "${AppStorageKey.USER_PROFILE}", encrypted: true)`); }) .catch((err: Error) => { onLog(`❌ IndexedDB write failed: ${err.message}`); diff --git a/apps/web/src/apps/showcase/example/features/i18n/presentation/I18nSample.tsx b/apps/web/src/apps/showcase/example/features/i18n/presentation/I18nSample.tsx index 1800b2c..5f566df 100644 --- a/apps/web/src/apps/showcase/example/features/i18n/presentation/I18nSample.tsx +++ b/apps/web/src/apps/showcase/example/features/i18n/presentation/I18nSample.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTranslation, changeLanguage, applyTenantOverrides, i18n } from '@repo/core-i18n'; -import { secureIndexedDB } from '@repo/core-storage'; +import { secureIndexedDB, AppStorageKey } from '../../../../../../core/storage'; // Decentralized locale imports import bookingId from '../locales/id/booking.json'; @@ -41,7 +41,7 @@ export default function I18nSample() { const [adminHeaderTitle, setAdminHeaderTitle] = useState('Daftar Pengeluaran'); const [dbPayloadStr, setDbPayloadStr] = useState('No data in DB'); - const MOCK_DB_KEY = 'mock_db_company_a'; + const MOCK_DB_KEY = AppStorageKey.MOCK_DB_COMPANY_A; const loadDbPayload = useCallback(async () => { try { diff --git a/apps/web/src/apps/showcase/example/features/storage/presentation/StorageSample.tsx b/apps/web/src/apps/showcase/example/features/storage/presentation/StorageSample.tsx index c399454..cb2d6b0 100644 --- a/apps/web/src/apps/showcase/example/features/storage/presentation/StorageSample.tsx +++ b/apps/web/src/apps/showcase/example/features/storage/presentation/StorageSample.tsx @@ -1,11 +1,11 @@ import { useState, useCallback } from 'react'; -import { secureStorage, secureIndexedDB, StorageKey } from '@repo/core-storage'; +import { secureStorage, secureIndexedDB, AppStorageKey } from '../../../../../../core/storage'; // ─── Demo Data ────────────────────────────────────────────────── interface DemoUser { - id: number; - user: string; + id: string; + name: string; role: string; } @@ -15,11 +15,16 @@ interface DemoDraft { content: string; } -const DEMO_USER: DemoUser = { id: 1, user: 'Firman', role: 'admin' }; +const DEMO_USER: DemoUser = { + id: 'u-123', + name: 'Firman Ramdhani', + 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 +const LS_KEY = AppStorageKey.USER_PROFILE; // Encrypted at rest (in ENCRYPTED_KEYS) +const IDB_KEY = AppStorageKey.OFFLINE_DRAFT; // Plain key for IndexedDB demo // ─── Shared Styles ────────────────────────────────────────────── diff --git a/apps/web/src/core/storage/index.ts b/apps/web/src/core/storage/index.ts new file mode 100644 index 0000000..0f5b737 --- /dev/null +++ b/apps/web/src/core/storage/index.ts @@ -0,0 +1,35 @@ +import { createLocalStorage, createIndexedDB } from '@repo/core-storage'; + +export const AppStorageKey = { + USER_PROFILE: 'user_profile', + LOCALE: 'app_locale', + ACCESS_TOKEN: 'access_token', + REFRESH_TOKEN: 'refresh_token', + MOCK_DB_COMPANY_A: 'mock_db_company_a', + OFFLINE_DRAFT: 'offline_draft', +} as const; + +export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; + +export const ENCRYPTED_KEYS = new Set([ + AppStorageKey.USER_PROFILE, + AppStorageKey.ACCESS_TOKEN, + AppStorageKey.REFRESH_TOKEN, +]); + +export const PLAIN_KEYS = new Set([ + AppStorageKey.LOCALE, + AppStorageKey.MOCK_DB_COMPANY_A, + AppStorageKey.OFFLINE_DRAFT, +]); + +export const secureStorage = createLocalStorage({ + encryptedKeys: ENCRYPTED_KEYS, + plainTextKeys: PLAIN_KEYS, +}); +export const secureIndexedDB = createIndexedDB({ + dbName: 'eigen_erp_db', + storeName: 'web_store', + encryptedKeys: ENCRYPTED_KEYS, + plainTextKeys: PLAIN_KEYS, +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 40f43fc..300ce25 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -16,12 +16,18 @@ import './main.css'; import { lazy, StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { setupI18n } from '@repo/core-i18n'; +import { secureStorage, AppStorageKey } from './core/storage'; const App = lazy(() => import('./apps')); async function bootstrap() { // Initialize i18next and load language from secureStorage - await setupI18n(); + await setupI18n({ + storageAdapter: { + getLanguage: async () => await secureStorage.getItem(AppStorageKey.LOCALE), + setLanguage: async (lng: string) => await secureStorage.setItem(AppStorageKey.LOCALE, lng), + }, + }); createRoot(document.getElementById('app')!).render( diff --git a/packages/core-i18n/README.md b/packages/core-i18n/README.md index e50f3fb..853098f 100644 --- a/packages/core-i18n/README.md +++ b/packages/core-i18n/README.md @@ -2,13 +2,13 @@ [← Back to Root](../../README.md) -A highly decoupled, type-safe internationalization engine for the Eigen Monorepo. +A highly decoupled, type-safe internationalization engine for the monorepo. It uses a **Hybrid Namespace Strategy**: -1. **Centralized Engine**: Setup, local persistence (`@repo/core-storage`), and global words (`common`). +1. **Centralized Engine**: Setup, local persistence orchestration, and global words (`common`). 2. **Decentralized Dictionaries**: Feature-specific translations (`booking`, `billing`) live inside the application modules and are lazy-loaded. -This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API and networking decisions entirely to the consuming applications. +This architecture strictly adheres to **Inversion of Control (IoC)**. The core engine handles local state and performance, but leaves API, networking, and storage implementation decisions entirely to the consuming applications. --- @@ -65,18 +65,29 @@ graph TD ## 1. App-Level Setup (Bootstrap) -Initialize the engine *before* your React application mounts to prevent UI flashing. +Initialize the engine *before* your React application mounts to prevent UI flashing. Provide an `I18nStorageAdapter` using Dependency Injection so the core engine can persist the user's language without being tightly coupled to a specific storage implementation. ```tsx // apps/web/src/main.tsx import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { setupI18n } from '@repo/core-i18n'; +import { secureStorage, AppStorageKey } from './core/storage'; import App from './app'; async function bootstrap() { - // Synchronously reads preferred language from storage & inits i18next - await setupI18n(); + // Synchronously reads preferred language from injected storage & inits i18next + await setupI18n({ + storageAdapter: { + getLanguage: async () => { + const stored = await secureStorage.getItem(AppStorageKey.LOCALE); + return typeof stored === 'string' ? stored : null; + }, + setLanguage: async (lng: string) => { + await secureStorage.setItem(AppStorageKey.LOCALE, lng); + }, + }, + }); createRoot(document.getElementById('app')!).render( , diff --git a/packages/core-i18n/package.json b/packages/core-i18n/package.json index a7a83cd..c9e1650 100644 --- a/packages/core-i18n/package.json +++ b/packages/core-i18n/package.json @@ -12,7 +12,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@repo/core-storage": "workspace:*", "@repo/utils": "workspace:*", "i18next": "^24.2.2", "react-i18next": "^15.4.0" diff --git a/packages/core-i18n/src/manager.ts b/packages/core-i18n/src/manager.ts index 08617ff..e881599 100644 --- a/packages/core-i18n/src/manager.ts +++ b/packages/core-i18n/src/manager.ts @@ -1,5 +1,5 @@ import i18n from 'i18next'; -import { secureStorage, StorageKey } from '@repo/core-storage'; +import { globalStorageAdapter } from './setup'; /** * Changes the active language, saves the preference locally, and optionally syncs with the backend. @@ -16,7 +16,9 @@ export async function changeLanguage( if (prevLng === newLng) return; // 1. Update local storage & i18next optimistically - await secureStorage.setItem(StorageKey.LOCALE, newLng); + if (globalStorageAdapter) { + await globalStorageAdapter.setLanguage(newLng); + } await i18n.changeLanguage(newLng); // 2. Trigger optional backend sync @@ -26,7 +28,9 @@ export async function changeLanguage( } catch (error) { console.error('[i18n] Backend sync failed, rolling back language', error); // Rollback on failure - await secureStorage.setItem(StorageKey.LOCALE, prevLng); + if (globalStorageAdapter) { + await globalStorageAdapter.setLanguage(prevLng); + } await i18n.changeLanguage(prevLng); throw error; // Rethrow so the caller can show an error toast } diff --git a/packages/core-i18n/src/setup.ts b/packages/core-i18n/src/setup.ts index b4c1064..cb77954 100644 --- a/packages/core-i18n/src/setup.ts +++ b/packages/core-i18n/src/setup.ts @@ -1,6 +1,5 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; -import { secureStorage, StorageKey } from '@repo/core-storage'; import commonEn from './locales/en/common.json'; import commonId from './locales/id/common.json'; @@ -14,18 +13,33 @@ export const resources = { id: { common: commonId.common }, } as const; +export interface I18nStorageAdapter { + getLanguage(): Promise; + setLanguage(lng: string): Promise; +} + +export interface I18nConfig { + storageAdapter?: I18nStorageAdapter; +} + +// Store the adapter module-wide so manager.ts can access it +export let globalStorageAdapter: I18nStorageAdapter | undefined; + /** * Bootstraps the central i18n engine. * - * This reads the preferred locale from secureStorage and initializes - * i18next synchronously before React renders. + * It accepts an optional storage adapter to read the initial language. */ -export async function setupI18n(): Promise { +export async function setupI18n(config: I18nConfig = {}): Promise { + globalStorageAdapter = config.storageAdapter; + let initialLng = DEFAULT_LANGUAGE; try { - const storedLng = await secureStorage.getItem(StorageKey.LOCALE); - if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) { - initialLng = storedLng; + if (globalStorageAdapter) { + const storedLng = await globalStorageAdapter.getLanguage(); + if (storedLng && SUPPORTED_LANGUAGES.includes(storedLng as SupportedLanguage)) { + initialLng = storedLng; + } } } catch (err) { console.warn('[i18n] Failed to read locale from storage', err); diff --git a/packages/core-storage/README.md b/packages/core-storage/README.md index 1165f83..437b7b2 100644 --- a/packages/core-storage/README.md +++ b/packages/core-storage/README.md @@ -4,7 +4,7 @@ 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`. +This package provides a unified, Factory-based interface for interacting with browser storage (`localStorage` and `IndexedDB`). It enforces strict type safety, **Runtime Validation**, App Autonomy (Inversion of Control), and automatically provides **AES encryption at rest** for sensitive payloads (like access tokens) using `@repo/utils`. --- @@ -12,13 +12,16 @@ This package provides a unified, Promise-based interface for interacting with br ```mermaid graph TD - subgraph Apps ["apps/* (Consumers)"] + subgraph Apps ["apps/* (App Autonomy)"] + REG[[AppStorageKey & App Registries]] UI[React Components / API Interceptors] + INST{{Storage Instances}} end - subgraph Core ["@repo/core-storage (Engine)"] + subgraph Core ["@repo/core-storage (Engine Factories)"] API[IStorageService API] - REG[[StorageKey & ENCRYPTED_KEYS Registry]] + FAC[createLocalStorage / createIndexedDB] + VAL{Runtime Gatekeeper} ENC{{AES Encryption Pipeline}} LOCAL[LocalStorage Adapter] IDB[IndexedDB Adapter] @@ -29,153 +32,137 @@ graph TD B_IDB[(IndexedDB)] end - UI -->|getItem / setItem| API - API --> REG - REG -.->|Sensitive Key?| ENC - ENC -.-> LOCAL & IDB - REG -.->|Plain-text Key| LOCAL & IDB + REG -.->|Injects Keys & Config| FAC + FAC --> INST + UI -->|getItem / setItem| INST + INST --> API + API --> VAL + + VAL -.->|Valid Key?| ENC + VAL -.->|Invalid Key!| ERR[Throws Security Exception] + + ENC -.->|Sensitive Key| LOCAL & IDB + VAL -.->|Plain-text Key| LOCAL & IDB LOCAL <--> B_LOCAL IDB <--> B_IDB - %% Styling Subgraphs (Backgrounds) + %% Styling Subgraphs style Apps fill:#e7f5ff,stroke:#74c0fc,stroke-width:2px,color:#1864ab style Core fill:#f8f9fa,stroke:#ced4da,stroke-width:2px,color:#495057 style Browser fill:#f1f3f5,stroke:#ced4da,stroke-width:2px,color:#495057 - %% Styling App Nodes (Blue) + %% Styling Nodes style UI fill:#339af0,stroke:#1864ab,color:#fff - - %% Styling Core Nodes (Purple Engine, Green Registry, Gold Encryption) + style REG fill:#1864ab,stroke:#1864ab,color:#fff + style INST fill:#339af0,stroke:#1864ab,color:#fff style API fill:#845ef7,stroke:#5f3dc4,color:#fff + style FAC fill:#845ef7,stroke:#5f3dc4,color:#fff + + %% Gatekeeper is GREEN (Security Checkpoint), Error is RED + style VAL fill:#20c997,stroke:#089981,color:#fff + style ERR fill:#fa5252,stroke:#c92a2a,color:#fff + style LOCAL fill:#845ef7,stroke:#5f3dc4,color:#fff style IDB fill:#845ef7,stroke:#5f3dc4,color:#fff - style REG fill:#20c997,stroke:#089981,color:#fff style ENC fill:#fab005,stroke:#e67700,color:#fff - - %% Styling Browser Nodes (Neutral Gray) style B_LOCAL fill:#868e96,stroke:#495057,color:#fff style B_IDB fill:#868e96,stroke:#495057,color:#fff ``` --- -## 🎯 Primary Goals & Separation of Concerns +## 🎯 Primary Goals & Architectural Principles -* **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. +* **App Autonomy (Inversion of Control)**: The core storage engine does not know about your application's keys. Consuming applications define their own keys, their own `encryptedKeys` sets, and their own `plainTextKeys` sets, injecting them into the factory upon instantiation. +* **Runtime Gatekeeper (Defensive Programming)**: The engine validates every `setItem`, `getItem`, and `removeItem` operation. If an app attempts to access a key that wasn't explicitly registered in `encryptedKeys` or `plainTextKeys`, the engine will immediately throw a Security Exception to prevent rogue data access/injection. * **Dual Backend Strategy**: - * `secureStorage` (localStorage): Ideal for small, synchronous-like data (tokens, user preferences, settings). - * `secureIndexedDB` (IndexedDB): Built for large, asynchronous data (offline drafts, cached API responses, large arrays/blobs) bypassing the 5MB localStorage 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. + * `createLocalStorage`: Ideal for small, synchronous-like data (tokens, user preferences, settings). + * `createIndexedDB`: Built for large, asynchronous data (offline drafts, cached API responses, large arrays/blobs) bypassing the 5MB localStorage limit. +* **Security by Default**: Developers don't need to manually encrypt/decrypt data. If a key is passed in the `encryptedKeys` configuration, the engine handles AES encryption transparently. * **Corrupt Data Resilience**: If parsing or decryption fails (e.g., tampered data or changed encryption keys), the corrupt entry is safely removed and returns `null`, preventing the app from crashing. --- -## 🚀 Usage Examples +## 🚀 App-Level Setup & Usage -Because this package is framework-agnostic, these instances can be imported anywhere: React components, Redux/Zustand stores, or Axios interceptors. +### 1. Define App Keys and Instantiate (Inversion of Control) -### 1. Secure Local Storage (Tokens, Profile) - -Use `secureStorage` for small payloads. If the key is in `ENCRYPTED_KEYS`, it will be encrypted at rest automatically. +In your consuming application (e.g., `apps/web/src/core/storage/index.ts`), define your keys and use the factories to create your instances. ```typescript -import { secureStorage, StorageKey } from '@repo/core-storage'; +// apps/web/src/core/storage/index.ts +import { createLocalStorage, createIndexedDB } from '@repo/core-storage'; + +// 1. Define Keys +export const AppStorageKey = { + USER_PROFILE: 'user_profile', + ACCESS_TOKEN: 'access_token', + LOCALE: 'app_locale', +} as const; + +export type AppStorageKeyValue = (typeof AppStorageKey)[keyof typeof AppStorageKey]; + +// 2. Classify Keys +export const ENCRYPTED_KEYS = new Set([ + AppStorageKey.USER_PROFILE, + AppStorageKey.ACCESS_TOKEN, +]); + +export const PLAIN_KEYS = new Set([ + AppStorageKey.LOCALE, +]); + +// 3. Instantiate Factories +export const secureStorage = createLocalStorage({ + encryptedKeys: ENCRYPTED_KEYS, + plainTextKeys: PLAIN_KEYS +}); + +export const secureIndexedDB = createIndexedDB({ + dbName: 'eigen_erp_db', + storeName: 'web_store', + encryptedKeys: ENCRYPTED_KEYS, + plainTextKeys: PLAIN_KEYS +}); +``` + +### 2. Usage in App Components + +Now, you can import your locally-created instances anywhere in your app. + +```typescript +import { secureStorage, AppStorageKey } from '@/core/storage'; import type { UserProfile } from '@/types'; // CREATE / UPDATE -// If StorageKey.USER_PROFILE is in ENCRYPTED_KEYS, this is AES-encrypted automatically. -await secureStorage.setItem(StorageKey.USER_PROFILE, { +// Since USER_PROFILE is in ENCRYPTED_KEYS, it is AES-encrypted automatically. +await secureStorage.setItem(AppStorageKey.USER_PROFILE, { id: 1, name: 'Firman', role: 'admin' }); // READ (Returns null if not found or if decryption fails) -const profile = await secureStorage.getItem(StorageKey.USER_PROFILE); +const profile = await secureStorage.getItem(AppStorageKey.USER_PROFILE); if (profile) { console.log('Welcome back,', profile.name); } // DELETE -await secureStorage.removeItem(StorageKey.USER_PROFILE); +await secureStorage.removeItem(AppStorageKey.USER_PROFILE); ``` -### 2. IndexedDB (Offline Data, Large Payloads) +### 3. The Runtime Gatekeeper -Use the pre-configured `secureIndexedDB` for large, asynchronous data. It uses the exact same `IStorageService` interface and encryption pipeline as local storage. +If you try to access an unregistered key, the engine protects the app by throwing an error at runtime: ```typescript -import { secureIndexedDB } from '@repo/core-storage'; - -interface DraftData { - id: string; - content: string; - lastModified: number; -} - -// Save a large draft offline (No 5MB limit) -await secureIndexedDB.setItem('offline_draft_123', { - id: '123', - content: 'Huge text content...', - lastModified: Date.now() -}); - -// Retrieve the draft -const draft = await secureIndexedDB.getItem('offline_draft_123'); +// Throws Error: "[Storage Engine] Security Exception: Key 'rogue_key' is not registered..." +await secureStorage.setItem('rogue_key' as any, 'hacked'); ``` --- -## 🔑 Adding New Keys (Current Registry Pattern) - -To maintain type safety and avoid collisions across the monorepo, **all** global storage keys must currently 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] -> **Migration Hazard**: 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 gracefully clear the key, which may effectively log them out or reset their local preference. - ---- - -## 🚧 Planned Updates: Inversion of Control (IoC) Refactor - -**Current Limitation:** Currently, the `StorageKey` and `ENCRYPTED_KEYS` registries live inside `@repo/core-storage`. This violates the strict **App Autonomy (IoC)** principle established in other core packages (like `@repo/core-events`), as consuming applications must modify the core package to register their app-specific keys. - -**Future Architecture Roadmap:** In a future major update, this package will be refactored into a pure Factory/Engine pattern to fully decouple it from application business logic. - -1. The core will export a generic `StorageEngine` class or `createStorage()` factory. -2. Apps (`apps/web`, `apps/landing`) will instantiate their own storage engines and define their own key registries and encryption rules autonomously. - -*Proposed Future API:* -```typescript -// apps/web/src/lib/storage.ts -import { StorageEngine } from '@repo/core-storage'; - -export const webStorage = new StorageEngine({ - prefix: 'web_erp_', - encryptedKeys: ['access_token', 'user_profile'], -}); -``` \ No newline at end of file +> **Migration Hazard**: If you move an existing key from `plainTextKeys` to `encryptedKeys` (or vice versa), 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 gracefully clear the key, which may effectively log them out or reset their local preference. \ No newline at end of file diff --git a/packages/core-storage/src/index.ts b/packages/core-storage/src/index.ts index 769079b..8543179 100644 --- a/packages/core-storage/src/index.ts +++ b/packages/core-storage/src/index.ts @@ -1,48 +1,8 @@ // ─── Interfaces ───────────────────────────────────────────────── export type { IStorageService } from './storage.interface'; - -// ─── Key Registry ─────────────────────────────────────────────── -export { StorageKey, ENCRYPTED_KEYS } from './storage.key'; -export type { StorageKeyValue } from './storage.key'; +export type { StorageOptions } from './local-storage.service'; +export type { IndexedDBConfig } from './indexed-db.service'; // ─── 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 { secureStorage, StorageKey } from '@repo/core-storage'; - * - * await secureStorage.setItem(StorageKey.ACCESS_TOKEN, 'eyJhbGci...'); - * const token = await secureStorage.getItem(StorageKey.ACCESS_TOKEN); - * ``` - */ -export const secureStorage = 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 `secureStorage`. - * - * @example - * ```ts - * import { secureIndexedDB } from '@repo/core-storage'; - * - * await secureIndexedDB.setItem('offline_draft', { content: '...' }); - * const draft = await secureIndexedDB.getItem('offline_draft'); - * ``` - */ -export const secureIndexedDB = new IndexedDBService({ dbName: 'app_db', storeName: 'kv_store' }); +export { LocalStorageService, createLocalStorage } from './local-storage.service'; +export { IndexedDBService, createIndexedDB } from './indexed-db.service'; diff --git a/packages/core-storage/src/indexed-db.service.ts b/packages/core-storage/src/indexed-db.service.ts index 7e0789c..4713c28 100644 --- a/packages/core-storage/src/indexed-db.service.ts +++ b/packages/core-storage/src/indexed-db.service.ts @@ -1,10 +1,10 @@ import { EncryptionUtils } from '@repo/utils'; import type { IStorageService } from './storage.interface'; -import { ENCRYPTED_KEYS } from './storage.key'; +import type { StorageOptions } from './local-storage.service'; // ─── Types ────────────────────────────────────────────────────── -interface IndexedDBConfig { +export interface IndexedDBConfig extends StorageOptions { /** Database name. @default 'app_db' */ dbName?: string; /** Object store name. @default 'kv_store' */ @@ -63,34 +63,23 @@ function withTransaction( /** * 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 { +export class IndexedDBService implements IStorageService { private readonly encryption: EncryptionUtils; private readonly dbName: string; private readonly storeName: string; private readonly version: number; + private readonly encryptedKeys: Set; + private readonly plainTextKeys: Set; private dbPromise: Promise | null = null; - constructor(config?: IndexedDBConfig, encryptionUtils?: EncryptionUtils) { + 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; + this.encryptedKeys = config?.encryptedKeys ?? new Set(); + this.plainTextKeys = config?.plainTextKeys ?? new Set(); } /** Lazy-open the database connection (cached). */ @@ -101,11 +90,18 @@ export class IndexedDBService implements IStorageService { return this.dbPromise; } - private shouldEncrypt(key: string): boolean { - return ENCRYPTED_KEYS.has(key); + private validateKey(key: TKey): void { + if (!this.encryptedKeys.has(key) && !this.plainTextKeys.has(key)) { + throw new Error(`[Storage Engine] Security Exception: Key '${key}' is not registered and cannot be accessed.`); + } } - async setItem(key: string, value: T): Promise { + private shouldEncrypt(key: TKey): boolean { + return this.encryptedKeys.has(key); + } + + async setItem(key: TKey, value: T): Promise { + this.validateKey(key); const db = await this.getDB(); const serialized = JSON.stringify(value); const payload = this.shouldEncrypt(key) @@ -113,18 +109,19 @@ export class IndexedDBService implements IStorageService { : serialized; await withTransaction(db, this.storeName, 'readwrite', (store) => - store.put(payload, key), + store.put(payload, key as string), ); } - async getItem(key: string): Promise { + async getItem(key: TKey): Promise { + this.validateKey(key); const db = await this.getDB(); const raw = await withTransaction( db, this.storeName, 'readonly', - (store) => store.get(key) as IDBRequest, + (store) => store.get(key as string) as IDBRequest, ); if (raw === undefined || raw === null) return null; @@ -143,10 +140,11 @@ export class IndexedDBService implements IStorageService { } } - async removeItem(key: string): Promise { + async removeItem(key: TKey): Promise { + this.validateKey(key); const db = await this.getDB(); await withTransaction(db, this.storeName, 'readwrite', (store) => - store.delete(key), + store.delete(key as string), ); } @@ -157,18 +155,25 @@ export class IndexedDBService implements IStorageService { ); } - async hasItem(key: string): Promise { + async hasItem(key: TKey): Promise { const value = await this.getItem(key); return value !== null; } - async keys(): Promise { + async keys(): Promise { const db = await this.getDB(); - return withTransaction( + const allKeys = await withTransaction( db, this.storeName, 'readonly', (store) => store.getAllKeys() as IDBRequest, ); + return allKeys as TKey[]; } } + +export function createIndexedDB( + config: IndexedDBConfig +): IStorageService { + return new IndexedDBService(config); +} diff --git a/packages/core-storage/src/local-storage.service.ts b/packages/core-storage/src/local-storage.service.ts index 7c319b4..70be514 100644 --- a/packages/core-storage/src/local-storage.service.ts +++ b/packages/core-storage/src/local-storage.service.ts @@ -1,54 +1,50 @@ import { EncryptionUtils } from '@repo/utils'; import type { IStorageService } from './storage.interface'; -import { ENCRYPTED_KEYS } from './storage.key'; + +export interface StorageOptions { + encryptedKeys?: Set; + plainTextKeys?: Set; +} /** * 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 { +export class LocalStorageService implements IStorageService { private readonly encryption: EncryptionUtils; + private readonly encryptedKeys: Set; + private readonly plainTextKeys: Set; - constructor(encryptionUtils?: EncryptionUtils) { + constructor(options?: StorageOptions, encryptionUtils?: EncryptionUtils) { this.encryption = encryptionUtils ?? EncryptionUtils.getInstance(); + this.encryptedKeys = options?.encryptedKeys ?? new Set(); + this.plainTextKeys = options?.plainTextKeys ?? new Set(); } - /** Check if a key should be encrypted. */ - private shouldEncrypt(key: string): boolean { - return ENCRYPTED_KEYS.has(key); + private validateKey(key: TKey): void { + if (!this.encryptedKeys.has(key) && !this.plainTextKeys.has(key)) { + throw new Error(`[Storage Engine] Security Exception: Key '${key}' is not registered and cannot be accessed.`); + } } - async setItem(key: string, value: T): Promise { + private shouldEncrypt(key: TKey): boolean { + return this.encryptedKeys.has(key); + } + + async setItem(key: TKey, value: T): Promise { + this.validateKey(key); const serialized = JSON.stringify(value); if (this.shouldEncrypt(key)) { const encrypted = this.encryption.encrypt(serialized); - localStorage.setItem(key, encrypted); + localStorage.setItem(key as string, encrypted); } else { - localStorage.setItem(key, serialized); + localStorage.setItem(key as string, serialized); } } - async getItem(key: string): Promise { - const raw = localStorage.getItem(key); + async getItem(key: TKey): Promise { + this.validateKey(key); + const raw = localStorage.getItem(key as string); if (raw === null) return null; try { @@ -60,29 +56,36 @@ export class LocalStorageService implements IStorageService { return JSON.parse(raw) as T; } catch { console.warn(`[core-storage] Failed to parse key "${key}". Removing corrupt entry.`); - localStorage.removeItem(key); + localStorage.removeItem(key as string); return null; } } - async removeItem(key: string): Promise { - localStorage.removeItem(key); + async removeItem(key: TKey): Promise { + this.validateKey(key); + localStorage.removeItem(key as string); } async clear(): Promise { localStorage.clear(); } - async hasItem(key: string): Promise { - return localStorage.getItem(key) !== null; + async hasItem(key: TKey): Promise { + return localStorage.getItem(key as string) !== null; } - async keys(): Promise { - const result: string[] = []; + async keys(): Promise { + const result: TKey[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (key !== null) result.push(key); + if (key !== null) result.push(key as TKey); } return result; } } + +export function createLocalStorage( + options?: StorageOptions +): IStorageService { + return new LocalStorageService(options); +} diff --git a/packages/core-storage/src/storage.interface.ts b/packages/core-storage/src/storage.interface.ts index 702623e..8eb0cd1 100644 --- a/packages/core-storage/src/storage.interface.ts +++ b/packages/core-storage/src/storage.interface.ts @@ -10,29 +10,29 @@ * const user = await storage.getItem(StorageKey.USER_PROFILE); * ``` */ -export interface IStorageService { +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; + setItem(key: TKey, 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; + getItem(key: TKey): Promise; /** Remove a single key from storage. */ - removeItem(key: string): Promise; + removeItem(key: TKey): Promise; /** Remove all keys managed by this storage instance. */ clear(): Promise; /** Check if a key exists in storage. */ - hasItem(key: string): Promise; + hasItem(key: TKey): Promise; /** Get all keys currently in storage. */ - keys(): Promise; + keys(): Promise; } diff --git a/packages/core-storage/src/storage.key.ts b/packages/core-storage/src/storage.key.ts deleted file mode 100644 index 7f85573..0000000 --- a/packages/core-storage/src/storage.key.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 index 0c2542f..d20676c 100644 --- a/packages/core-storage/src/storage.test.ts +++ b/packages/core-storage/src/storage.test.ts @@ -1,6 +1,5 @@ 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 ─────────────────────────── @@ -37,6 +36,27 @@ Object.defineProperty(globalThis, 'localStorage', { // ─── Test Data ────────────────────────────────────────────────── +const TestStorageKey = { + THEME: 'theme', + LOCALE: 'locale', + ACCESS_TOKEN: 'access_token', + REFRESH_TOKEN: 'refresh_token', + USER_PROFILE: 'user_profile', +} as const; + +type TestStorageKeyValue = (typeof TestStorageKey)[keyof typeof TestStorageKey]; + +const ENCRYPTED_KEYS = new Set([ + TestStorageKey.ACCESS_TOKEN, + TestStorageKey.REFRESH_TOKEN, + TestStorageKey.USER_PROFILE, +]); + +const PLAIN_KEYS = new Set([ + TestStorageKey.THEME, + TestStorageKey.LOCALE, +]); + interface TestUser { id: number; name: string; @@ -48,95 +68,108 @@ const testUser: TestUser = { id: 1, name: 'Firman', role: 'admin' }; // ─── Tests ────────────────────────────────────────────────────── describe('LocalStorageService', () => { - let storage: 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); + storage = new LocalStorageService( + { encryptedKeys: ENCRYPTED_KEYS, plainTextKeys: PLAIN_KEYS }, + mockEncryptionUtils as never + ); + }); + + describe('Runtime Validation', () => { + it('throws an error if the key is not in encryptedKeys or plainTextKeys', async () => { + // Cast a rogue key to bypass TS for the runtime check test + const rogueKey = 'unregistered_key' as TestStorageKeyValue; + await expect(storage.setItem(rogueKey, 'data')).rejects.toThrowError( + "[Storage Engine] Security Exception: Key 'unregistered_key' is not registered and cannot be accessed." + ); + }); }); // ── setItem / getItem ───────────────────────────────────────── describe('setItem / getItem', () => { it('stores and retrieves a plain object (non-encrypted key)', async () => { - await storage.setItem(StorageKey.THEME, 'dark'); + await storage.setItem(TestStorageKey.THEME, 'dark'); - const result = await storage.getItem(StorageKey.THEME); + const result = await storage.getItem(TestStorageKey.THEME); expect(result).toBe('dark'); }); it('stores plain JSON without encryption for non-sensitive keys', async () => { - await storage.setItem(StorageKey.LOCALE, 'en-US'); + await storage.setItem(TestStorageKey.LOCALE, 'en-US'); expect(mockEncrypt).not.toHaveBeenCalled(); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( - StorageKey.LOCALE, + TestStorageKey.LOCALE, '"en-US"', ); }); it('encrypts sensitive keys (ACCESS_TOKEN)', async () => { const token = 'eyJhbGciOiJIUzI1NiJ9.test'; - await storage.setItem(StorageKey.ACCESS_TOKEN, token); + await storage.setItem(TestStorageKey.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)}]`); + expect(store[TestStorageKey.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); + await storage.setItem(TestStorageKey.ACCESS_TOKEN, token); - const result = await storage.getItem(StorageKey.ACCESS_TOKEN); + const result = await storage.getItem(TestStorageKey.ACCESS_TOKEN); expect(mockDecrypt).toHaveBeenCalled(); expect(result).toBe(token); }); it('stores and retrieves complex objects with generics', async () => { - await storage.setItem(StorageKey.THEME, testUser); + await storage.setItem(TestStorageKey.THEME, testUser); - const result = await storage.getItem(StorageKey.THEME); + const result = await storage.getItem(TestStorageKey.THEME); expect(result).toEqual(testUser); }); it('stores complex objects encrypted for sensitive keys', async () => { - await storage.setItem(StorageKey.USER_PROFILE, testUser); + await storage.setItem(TestStorageKey.USER_PROFILE, testUser); expect(mockEncrypt).toHaveBeenCalledWith(JSON.stringify(testUser)); - const result = await storage.getItem(StorageKey.USER_PROFILE); + const result = await storage.getItem(TestStorageKey.USER_PROFILE); expect(result).toEqual(testUser); }); it('returns null for non-existent keys', async () => { - const result = await storage.getItem('nonexistent'); + const result = await storage.getItem('nonexistent' as TestStorageKeyValue); expect(result).toBeNull(); }); it('handles corrupt/invalid JSON gracefully', async () => { - store[StorageKey.THEME] = '{invalid json'; + store[TestStorageKey.THEME] = '{invalid json'; const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const result = await storage.getItem(StorageKey.THEME); + const result = await storage.getItem(TestStorageKey.THEME); expect(result).toBeNull(); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('Failed to parse key'), ); // Corrupt entry should be cleaned up - expect(store[StorageKey.THEME]).toBeUndefined(); + expect(store[TestStorageKey.THEME]).toBeUndefined(); warnSpy.mockRestore(); }); it('handles failed decryption gracefully', async () => { // Write raw garbage to an encrypted key - store[StorageKey.ACCESS_TOKEN] = 'not-encrypted-data'; + store[TestStorageKey.ACCESS_TOKEN] = 'not-encrypted-data'; mockDecrypt.mockReturnValueOnce(''); - const result = await storage.getItem(StorageKey.ACCESS_TOKEN); + const result = await storage.getItem(TestStorageKey.ACCESS_TOKEN); expect(result).toBeNull(); }); }); @@ -145,11 +178,11 @@ describe('LocalStorageService', () => { describe('removeItem', () => { it('removes a key from storage', async () => { - await storage.setItem(StorageKey.THEME, 'dark'); - await storage.removeItem(StorageKey.THEME); + await storage.setItem(TestStorageKey.THEME, 'dark'); + await storage.removeItem(TestStorageKey.THEME); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(StorageKey.THEME); - const result = await storage.getItem(StorageKey.THEME); + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(TestStorageKey.THEME); + const result = await storage.getItem(TestStorageKey.THEME); expect(result).toBeNull(); }); }); @@ -158,8 +191,8 @@ describe('LocalStorageService', () => { describe('clear', () => { it('clears all keys from storage', async () => { - await storage.setItem(StorageKey.THEME, 'dark'); - await storage.setItem(StorageKey.LOCALE, 'en'); + await storage.setItem(TestStorageKey.THEME, 'dark'); + await storage.setItem(TestStorageKey.LOCALE, 'en'); await storage.clear(); expect(mockLocalStorage.clear).toHaveBeenCalled(); @@ -171,12 +204,12 @@ describe('LocalStorageService', () => { describe('hasItem', () => { it('returns true for existing keys', async () => { - await storage.setItem(StorageKey.THEME, 'dark'); - expect(await storage.hasItem(StorageKey.THEME)).toBe(true); + await storage.setItem(TestStorageKey.THEME, 'dark'); + expect(await storage.hasItem(TestStorageKey.THEME)).toBe(true); }); it('returns false for non-existent keys', async () => { - expect(await storage.hasItem('ghost_key')).toBe(false); + expect(await storage.hasItem('ghost_key' as TestStorageKeyValue)).toBe(false); }); }); @@ -184,12 +217,12 @@ describe('LocalStorageService', () => { describe('keys', () => { it('returns all stored keys', async () => { - await storage.setItem(StorageKey.THEME, 'dark'); - await storage.setItem(StorageKey.LOCALE, 'en'); + await storage.setItem(TestStorageKey.THEME, 'dark'); + await storage.setItem(TestStorageKey.LOCALE, 'en'); const allKeys = await storage.keys(); - expect(allKeys).toContain(StorageKey.THEME); - expect(allKeys).toContain(StorageKey.LOCALE); + expect(allKeys).toContain(TestStorageKey.THEME); + expect(allKeys).toContain(TestStorageKey.LOCALE); expect(allKeys).toHaveLength(2); }); }); @@ -198,23 +231,23 @@ describe('LocalStorageService', () => { describe('encryption key classification', () => { it('ACCESS_TOKEN is in ENCRYPTED_KEYS', () => { - expect(ENCRYPTED_KEYS.has(StorageKey.ACCESS_TOKEN)).toBe(true); + expect(ENCRYPTED_KEYS.has(TestStorageKey.ACCESS_TOKEN)).toBe(true); }); it('REFRESH_TOKEN is in ENCRYPTED_KEYS', () => { - expect(ENCRYPTED_KEYS.has(StorageKey.REFRESH_TOKEN)).toBe(true); + expect(ENCRYPTED_KEYS.has(TestStorageKey.REFRESH_TOKEN)).toBe(true); }); it('USER_PROFILE is in ENCRYPTED_KEYS', () => { - expect(ENCRYPTED_KEYS.has(StorageKey.USER_PROFILE)).toBe(true); + expect(ENCRYPTED_KEYS.has(TestStorageKey.USER_PROFILE)).toBe(true); }); it('THEME is NOT in ENCRYPTED_KEYS', () => { - expect(ENCRYPTED_KEYS.has(StorageKey.THEME)).toBe(false); + expect(ENCRYPTED_KEYS.has(TestStorageKey.THEME)).toBe(false); }); it('LOCALE is NOT in ENCRYPTED_KEYS', () => { - expect(ENCRYPTED_KEYS.has(StorageKey.LOCALE)).toBe(false); + expect(ENCRYPTED_KEYS.has(TestStorageKey.LOCALE)).toBe(false); }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 87eb7f4..9a9b6a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ importers: '@repo/core-i18n': specifier: workspace:* version: link:../../packages/core-i18n + '@repo/core-storage': + specifier: workspace:* + version: link:../../packages/core-storage '@repo/ui': specifier: workspace:* version: link:../../packages/ui @@ -348,9 +351,6 @@ importers: packages/core-i18n: dependencies: - '@repo/core-storage': - specifier: workspace:* - version: link:../core-storage '@repo/utils': specifier: workspace:* version: link:../utils