import { updateHasherSettings } from './utils/hashids'; export interface SecuritySettings { Authority: string; Audience: string; ClientId: string; ClientSecret?: string; Scope?: string; } export interface ApiSettings { BaseUrl: string; } export interface HasherSettings { Salt?: string; MinHashLength: number; } export type ConfigSource = | 'runtime-injected' | 'runtime-fetched' | 'build-env' | 'fallback-defaults'; export interface AppConfig { SecuritySettings: SecuritySettings; ApiSettings: ApiSettings; HasherSettings: HasherSettings; ConfigSource: ConfigSource; LoadedAt?: string; } // Fallback presets (used only when no runtime or build-time variable is provided) export const defaultSecuritySettings: SecuritySettings = { Authority: 'https://sts.security.khongisa.co.za', Audience: 'pfm-api-dev', ClientId: 'pfm-console-dev', Scope: 'openid profile roles email address pfm-api-dev', }; export const defaultApiSettings: ApiSettings = { BaseUrl: 'https://api.ifri.khongisa.co.za/', }; export const defaultHasherSettings: HasherSettings = { Salt: '', MinHashLength: 11, }; function syncAllHasherSettings(salt: string | undefined, minHashLength: number): void { updateHasherSettings(salt, minHashLength); } /** * Extracts first non-empty, trimmed string from candidate values. */ function pickFirstNonEmpty(...candidates: any[]): string | undefined { for (const c of candidates) { if (c !== undefined && c !== null) { const s = String(c).trim(); if (s !== '' && s !== 'undefined' && s !== 'null') { return s; } } } return undefined; } /** * Normalizes an API base URL ensuring HTTPS protocol by default and a single trailing slash. */ function normalizeBaseUrl(url: string): string { let trimmed = url.trim(); // Ensure HTTPS is default protocol if protocol is omitted if (!/^https?:\/\//i.test(trimmed)) { trimmed = `https://${trimmed}`; } else if (trimmed.startsWith('http://') && !trimmed.includes('localhost') && !trimmed.includes('127.0.0.1')) { // Upgrade remote HTTP URLs to HTTPS by default trimmed = trimmed.replace(/^http:\/\//i, 'https://'); } return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; } /** * Normalizes an OIDC/STS Authority URL ensuring HTTPS protocol by default. */ function normalizeAuthorityUrl(url: string): string { let trimmed = url.trim(); if (!/^https?:\/\//i.test(trimmed)) { trimmed = `https://${trimmed}`; } else if (trimmed.startsWith('http://') && !trimmed.includes('localhost') && !trimmed.includes('127.0.0.1')) { trimmed = trimmed.replace(/^http:\/\//i, 'https://'); } return trimmed.replace(/\/+$/, ''); } /** * Reads any runtime config object injected into window. * Supports: window.__RUNTIME_CONFIG__, window.APP_CONFIG, window._env_ */ function getWindowRuntimeConfig(): Record { if (typeof window === 'undefined') return {}; const win = window as any; return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {}; } /** * Parses and maps configuration from a given source object. * Checks flat keys, nested keys, and ASP.NET double-underscore conventions. */ function extractSourceConfig(src: Record): { authority?: string; audience?: string; clientId?: string; clientSecret?: string; scope?: string; baseUrl?: string; hasherSalt?: string; hasherMinLength?: number; } { if (!src || typeof src !== 'object') return {}; const authority = pickFirstNonEmpty( src.VITE_OIDC_AUTHORITY, src.OIDC_AUTHORITY, src.SecuritySettings?.Authority, src.SecuritySettings__Authority, src.SecuritySettings_Authority, src.AUTHORITY, src.STS_AUTHORITY ); const audience = pickFirstNonEmpty( src.VITE_OIDC_AUDIENCE, src.OIDC_AUDIENCE, src.SecuritySettings?.Audience, src.SecuritySettings__Audience, src.SecuritySettings_Audience, src.AUDIENCE ); const clientId = pickFirstNonEmpty( src.VITE_OIDC_CLIENT_ID, src.OIDC_CLIENT_ID, src.SecuritySettings?.ClientId, src.SecuritySettings__ClientId, src.SecuritySettings_ClientId, src.CLIENT_ID ); const clientSecret = pickFirstNonEmpty( src.VITE_OIDC_CLIENT_SECRET, src.OIDC_CLIENT_SECRET, src.SecuritySettings?.ClientSecret, src.SecuritySettings__ClientSecret, src.SecuritySettings_ClientSecret, src.CLIENT_SECRET ); const scope = pickFirstNonEmpty( src.VITE_OIDC_SCOPE, src.OIDC_SCOPE, src.SecuritySettings?.Scope, src.SecuritySettings__Scope, src.SecuritySettings_Scope, src.SCOPE ); const baseUrl = pickFirstNonEmpty( src.VITE_API_BASE_URL, src.API_BASE_URL, src.ApiSettings?.BaseUrl, src.ApiSettings__BaseUrl, src.ApiSettings_BaseUrl, src.PFM_API_BASE_URL ); const hasherSalt = pickFirstNonEmpty( src.VITE_HASHER_SALT, src.HASHER_SALT, src.HasherSettings?.Salt, src.HasherSettings__Salt, src.HasherSettings_Salt ); const rawMinLength = pickFirstNonEmpty( src.VITE_HASHER_MIN_LENGTH, src.HASHER_MIN_LENGTH, src.HasherSettings?.MinHashLength, src.HasherSettings__MinLength, src.HasherSettings_MinLength ); const hasherMinLength = rawMinLength ? Number(rawMinLength) : undefined; return { authority, audience, clientId, clientSecret, scope, baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined, hasherSalt, hasherMinLength: hasherMinLength && !isNaN(hasherMinLength) && hasherMinLength > 0 ? hasherMinLength : undefined, }; } /** * Builds the prioritized configuration by evaluating: * Tier 1: Injected Runtime Config (window.__RUNTIME_CONFIG__ from docker-entrypoint.sh) * Tier 2: Fetched Runtime Config (/config.json from server) * Tier 3: Vite Build-Time Variables (import.meta.env.VITE_*) * Tier 4: Fallback Defaults */ function resolveConfig(fetchedJson?: Record): AppConfig { const windowConfig = extractSourceConfig(getWindowRuntimeConfig()); const fetchedConfig = fetchedJson ? extractSourceConfig(fetchedJson) : {}; const buildEnvConfig = extractSourceConfig( (typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record ); // Check if any runtime deployment override was provided const hasInjected = Object.values(windowConfig).some((v) => v !== undefined); const hasFetched = Object.values(fetchedConfig).some((v) => v !== undefined); const hasBuildEnv = Object.values(buildEnvConfig).some((v) => v !== undefined); let source: ConfigSource = 'fallback-defaults'; if (hasInjected) { source = 'runtime-injected'; } else if (hasFetched) { source = 'runtime-fetched'; } else if (hasBuildEnv) { source = 'build-env'; } const authority = pickFirstNonEmpty( windowConfig.authority, fetchedConfig.authority, buildEnvConfig.authority, defaultSecuritySettings.Authority )!; const audience = pickFirstNonEmpty( windowConfig.audience, fetchedConfig.audience, buildEnvConfig.audience, defaultSecuritySettings.Audience )!; const clientId = pickFirstNonEmpty( windowConfig.clientId, fetchedConfig.clientId, buildEnvConfig.clientId, defaultSecuritySettings.ClientId )!; const clientSecret = pickFirstNonEmpty( windowConfig.clientSecret, fetchedConfig.clientSecret, buildEnvConfig.clientSecret ); const scope = pickFirstNonEmpty( windowConfig.scope, fetchedConfig.scope, buildEnvConfig.scope, defaultSecuritySettings.Scope ); const baseUrl = pickFirstNonEmpty( windowConfig.baseUrl, fetchedConfig.baseUrl, buildEnvConfig.baseUrl, defaultApiSettings.BaseUrl )!; const salt = pickFirstNonEmpty( windowConfig.hasherSalt, fetchedConfig.hasherSalt, buildEnvConfig.hasherSalt, defaultHasherSettings.Salt ); const minHashLength = windowConfig.hasherMinLength || fetchedConfig.hasherMinLength || buildEnvConfig.hasherMinLength || defaultHasherSettings.MinHashLength; return { SecuritySettings: { Authority: normalizeAuthorityUrl(authority), Audience: audience, ClientId: clientId, ...(clientSecret ? { ClientSecret: clientSecret } : {}), Scope: scope, }, ApiSettings: { BaseUrl: normalizeBaseUrl(baseUrl), }, HasherSettings: { Salt: salt || '', MinHashLength: minHashLength, }, ConfigSource: source, LoadedAt: new Date().toISOString(), }; } // Initial synchronous computation from window.__RUNTIME_CONFIG__ and build-time env let runtimeConfig: AppConfig = resolveConfig(); // Immediately apply deterministic hasher settings syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); const configListeners: Array<(config: AppConfig) => void> = []; export function onConfigChanged(listener: (config: AppConfig) => void): () => void { configListeners.push(listener); return () => { const idx = configListeners.indexOf(listener); if (idx !== -1) configListeners.splice(idx, 1); }; } let loadPromise: Promise | null = null; /** * Asynchronously loads and verifies runtime configuration. * Fetches /config.json with cache-busting to ensure latest deployment variables * are prioritized over static build artifacts. */ export async function loadRuntimeConfig(): Promise { if (loadPromise) return loadPromise; loadPromise = (async () => { let fetchedJson: Record | undefined; if (typeof window !== 'undefined') { try { // Fetch /config.json with cache-busting to bypass any intermediary caching const res = await fetch(`/config.json?_t=${Date.now()}`, { cache: 'no-store', headers: { Accept: 'application/json', }, }); if (res.ok) { const data = await res.json(); if (data && typeof data === 'object') { fetchedJson = data; } } } catch (err) { // Fetching /config.json is non-blocking; window.__RUNTIME_CONFIG__ or env fallback will be used console.debug('[Config] Note: Remote /config.json fetch bypassed:', err); } } runtimeConfig = resolveConfig(fetchedJson); // Synchronize deterministic hasher settings syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); console.info( `[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}` ); // Notify registered listeners configListeners.forEach((fn) => { try { fn(runtimeConfig); } catch (e) { console.error('[Config] Listener error:', e); } }); return runtimeConfig; })(); return loadPromise; } export function getAppConfig(): AppConfig { return runtimeConfig; } export function getSecuritySettings(): SecuritySettings { return runtimeConfig.SecuritySettings; } export function getApiBaseUrl(): string { return runtimeConfig.ApiSettings.BaseUrl; } export function getHasherSettings(): HasherSettings { return runtimeConfig.HasherSettings; } export function getConfigSource(): ConfigSource { return runtimeConfig.ConfigSource; }