|
| 1 | +import { readFileSync } from 'node:fs'; |
| 2 | + |
| 3 | +export type HostedAccountProvider = 'external' | 'manual' | 'fixture'; |
| 4 | + |
| 5 | +export interface HostedConfig { |
| 6 | + devFrontendUrl: string; |
| 7 | + devApiUrl: string; |
| 8 | + stableFrontendUrl: string; |
| 9 | + stableApiUrl: string; |
| 10 | + expectedDevChannel: string; |
| 11 | + expectedStableChannel: string; |
| 12 | + expectedDevCommit?: string; |
| 13 | + expectedStableCommit?: string; |
| 14 | + testAccountProvider: HostedAccountProvider; |
| 15 | + allowMutations: boolean; |
| 16 | + allowOAuth: boolean; |
| 17 | + timeoutMs: number; |
| 18 | + allowedApiHosts: string[]; |
| 19 | +} |
| 20 | + |
| 21 | +export interface HostedConfigInput { |
| 22 | + HOSTED_DEV_FRONTEND_URL?: string; |
| 23 | + HOSTED_DEV_API_URL?: string; |
| 24 | + HOSTED_STABLE_FRONTEND_URL?: string; |
| 25 | + HOSTED_STABLE_API_URL?: string; |
| 26 | + HOSTED_EXPECTED_DEV_CHANNEL?: string; |
| 27 | + HOSTED_EXPECTED_STABLE_CHANNEL?: string; |
| 28 | + HOSTED_EXPECTED_DEV_COMMIT?: string; |
| 29 | + HOSTED_EXPECTED_STABLE_COMMIT?: string; |
| 30 | + HOSTED_TEST_ACCOUNT_PROVIDER?: string; |
| 31 | + HOSTED_ALLOW_MUTATIONS?: string | boolean; |
| 32 | + HOSTED_ALLOW_OAUTH?: string | boolean; |
| 33 | + HOSTED_TIMEOUT_MS?: string | number; |
| 34 | + HOSTED_ALLOWED_API_HOSTS?: string | string[]; |
| 35 | +} |
| 36 | + |
| 37 | +export function parseHostedConfig(input: HostedConfigInput): HostedConfig { |
| 38 | + const config: HostedConfig = { |
| 39 | + devFrontendUrl: required(input.HOSTED_DEV_FRONTEND_URL, 'HOSTED_DEV_FRONTEND_URL'), |
| 40 | + devApiUrl: required(input.HOSTED_DEV_API_URL, 'HOSTED_DEV_API_URL'), |
| 41 | + stableFrontendUrl: required(input.HOSTED_STABLE_FRONTEND_URL, 'HOSTED_STABLE_FRONTEND_URL'), |
| 42 | + stableApiUrl: required(input.HOSTED_STABLE_API_URL, 'HOSTED_STABLE_API_URL'), |
| 43 | + expectedDevChannel: input.HOSTED_EXPECTED_DEV_CHANNEL?.trim() || 'dev', |
| 44 | + expectedStableChannel: input.HOSTED_EXPECTED_STABLE_CHANNEL?.trim() || 'stable', |
| 45 | + expectedDevCommit: optional(input.HOSTED_EXPECTED_DEV_COMMIT), |
| 46 | + expectedStableCommit: optional(input.HOSTED_EXPECTED_STABLE_COMMIT), |
| 47 | + testAccountProvider: parseProvider(input.HOSTED_TEST_ACCOUNT_PROVIDER), |
| 48 | + allowMutations: parseBoolean(input.HOSTED_ALLOW_MUTATIONS, false), |
| 49 | + allowOAuth: parseBoolean(input.HOSTED_ALLOW_OAUTH, false), |
| 50 | + timeoutMs: parseTimeout(input.HOSTED_TIMEOUT_MS), |
| 51 | + allowedApiHosts: parseHosts(input.HOSTED_ALLOWED_API_HOSTS), |
| 52 | + }; |
| 53 | + validateHostedConfig(config); |
| 54 | + return config; |
| 55 | +} |
| 56 | + |
| 57 | +export function loadHostedConfig(filePath: string): HostedConfig { |
| 58 | + const raw = JSON.parse(readFileSync(filePath, 'utf8')) as HostedConfigInput; |
| 59 | + return parseHostedConfig(raw); |
| 60 | +} |
| 61 | + |
| 62 | +export function validateHostedConfig(config: HostedConfig): void { |
| 63 | + const urls = [config.devFrontendUrl, config.devApiUrl, config.stableFrontendUrl, config.stableApiUrl]; |
| 64 | + for (const value of urls) { |
| 65 | + const url = parseUrl(value); |
| 66 | + if (url.protocol !== 'https:') throw new Error(`Hosted URL must use HTTPS: ${value}`); |
| 67 | + if (url.username || url.password) throw new Error(`Hosted URL must not contain credentials: ${value}`); |
| 68 | + } |
| 69 | + const devApi = parseUrl(config.devApiUrl); |
| 70 | + const stableApi = parseUrl(config.stableApiUrl); |
| 71 | + if (devApi.origin === stableApi.origin) throw new Error('Development and stable API origins must be distinct.'); |
| 72 | + if (!config.allowedApiHosts.includes(devApi.host) || !config.allowedApiHosts.includes(stableApi.host)) { |
| 73 | + throw new Error('Development and stable API hosts must be present in the configured API allowlist (HOSTED_ALLOWED_API_HOSTS).'); |
| 74 | + } |
| 75 | + if (!Number.isInteger(config.timeoutMs) || config.timeoutMs < 100 || config.timeoutMs > 120_000) { |
| 76 | + throw new Error('HOSTED_TIMEOUT_MS must be an integer between 100 and 120000.'); |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +function required(value: string | undefined, name: string): string { |
| 81 | + if (!value?.trim()) throw new Error(`${name} is required.`); |
| 82 | + return value.trim(); |
| 83 | +} |
| 84 | + |
| 85 | +function optional(value: string | undefined): string | undefined { return value?.trim() || undefined; } |
| 86 | + |
| 87 | +function parseUrl(value: string): URL { |
| 88 | + try { return new URL(value); } catch { throw new Error(`Invalid hosted URL: ${value}`); } |
| 89 | +} |
| 90 | + |
| 91 | +function parseProvider(value: string | undefined): HostedAccountProvider { |
| 92 | + if (value !== 'external' && value !== 'manual' && value !== 'fixture') throw new Error('HOSTED_TEST_ACCOUNT_PROVIDER must be external, manual, or fixture.'); |
| 93 | + return value; |
| 94 | +} |
| 95 | + |
| 96 | +function parseBoolean(value: string | boolean | undefined, fallback: boolean): boolean { |
| 97 | + if (value === undefined) return fallback; |
| 98 | + if (value === true || value === 'true') return true; |
| 99 | + if (value === false || value === 'false') return false; |
| 100 | + throw new Error('Hosted boolean options must be true or false.'); |
| 101 | +} |
| 102 | + |
| 103 | +function parseTimeout(value: string | number | undefined): number { return value === undefined ? 15_000 : Number(value); } |
| 104 | + |
| 105 | +function parseHosts(value: string | string[] | undefined): string[] { |
| 106 | + const values = Array.isArray(value) ? value : value?.split(',') ?? []; |
| 107 | + return values.map((host) => host.trim().toLowerCase()).filter(Boolean); |
| 108 | +} |
0 commit comments