diff --git a/.gitignore b/.gitignore index 428e9b3..1420bea 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,8 @@ lerna-debug.log* # Environment .env + +# Widget config — instance-specific, auto-generated on first boot. +# Each environment mints its own HMAC-signed site key. +data/widget.json +data/widget-backups/ diff --git a/src/config/config-theme.module.ts b/src/config/config-theme.module.ts index e299fa9..854c38d 100644 --- a/src/config/config-theme.module.ts +++ b/src/config/config-theme.module.ts @@ -1,10 +1,19 @@ import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; import { ThemeController } from './theme.controller'; import { ThemeService } from './theme.service'; +import { WidgetKeysService } from './widget-keys.service'; +import { WidgetConfigService } from './widget-config.service'; +import { WidgetConfigController } from './widget-config.controller'; +// Central config module — owns everything that lives in data/*.json and is +// editable from the admin portal. Theme today, widget config now, rules later. +// AuthModule is imported because WidgetKeysService depends on SessionService +// (Redis-backed cache for site key storage). @Module({ - controllers: [ThemeController], - providers: [ThemeService], - exports: [ThemeService], + imports: [AuthModule], + controllers: [ThemeController, WidgetConfigController], + providers: [ThemeService, WidgetKeysService, WidgetConfigService], + exports: [ThemeService, WidgetKeysService, WidgetConfigService], }) export class ConfigThemeModule {} diff --git a/src/config/widget-config.controller.ts b/src/config/widget-config.controller.ts new file mode 100644 index 0000000..ca4e8b3 --- /dev/null +++ b/src/config/widget-config.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common'; +import { WidgetConfigService } from './widget-config.service'; +import type { WidgetConfig } from './widget.defaults'; + +// Mounted under /api/config (same prefix as ThemeController). +// +// GET /api/config/widget — public subset, called by the embed +// page to decide whether & how to load +// widget.js +// GET /api/config/widget/admin — full config incl. origins + metadata +// PUT /api/config/widget — admin update (merge patch) +// POST /api/config/widget/rotate-key — rotate the HMAC site key +// POST /api/config/widget/reset — reset to defaults (regenerates key) +// +// TODO: protect the admin endpoints with the admin guard once the settings UI +// ships. Matches the current ThemeController convention (also currently open). +@Controller('api/config') +export class WidgetConfigController { + private readonly logger = new Logger(WidgetConfigController.name); + + constructor(private readonly widgetConfig: WidgetConfigService) {} + + @Get('widget') + getPublicWidget() { + return this.widgetConfig.getPublicConfig(); + } + + @Get('widget/admin') + getAdminWidget() { + return this.widgetConfig.getConfig(); + } + + @Put('widget') + async updateWidget(@Body() body: Partial) { + this.logger.log('Widget config update request'); + return this.widgetConfig.updateConfig(body); + } + + @Post('widget/rotate-key') + async rotateKey() { + this.logger.log('Widget key rotation request'); + return this.widgetConfig.rotateKey(); + } + + @Post('widget/reset') + async resetWidget() { + this.logger.log('Widget config reset request'); + return this.widgetConfig.resetConfig(); + } +} diff --git a/src/config/widget-config.service.ts b/src/config/widget-config.service.ts new file mode 100644 index 0000000..bf6c4d7 --- /dev/null +++ b/src/config/widget-config.service.ts @@ -0,0 +1,191 @@ +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'; + +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) {} + + 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 same + // 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( + current.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 hospitalName = cfg.hospitalName || DEFAULT_WIDGET_CONFIG.hospitalName; + const { key, siteKey } = this.widgetKeys.generateKey( + 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: cfg.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}`); + } + } +} diff --git a/src/widget/widget-keys.service.ts b/src/config/widget-keys.service.ts similarity index 98% rename from src/widget/widget-keys.service.ts rename to src/config/widget-keys.service.ts index eebbeac..f381f92 100644 --- a/src/widget/widget-keys.service.ts +++ b/src/config/widget-keys.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createHmac, timingSafeEqual, randomUUID } from 'crypto'; import { SessionService } from '../auth/session.service'; -import type { WidgetSiteKey } from './widget.types'; +import type { WidgetSiteKey } from '../widget/widget.types'; const KEY_PREFIX = 'widget:keys:'; diff --git a/src/config/widget.defaults.ts b/src/config/widget.defaults.ts new file mode 100644 index 0000000..6ef311a --- /dev/null +++ b/src/config/widget.defaults.ts @@ -0,0 +1,50 @@ +// Shape of the website-widget configuration, stored in data/widget.json. +// Mirrors the theme config pattern — file-backed, versioned, admin-editable. +export type WidgetConfig = { + // Master feature flag. When false, the widget does not render anywhere. + enabled: boolean; + + // HMAC-signed site key the embed script passes as data-key. Auto-generated + // on first boot if empty. Rotate via POST /api/config/widget/rotate-key. + key: string; + + // Stable site identifier derived from the key. Used for Redis lookup and + // revocation. Populated alongside `key`. + siteId: string; + + // Public base URL where widget.js is hosted. Typically the sidecar host. + // If empty, the embed page falls back to its own VITE_API_URL at fetch time. + url: string; + + // Origin allowlist. Empty array means any origin is accepted (test mode). + // Set tight values in production: ['https://hospital.com']. + allowedOrigins: string[]; + + // Display label attached to the site key in the CRM / admin listings. + hospitalName: string; + + // Embed toggles — where the widget should render. Kept as an object so we + // can add other surfaces (public landing page, portal, etc.) without a + // breaking schema change. + embed: { + // Show on the staff login page. Useful for testing without a public + // landing page; turn off in production. + loginPage: boolean; + }; + + // Bookkeeping — incremented on every update, like the theme config. + version?: number; + updatedAt?: string; +}; + +export const DEFAULT_WIDGET_CONFIG: WidgetConfig = { + enabled: true, + key: '', + siteId: '', + url: '', + allowedOrigins: [], + hospitalName: 'Global Hospital', + embed: { + loginPage: true, + }, +}; diff --git a/src/widget/widget-key.guard.ts b/src/widget/widget-key.guard.ts index ca37cde..66ddb5b 100644 --- a/src/widget/widget-key.guard.ts +++ b/src/widget/widget-key.guard.ts @@ -1,5 +1,5 @@ import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common'; -import { WidgetKeysService } from './widget-keys.service'; +import { WidgetKeysService } from '../config/widget-keys.service'; @Injectable() export class WidgetKeyGuard implements CanActivate { diff --git a/src/widget/widget.controller.ts b/src/widget/widget.controller.ts index 0d3b971..f8a7ef5 100644 --- a/src/widget/widget.controller.ts +++ b/src/widget/widget.controller.ts @@ -3,7 +3,7 @@ import type { Request, Response } from 'express'; import type { ModelMessage } from 'ai'; import { WidgetService } from './widget.service'; import { WidgetChatService } from './widget-chat.service'; -import { WidgetKeysService } from './widget-keys.service'; +import { WidgetKeysService } from '../config/widget-keys.service'; import { WidgetKeyGuard } from './widget-key.guard'; import { CaptchaGuard } from './captcha.guard'; import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types'; diff --git a/src/widget/widget.module.ts b/src/widget/widget.module.ts index 59e8c3b..bff91bf 100644 --- a/src/widget/widget.module.ts +++ b/src/widget/widget.module.ts @@ -3,15 +3,16 @@ import { WidgetController } from './widget.controller'; import { WebhooksController } from './webhooks.controller'; import { WidgetService } from './widget.service'; import { WidgetChatService } from './widget-chat.service'; -import { WidgetKeysService } from './widget-keys.service'; import { PlatformModule } from '../platform/platform.module'; import { AuthModule } from '../auth/auth.module'; import { ConfigThemeModule } from '../config/config-theme.module'; +// WidgetKeysService lives in ConfigThemeModule now — injected here via the +// module's exports. This module only owns the widget-facing API endpoints +// (init / chat / book / lead) plus the NestJS guards that consume the keys. @Module({ imports: [PlatformModule, AuthModule, ConfigThemeModule], controllers: [WidgetController, WebhooksController], - providers: [WidgetService, WidgetChatService, WidgetKeysService], - exports: [WidgetKeysService], + providers: [WidgetService, WidgetChatService], }) export class WidgetModule {}