import { updateHasherSettings as updateToolsHasherSettings } from './services/toolsService'; import { updateHasherSettings as updateUtilsHasherSettings } 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; } 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 { updateToolsHasherSettings(salt, minHashLength); updateUtilsHasherSettings(salt, minHashLength); } 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; } function normalizeBaseUrl(url: string): string { let trimmed = url.trim(); if (!/^https?:\/\//i.test(trimmed)) { trimmed = `https://${trimmed}`; } return trimmed.endsWith('/') ? trimmed : `${trimmed}/`; } function normalizeAuthorityUrl(url: string): string { let trimmed = url.trim(); if (!/^https?:\/\//i.test(trimmed)) { trimmed = `https://${trimmed}`; } return trimmed.replace(/\/+$/, ''); } function getWindowRuntimeConfig(): Record { if (typeof window === 'undefined') return {}; const win = window as any; return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {}; } 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 sec = src.SecuritySettings || src.securitySettings || {}; const api = src.ApiSettings || src.apiSettings || {}; const hasher = src.HasherSettings || src.hasherSettings || {}; const minLenRaw = pickFirstNonEmpty( src.VITE_HASHER_MIN_LENGTH, src.HASHER_MIN_LENGTH, src.HasherSettings__MinHashLength, hasher.MinHashLength, hasher.minHashLength, src.MinHashLength, src.minHashLength ); const parsedMinLen = minLenRaw !== undefined ? Number(minLenRaw) : undefined; return { authority: pickFirstNonEmpty( src.VITE_OIDC_AUTHORITY, src.OIDC_AUTHORITY, src.SecuritySettings__Authority, sec.Authority, sec.authority, src.Authority, src.authority ), audience: pickFirstNonEmpty( src.VITE_OIDC_AUDIENCE, src.OIDC_AUDIENCE, src.SecuritySettings__Audience, sec.Audience, sec.audience, src.Audience, src.audience ), clientId: pickFirstNonEmpty( src.VITE_OIDC_CLIENT_ID, src.OIDC_CLIENT_ID, src.SecuritySettings__ClientId, sec.ClientId, sec.clientId, src.ClientId, src.clientId ), clientSecret: pickFirstNonEmpty( src.VITE_OIDC_CLIENT_SECRET, src.OIDC_CLIENT_SECRET, src.SecuritySettings__ClientSecret, sec.ClientSecret, sec.clientSecret, src.ClientSecret, src.clientSecret ), scope: pickFirstNonEmpty( src.VITE_OIDC_SCOPE, src.OIDC_SCOPE, src.SecuritySettings__Scope, sec.Scope, sec.scope, src.Scope, src.scope ), baseUrl: pickFirstNonEmpty( src.VITE_API_BASE_URL, src.API_BASE_URL, src.ApiSettings__BaseUrl, api.BaseUrl, api.baseUrl, src.BaseUrl, src.baseUrl ), hasherSalt: pickFirstNonEmpty( src.VITE_HASHER_SALT, src.HASHER_SALT, src.HasherSettings__Salt, hasher.Salt, hasher.salt, src.Salt, src.salt ), hasherMinLength: parsedMinLen !== undefined && !isNaN(parsedMinLen) && parsedMinLen > 0 ? parsedMinLen : undefined, }; } function resolveConfig(fetchedJson?: Record): AppConfig { const winCfg = extractSourceConfig(getWindowRuntimeConfig()); const fetchCfg = fetchedJson ? extractSourceConfig(fetchedJson) : {}; const envCfg = extractSourceConfig( (typeof import.meta !== 'undefined' && (import.meta as any).env ? (import.meta as any).env : {}) as Record ); const hasWin = Object.values(winCfg).some((v) => v !== undefined); const hasFetch = Object.values(fetchCfg).some((v) => v !== undefined); const hasEnv = Object.values(envCfg).some((v) => v !== undefined); let source: ConfigSource = 'fallback-defaults'; if (hasWin) source = 'runtime-injected'; else if (hasFetch) source = 'runtime-fetched'; else if (hasEnv) source = 'build-env'; const authority = normalizeAuthorityUrl( pickFirstNonEmpty(winCfg.authority, fetchCfg.authority, envCfg.authority, defaultSecuritySettings.Authority)! ); const audience = pickFirstNonEmpty( winCfg.audience, fetchCfg.audience, envCfg.audience, defaultSecuritySettings.Audience )!; const clientId = pickFirstNonEmpty( winCfg.clientId, fetchCfg.clientId, envCfg.clientId, defaultSecuritySettings.ClientId )!; const clientSecret = pickFirstNonEmpty( winCfg.clientSecret, fetchCfg.clientSecret, envCfg.clientSecret ); const scope = pickFirstNonEmpty( winCfg.scope, fetchCfg.scope, envCfg.scope, defaultSecuritySettings.Scope ); const baseUrl = normalizeBaseUrl( pickFirstNonEmpty(winCfg.baseUrl, fetchCfg.baseUrl, envCfg.baseUrl, defaultApiSettings.BaseUrl)! ); const hasherSalt = pickFirstNonEmpty( winCfg.hasherSalt, fetchCfg.hasherSalt, envCfg.hasherSalt, defaultHasherSettings.Salt ); const hasherMinLength = winCfg.hasherMinLength || fetchCfg.hasherMinLength || envCfg.hasherMinLength || defaultHasherSettings.MinHashLength; return { SecuritySettings: { Authority: authority, Audience: audience, ClientId: clientId, ...(clientSecret ? { ClientSecret: clientSecret } : {}), Scope: scope, }, ApiSettings: { BaseUrl: baseUrl, }, HasherSettings: { Salt: hasherSalt || '', MinHashLength: hasherMinLength, }, ConfigSource: source, LoadedAt: new Date().toISOString(), }; } let runtimeConfig: AppConfig = resolveConfig(); 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; export async function loadRuntimeConfig(forceReload = false): Promise { if (loadPromise && !forceReload) { return loadPromise; } loadPromise = (async () => { let fetchedJson: Record | undefined; if (typeof window !== 'undefined') { try { const res = await fetch(`/config.json?_t=${Date.now()}`, { cache: 'no-store', headers: { Accept: 'application/json' }, }).catch(() => null); if (res && res.ok) { const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json') || contentType.includes('text/plain')) { fetchedJson = await res.json().catch(() => undefined); } else { const text = await res.text().catch(() => ''); if (text.trim().startsWith('{')) { fetchedJson = JSON.parse(text); } } } } catch { // Fallback if /config.json is unreachable } } runtimeConfig = resolveConfig(fetchedJson); syncAllHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength); configListeners.forEach((fn) => fn(runtimeConfig)); return runtimeConfig; })(); return loadPromise; } export const getAppConfig = (): AppConfig => runtimeConfig; export const getSecuritySettings = (): SecuritySettings => runtimeConfig.SecuritySettings; export const getApiBaseUrl = (): string => runtimeConfig.ApiSettings.BaseUrl; export const getHasherSettings = (): HasherSettings => runtimeConfig.HasherSettings;