403 lines
11 KiB
TypeScript
403 lines
11 KiB
TypeScript
import { updateHasherSettings } from './services/toolsService';
|
|
|
|
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',
|
|
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
|
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: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
|
MinHashLength: 11,
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
defaultSecuritySettings.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,
|
|
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
|
|
updateHasherSettings(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
|
|
updateHasherSettings(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;
|
|
}
|