Files
pfm/PostFundManagement.ConsoleUI/src/config.ts
T
2026-10-24 17:36:30 +02:00

405 lines
11 KiB
TypeScript

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<string, any> {
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<string, any>): {
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<string, any>): 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<string, any>
);
// 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<AppConfig> | 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<AppConfig> {
if (loadPromise) return loadPromise;
loadPromise = (async () => {
let fetchedJson: Record<string, any> | 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;
}