mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: widget config via admin-editable data/widget.json
Mirrors the existing theme config pattern so website widget settings can be
edited from the admin portal instead of baked into frontend env vars. Fixes
the current symptom where the staging widget is silently disabled because
VITE_WIDGET_KEY is missing from .env.production.
Backend (sidecar):
- src/config/widget.defaults.ts — WidgetConfig type + defaults
(enabled, key, siteId, url, allowedOrigins, hospitalName,
embed.loginPage, version, updatedAt)
- src/config/widget-config.service.ts — file-backed load / update /
rotate-key / reset with backups, mirroring ThemeService. On module init:
* first boot → auto-generates an HMAC-signed site key via
WidgetKeysService, persists both to data/widget.json and to Redis
* subsequent boots → re-registers the key in Redis if missing (handles
Redis flushes so validateKey() keeps working without admin action)
- src/config/widget-config.controller.ts — new endpoints under /api/config:
GET /api/config/widget public subset {enabled, key, url, embed}
GET /api/config/widget/admin full config for the settings UI
PUT /api/config/widget admin update (partial merge)
POST /api/config/widget/rotate-key revoke old siteId + mint a new key
POST /api/config/widget/reset reset to defaults + regenerate
- Move src/widget/widget-keys.service.ts → src/config/widget-keys.service.ts
(it's a config-layer concern now, not widget-layer). config-theme.module
becomes the owner, imports AuthModule for SessionService, and exports
WidgetKeysService + WidgetConfigService alongside ThemeService.
- widget.module stops providing WidgetKeysService (it imports ConfigThemeModule
already, so the guard + controller still get it via DI).
- .gitignore data/widget.json + data/widget-backups/ so each environment
auto-generates its own instance-specific key instead of sharing one via git.
TODO (flagged, out of scope for this pass):
- Protect admin endpoints with an auth guard when settings UI ships.
- Set WIDGET_SECRET env var in staging (currently falls back to the
hardcoded default in widget-keys.service.ts).
- Admin portal settings page for editing widget config (mirror branding-settings).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
50
src/config/widget-config.controller.ts
Normal file
50
src/config/widget-config.controller.ts
Normal file
@@ -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<WidgetConfig>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
191
src/config/widget-config.service.ts
Normal file
191
src/config/widget-config.service.ts
Normal file
@@ -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<WidgetConfig>): Promise<WidgetConfig> {
|
||||
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<WidgetConfig> {
|
||||
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<WidgetConfig> {
|
||||
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<WidgetConfig> {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:';
|
||||
|
||||
50
src/config/widget.defaults.ts
Normal file
50
src/config/widget.defaults.ts
Normal file
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user