// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors
// SPDX-License-Identifier: Apache-2.0
import { concatUrls } from '@medplum/core';
import { generateKeyPairSync, randomUUID } from 'node:crypto';
import { getLogger } from '../logger';
import type { MedplumServerConfig } from './types';
const DEFAULT_AWS_REGION = 'us-east-1';
export type ServerConfig = MedplumServerConfig & Required<Pick<MedplumServerConfig, DefaultConfigKeys>>;
/**
* Adds default values to the config.
* @param config - The input config as loaded from the config file.
* @returns The config with default values added.
*/
export function addDefaults(config: MedplumServerConfig): ServerConfig {
config.port ||= 8103;
config.issuer ||= config.baseUrl;
config.jwksUrl ||= concatUrls(config.baseUrl, '/.well-known/jwks.json');
config.authorizeUrl ||= concatUrls(config.baseUrl, '/oauth2/authorize');
config.tokenUrl ||= concatUrls(config.baseUrl, '/oauth2/token');
config.userInfoUrl ||= concatUrls(config.baseUrl, '/oauth2/userinfo');
config.introspectUrl ||= concatUrls(config.baseUrl, '/oauth2/introspect');
config.registerUrl ||= concatUrls(config.baseUrl, '/oauth2/register');
config.storageBaseUrl ||= concatUrls(config.baseUrl, '/storage');
config.maxJsonSize ||= '1mb';
config.maxBatchSize ||= '50mb';
config.awsRegion ||= DEFAULT_AWS_REGION;
config.botLambdaLayerName ||= 'medplum-bot-layer';
config.bcryptHashSalt ||= 10;
config.bullmq = { concurrency: 20, removeOnComplete: { count: 1 }, removeOnFail: { count: 1 }, ...config.bullmq };
config.shutdownTimeoutMilliseconds ??= 30_000;
config.accurateCountThreshold ??= 1_000_000;
config.maxSearchOffset ??= 10_000;
config.defaultBotRuntimeVersion ??= 'awslambda';
config.defaultProjectFeatures ??= [];
config.defaultProjectSystemSetting ??= [];
config.emailProvider ||= config.smtp ? 'smtp' : 'awsses';
config.autoDownloadEnabled ??= true;
// History:
// Before, the default "auth rate limit" was 600 per 15 minutes, but used "MemoryStore" rather than "RedisStore"
// That meant that the rate limit was per server instance, rather than per server cluster
// The value was primarily tuned for one particular cluster with 6 server instances
// Therefore, to maintain parity, the new default "auth rate limit" is 1200 per 15 minutes
config.defaultRateLimit ??= 60_000;
config.defaultAuthRateLimit ??= 160;
config.defaultFhirQuota ??= 50_000;
// Automatically generate a signing key if using built-in storage and no signing key is provided
if (config.storageBaseUrl.startsWith(config.baseUrl) && !config.signingKey) {
getLogger().warn(
'Generating temporary signing key. Storage URLs will not work in cluster environments, and will be invalid after server restart.'
);
const passphrase = randomUUID();
const signingKey = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs1',
format: 'pem',
cipher: 'aes-256-cbc',
passphrase,
},
});
config.signingKeyId = 'medplum-generated-key';
config.signingKey = signingKey.privateKey;
config.signingKeyPassphrase = passphrase;
}
return config as ServerConfig;
}
type DefaultConfigKeys =
| 'port'
| 'issuer'
| 'jwksUrl'
| 'authorizeUrl'
| 'tokenUrl'
| 'userInfoUrl'
| 'introspectUrl'
| 'storageBaseUrl'
| 'maxJsonSize'
| 'maxBatchSize'
| 'awsRegion'
| 'botLambdaLayerName'
| 'bcryptHashSalt'
| 'bullmq'
| 'shutdownTimeoutMilliseconds'
| 'accurateCountThreshold'
| 'maxSearchOffset'
| 'defaultBotRuntimeVersion'
| 'defaultProjectFeatures'
| 'defaultProjectSystemSetting'
| 'emailProvider'
| 'defaultRateLimit'
| 'defaultAuthRateLimit'
| 'defaultFhirQuota';
const integerKeys = new Set([
'accurateCountThreshold',
'bcryptHashSalt',
'defaultAuthRateLimit',
'defaultFhirQuota',
'defaultRateLimit',
'heartbeatMilliseconds',
'keepAliveTimeout',
'maxBotLogLengthForLogs',
'maxBotLogLengthForResource',
'maxSearchOffset',
'mfaAuthenticatorWindow',
'port',
'shutdownTimeoutMilliseconds',
'transactionAttempts',
'transactionExpBackoffBaseDelayMs',
'fhirSearchMinLimit',
'database.maxConnections',
'database.port',
'database.queryTimeout',
'readonlyDatabase.maxConnections',
'readonlyDatabase.port',
'readonlyDatabase.queryTimeout',
'redis.db',
'redis.port',
'smtp.port',
'bullmq.concurrency',
'fission.routerPort',
]);
export function isIntegerConfig(key: string): boolean {
return integerKeys.has(key);
}
export function isFloatConfig(_key: string): boolean {
return false;
}
const booleanKeys = new Set([
'botCustomFunctionsEnabled',
'database.ssl.rejectUnauthorized',
'database.ssl.require',
'database.disableConnectionConfiguration',
'database.disableRunPostDeployMigrations',
'database.runMigrations',
'readonlyDatabase.ssl.rejectUnauthorized',
'readonlyDatabase.ssl.require',
'readonlyDatabase.disableConnectionConfiguration',
'logRequests',
'logAuditEvents',
'mcpEnabled',
'registerEnabled',
'require',
'rejectUnauthorized',
'fhirSearchDiscourageSeqScan',
'redactAuditEvents',
]);
export function isBooleanConfig(key: string): boolean {
return booleanKeys.has(key);
}
const objectKeys = new Set([
'tls',
'ssl',
'defaultProjectSystemSetting',
'defaultOAuthClients',
'smtp',
'arrayColumnPadding',
]);
export function isObjectConfig(key: string): boolean {
return objectKeys.has(key);
}
export function setValue(config: Record<string, unknown>, key: string, value: string): void {
const keySegments = key.split('.');
let obj = config;
while (keySegments.length > 1) {
const segment = keySegments.shift() as string;
if (!obj[segment]) {
obj[segment] = {};
}
obj = obj[segment] as Record<string, unknown>;
}
let parsedValue: any = value;
if (isIntegerConfig(key)) {
parsedValue = Number.parseInt(value, 10);
} else if (isBooleanConfig(key)) {
parsedValue = value === 'true';
} else if (isObjectConfig(key)) {
parsedValue = JSON.parse(value);
}
obj[keySegments[0]] = parsedValue;
}