import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs'; import { join, dirname } from 'path'; import { DEFAULT_WIDGET_CONFIG, type WidgetConfig } from './widget.defaults'; import { WidgetKeysService } from './widget-keys.service'; import { ThemeService } from './theme.service'; const CONFIG_PATH = join(process.cwd(), 'data', 'widget.json'); const BACKUP_DIR = join(process.cwd(), 'data', 'widget-backups'); // File-backed store for admin-editable widget configuration. Mirrors ThemeService: // - onModuleInit() → load from disk → ensure key exists (generate + persist) // - getConfig() → in-memory cached lookup // - updateConfig() → merge patch + backup + write + bump version // - rotateKey() → revoke old siteId in Redis + generate new + persist // // Also guarantees the key stays valid across Redis flushes: if the file has a // key but Redis doesn't know about its siteId, we silently re-register it on // boot so POST /api/widget/* requests keep authenticating. @Injectable() export class WidgetConfigService implements OnModuleInit { private readonly logger = new Logger(WidgetConfigService.name); private cached: WidgetConfig | null = null; constructor( private readonly widgetKeys: WidgetKeysService, private readonly theme: ThemeService, ) {} // Hospital name comes from the theme — single source of truth. The widget // key's Redis label is just bookkeeping; pulling it from theme means // renaming the hospital via /branding-settings flows through to the next // key rotation automatically. private get hospitalName(): string { return this.theme.getTheme().brand.hospitalName; } async onModuleInit() { await this.ensureReady(); } getConfig(): WidgetConfig { if (this.cached) return this.cached; return this.load(); } // Public-facing subset served from GET /api/config/widget. Only the fields // the embed bootstrap code needs — no origins, no hospital label, no // version metadata. getPublicConfig() { const c = this.getConfig(); return { enabled: c.enabled, key: c.key, url: c.url, embed: c.embed, }; } async updateConfig(updates: Partial): Promise { const current = this.getConfig(); const merged: WidgetConfig = { ...current, ...updates, embed: { ...current.embed, ...updates.embed }, allowedOrigins: updates.allowedOrigins ?? current.allowedOrigins, version: (current.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; this.backup(); this.writeFile(merged); this.cached = merged; this.logger.log(`Widget config updated to v${merged.version}`); return merged; } // Revoke the current siteId in Redis, mint a new key with the current // theme.brand.hospitalName + allowedOrigins, persist both the Redis entry // and the config file. Used by admins to invalidate a leaked or stale key. async rotateKey(): Promise { const current = this.getConfig(); if (current.siteId) { await this.widgetKeys.revokeKey(current.siteId).catch(err => { this.logger.warn(`Revoke of old siteId ${current.siteId} failed: ${err}`); }); } const { key, siteKey } = this.widgetKeys.generateKey( this.hospitalName, current.allowedOrigins, ); await this.widgetKeys.saveKey(siteKey); const updated: WidgetConfig = { ...current, key, siteId: siteKey.siteId, version: (current.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; this.backup(); this.writeFile(updated); this.cached = updated; this.logger.log(`Widget key rotated: new siteId=${siteKey.siteId}`); return updated; } async resetConfig(): Promise { this.backup(); this.writeFile(DEFAULT_WIDGET_CONFIG); this.cached = { ...DEFAULT_WIDGET_CONFIG }; this.logger.log('Widget config reset to defaults'); return this.ensureReady(); } private async ensureReady(): Promise { let cfg = this.load(); // First boot or missing key → generate + persist. const needsKey = !cfg.key || !cfg.siteId; if (needsKey) { this.logger.log('No widget key in config — generating a fresh one'); const { key, siteKey } = this.widgetKeys.generateKey( this.hospitalName, cfg.allowedOrigins, ); await this.widgetKeys.saveKey(siteKey); cfg = { ...cfg, key, siteId: siteKey.siteId, // Allow WIDGET_PUBLIC_URL env var to seed the url field on // first boot, so dev/staging don't start with a blank URL. url: cfg.url || process.env.WIDGET_PUBLIC_URL || '', version: (cfg.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; this.writeFile(cfg); this.cached = cfg; this.logger.log(`Widget key generated: siteId=${siteKey.siteId}`); return cfg; } // Key exists on disk but may be missing from Redis (e.g., Redis // flushed or sidecar migrated to new Redis). Re-register so requests // validate correctly. This is silent — callers don't care. const validated = await this.widgetKeys.validateKey(cfg.key).catch(() => null); if (!validated) { this.logger.warn( `Widget key in config not found in Redis — re-registering siteId=${cfg.siteId}`, ); await this.widgetKeys.saveKey({ siteId: cfg.siteId, hospitalName: this.hospitalName, allowedOrigins: cfg.allowedOrigins, active: true, createdAt: cfg.updatedAt ?? new Date().toISOString(), }); } return cfg; } private load(): WidgetConfig { try { if (existsSync(CONFIG_PATH)) { const raw = readFileSync(CONFIG_PATH, 'utf8'); const parsed = JSON.parse(raw); const merged: WidgetConfig = { ...DEFAULT_WIDGET_CONFIG, ...parsed, embed: { ...DEFAULT_WIDGET_CONFIG.embed, ...parsed.embed }, allowedOrigins: parsed.allowedOrigins ?? DEFAULT_WIDGET_CONFIG.allowedOrigins, }; this.cached = merged; this.logger.log('Widget config loaded from file'); return merged; } } catch (err) { this.logger.warn(`Failed to load widget config: ${err}`); } const fallback: WidgetConfig = { ...DEFAULT_WIDGET_CONFIG }; this.cached = fallback; this.logger.log('Using default widget config'); return fallback; } private writeFile(cfg: WidgetConfig) { const dir = dirname(CONFIG_PATH); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8'); } private backup() { try { if (!existsSync(CONFIG_PATH)) return; if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true }); const ts = new Date().toISOString().replace(/[:.]/g, '-'); copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `widget-${ts}.json`)); } catch (err) { this.logger.warn(`Widget config backup failed: ${err}`); } } }