# StellaOps Offline-First Implementation Guide ## Overview StellaOps supports offline operation for air-gapped environments. This document describes the implementation strategy for offline mode detection, data caching, and graceful degradation. ## Architecture ### Offline Mode States ``` ┌─────────────────────────────────────────────────────────────────┐ │ Offline Mode State Machine │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────┐ /health fails ┌──────────────┐ │ │ │ ONLINE │ ─────────────────> │ DEGRADED │ │ │ └─────────┘ 3 retries └──────────────┘ │ │ ↑ │ │ │ │ /health succeeds │ 30s timeout │ │ │ ↓ │ │ │ ┌──────────────┐ │ │ └───────────────────────── │ OFFLINE │ │ │ manual reconnect └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### Mode Behaviors | State | Read Operations | Write Operations | UI Indicators | |-------|-----------------|------------------|---------------| | **Online** | Live data | Enabled | None | | **Degraded** | Live + cache fallback | Enabled with queue | Yellow banner | | **Offline** | Cache only | Disabled | Red banner | ## Service Worker Strategy ### Caching Strategy ```typescript // sw-config.ts const cacheStrategies = { // Static assets: Cache-first static: { pattern: /\.(js|css|woff2?|png|svg)$/, strategy: 'cache-first', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }, // API contracts: Stale-while-revalidate apiContracts: { pattern: /\/api\/v1\/openapi/, strategy: 'stale-while-revalidate', maxAge: 24 * 60 * 60 * 1000, // 24 hours }, // Dynamic data: Network-first with cache fallback data: { pattern: /\/api\/v1\/(findings|artifacts|policies)/, strategy: 'network-first', maxAge: 60 * 60 * 1000, // 1 hour fallbackCache: true, }, // Mutations: Network-only with queue mutations: { pattern: /\/api\/v1\/.+\/(create|update|delete)/, strategy: 'network-only', queueOffline: true, }, }; ``` ### Service Worker Registration ```typescript // main.ts if ('serviceWorker' in navigator && environment.production) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then(registration => { console.log('SW registered:', registration); // Check for updates hourly setInterval(() => registration.update(), 60 * 60 * 1000); }) .catch(error => { console.error('SW registration failed:', error); }); }); } ``` ## IndexedDB Schema ### Database Structure ```typescript // db-schema.ts const DB_NAME = 'stellaops-offline'; const DB_VERSION = 1; interface OfflineDatabase { // Cached API responses apiCache: { key: string; // API endpoint + params hash value: { data: any; timestamp: number; etag?: string; }; }; // Pending mutations mutationQueue: { id: string; // UUID endpoint: string; method: 'POST' | 'PUT' | 'DELETE'; body: any; timestamp: number; retries: number; }; // Offline bundle assets bundleAssets: { hash: string; // Content hash type: 'jwks' | 'advisory' | 'vex' | 'manifest'; data: ArrayBuffer; importedAt: number; }; // User preferences preferences: { key: string; value: any; }; } ``` ### IndexedDB Operations ```typescript // offline-db.service.ts @Injectable({ providedIn: 'root' }) export class OfflineDbService { private db: IDBDatabase | null = null; async initialize(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(); }; request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result; // API cache store const apiCache = db.createObjectStore('apiCache', { keyPath: 'key' }); apiCache.createIndex('timestamp', 'timestamp'); // Mutation queue store const mutations = db.createObjectStore('mutationQueue', { keyPath: 'id' }); mutations.createIndex('timestamp', 'timestamp'); // Bundle assets store const bundles = db.createObjectStore('bundleAssets', { keyPath: 'hash' }); bundles.createIndex('type', 'type'); // Preferences store db.createObjectStore('preferences', { keyPath: 'key' }); }; }); } async getCached(key: string): Promise { return this.get<{ data: T }>('apiCache', key).then(r => r?.data ?? null); } async setCached(key: string, data: T, etag?: string): Promise { await this.put('apiCache', { key, value: { data, timestamp: Date.now(), etag }, }); } async queueMutation(mutation: Omit): Promise { const id = crypto.randomUUID(); await this.put('mutationQueue', { ...mutation, id, timestamp: Date.now(), retries: 0, }); return id; } } ``` ## Offline Mode Service ### Mode Detection ```typescript // offline-mode.service.ts @Injectable({ providedIn: 'root' }) export class OfflineModeService { private readonly healthUrl = '/health'; private readonly maxRetries = 3; private readonly retryDelay = 1000; readonly mode$ = new BehaviorSubject<'online' | 'degraded' | 'offline'>('online'); readonly bundleInfo$ = new BehaviorSubject(null); constructor(private http: HttpClient) { this.startHealthPolling(); window.addEventListener('online', () => this.checkHealth()); window.addEventListener('offline', () => this.mode$.next('offline')); } private async checkHealth(): Promise { for (let i = 0; i < this.maxRetries; i++) { try { await firstValueFrom( this.http.get(this.healthUrl, { responseType: 'text' }) .pipe(timeout(3000)) ); this.mode$.next('online'); return true; } catch { await this.delay(this.retryDelay * Math.pow(2, i)); } } const currentMode = this.mode$.value; this.mode$.next(currentMode === 'online' ? 'degraded' : 'offline'); return false; } private startHealthPolling(): void { interval(30000).pipe( startWith(0), switchMap(() => this.checkHealth()) ).subscribe(); } async importBundle(file: File): Promise { const manifest = await this.extractManifest(file); await this.validateSignature(manifest); await this.storeAssets(file, manifest); this.bundleInfo$.next({ version: manifest.version, createdAt: new Date(manifest.created_at), expiresAt: new Date(manifest.expires_at), }); } } ``` ## Graceful Degradation ### Read-Only Guard ```typescript // read-only.guard.ts @Injectable({ providedIn: 'root' }) export class ReadOnlyGuard implements CanActivate { constructor( private offlineMode: OfflineModeService, private toast: ToastService ) {} canActivate(route: ActivatedRouteSnapshot): boolean { const requiresWrite = route.data['requiresWrite'] as boolean; const isOffline = this.offlineMode.mode$.value === 'offline'; if (requiresWrite && isOffline) { this.toast.warning('This action requires an online connection'); return false; } return true; } } ``` ### Offline Banner Component ```typescript // offline-banner.component.ts @Component({ selector: 'app-offline-banner', template: `
wifi_off Connection unstable. Some features may be limited. cloud_off Offline Mode - Data as of {{ bundleDate | date:'medium' }}
`, }) export class OfflineBannerComponent { mode$ = this.offlineMode.mode$; bundleDate = this.offlineMode.bundleInfo$.value?.createdAt; constructor(private offlineMode: OfflineModeService) {} } ``` ### Disabled Mutation Buttons ```typescript // mutation-button.directive.ts @Directive({ selector: '[appMutationButton]' }) export class MutationButtonDirective implements OnInit, OnDestroy { private sub!: Subscription; constructor( private el: ElementRef, private offlineMode: OfflineModeService ) {} ngOnInit(): void { this.sub = this.offlineMode.mode$.subscribe(mode => { const button = this.el.nativeElement; const isOffline = mode === 'offline'; button.disabled = isOffline; button.title = isOffline ? 'Requires online connection' : ''; button.classList.toggle('mutation-disabled', isOffline); }); } ngOnDestroy(): void { this.sub.unsubscribe(); } } ``` ## Bundle Freshness ### Freshness Indicators ```typescript // bundle-freshness.component.ts @Component({ selector: 'app-bundle-freshness', template: `
{{ icon }} {{ message }} Updated {{ age | relativeTime }}
`, }) export class BundleFreshnessComponent { @Input() bundleDate!: Date; get status(): 'fresh' | 'stale' | 'expired' { const ageMs = Date.now() - this.bundleDate.getTime(); const ageDays = ageMs / (24 * 60 * 60 * 1000); if (ageDays < 7) return 'fresh'; if (ageDays < 30) return 'stale'; return 'expired'; } get icon(): string { switch (this.status) { case 'fresh': return 'check_circle'; case 'stale': return 'warning'; case 'expired': return 'error'; } } get message(): string { switch (this.status) { case 'fresh': return 'Data is current'; case 'stale': return 'Data may be outdated'; case 'expired': return 'Data is seriously outdated'; } } } ``` ## Mutation Queue Sync ### Queue Processing ```typescript // mutation-sync.service.ts @Injectable({ providedIn: 'root' }) export class MutationSyncService { private processing = false; constructor( private db: OfflineDbService, private http: HttpClient, private offlineMode: OfflineModeService ) { // Process queue when coming online this.offlineMode.mode$.pipe( filter(mode => mode === 'online'), switchMap(() => this.processQueue()) ).subscribe(); } async processQueue(): Promise { if (this.processing) return; this.processing = true; try { const mutations = await this.db.getPendingMutations(); for (const mutation of mutations) { try { await firstValueFrom( this.http.request(mutation.method, mutation.endpoint, { body: mutation.body, }) ); await this.db.removeMutation(mutation.id); } catch (error) { await this.db.incrementRetries(mutation.id); if (mutation.retries >= 3) { await this.db.moveToDlq(mutation.id); } } } } finally { this.processing = false; } } } ``` ## Testing Offline Mode ### E2E Test Helpers ```typescript // offline.e2e-spec.ts describe('Offline Mode', () => { beforeEach(() => { // Intercept health check cy.intercept('/health', { forceNetworkError: true }); }); it('should show offline banner after health check fails', () => { cy.visit('/'); cy.get('.offline-banner').should('be.visible'); cy.get('.offline-banner').should('contain', 'Offline Mode'); }); it('should disable mutation buttons in offline mode', () => { cy.visit('/findings'); cy.get('[data-testid="create-vex-button"]').should('be.disabled'); }); it('should show cached data in offline mode', () => { // Pre-populate cache cy.window().then(win => { win.localStorage.setItem('apiCache', JSON.stringify({ '/api/v1/findings': mockFindings, })); }); cy.visit('/findings'); cy.get('[data-testid="findings-table"]').should('have.length.gt', 0); }); }); ``` ## Related Documentation - [SPRINT_026 - Offline Kit Integration](../../implplan/SPRINT_20251229_026_PLATFORM_offline_kit_integration.md) - [UI Architecture](./architecture.md) - [Component Library](./components.md)