package com.osglab.account.config import com.osglab.account.features.storekit.domain.StoreKitProduct import io.ktor.server.config.ApplicationConfig import java.net.URI import java.time.ZoneId import java.util.Base64 import java.util.UUID data class AppConfig( val environment: Environment, val publicBaseUrl: String, val inviteBaseUrl: String, val appStoreUrl: String, val database: DatabaseConfig, val session: SessionConfig, val encryption: EncryptionConfig, val antiAbuse: AntiAbuseConfig, val apple: AppleConfig, val credits: CreditsConfig, val storeKit: StoreKitConfig = StoreKitConfig(), val providers: ProvidersConfig, val integrity: IntegrityConfig, val admin: AdminConfig = AdminConfig(), val hintFeed: HintFeedConfig = HintFeedConfig(), ) { val isProduction: Boolean = environment == Environment.PRODUCTION companion object { fun from(config: ApplicationConfig): AppConfig { val environment = Environment.parse(config.required("app.environment")) val production = environment == Environment.PRODUCTION val databaseUsername = config.required("app.database.username") val databasePassword = config.secret("app.database.password", production) val migrationUsername = if (production) { config.required("app.database.migrationUsername") } else { config.optionalValue("app.database.migrationUsername") ?: databaseUsername } val migrationPassword = config.optionalSecret( "app.database.migrationPassword", production = production, ) ?: databasePassword val database = DatabaseConfig( jdbcUrl = config.required("app.database.jdbcUrl"), username = databaseUsername, password = databasePassword, migrationUsername = migrationUsername, migrationPassword = migrationPassword, maximumPoolSize = config.positiveInt("app.database.maximumPoolSize"), ) val session = SessionConfig( issuer = config.required("app.session.issuer"), audience = config.required("app.session.audience"), hmacSecret = config.secret("app.session.secret", production).toByteArray(), accessMinutes = config.positiveLong("app.session.accessMinutes"), refreshDays = config.positiveLong("app.session.refreshDays"), legacyRefreshReplaySeconds = config.positiveLong( "app.session.legacyRefreshReplaySeconds", 30, ), gatewayGrantDays = config.positiveLong("app.session.gatewayGrantDays", 30), ) val encryption = EncryptionConfig( key = config.base64Key("app.encryption.keyBase64", production), ) val antiAbuse = AntiAbuseConfig( identityHmacKey = config.base64Key("app.antiAbuse.identityHmacKeyBase64", production), tombstoneRetentionDays = config.positiveLong( "app.antiAbuse.tombstoneRetentionDays", 365, ), ) val apple = AppleConfig( teamId = config.optionalSecret("app.apple.teamId", production), keyId = config.optionalSecret("app.apple.keyId", production), clientId = config.required("app.apple.clientId"), privateKeyPem = config.optionalSecret("app.apple.privateKeyPem", production) ?.replace("\\n", "\n"), jwksUrl = config.httpsUrl("app.apple.jwksUrl", production), tokenUrl = config.httpsUrl("app.apple.tokenUrl", production), revokeUrl = config.httpsUrl("app.apple.revokeUrl", production), ) val credits = CreditsConfig( signupTrial = config.positiveLong("app.credits.signupTrial", 1_000), referralInviter = config.positiveLong("app.credits.referralInviter", 1_000), referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000), referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7), ) val storeKitEnabled = config.booleanOrDefault("app.storeKit.enabled", false) val storeKitAppAppleId = config.optionalValue("app.storeKit.appAppleId")?.let { raw -> raw.toLongOrNull()?.takeIf { it > 0 } ?: throw ConfigValidationException("app.storeKit.appAppleId must be positive") } val storeKit = StoreKitConfig( enabled = storeKitEnabled, bundleId = config.valueOrDefault("app.storeKit.bundleId", apple.clientId), appAppleId = storeKitAppAppleId, products = config.storeKitProducts("app.storeKit.products"), ) if (storeKitEnabled) { require(storeKit.bundleId == apple.clientId) { "app.storeKit.bundleId must match app.apple.clientId" } require(storeKit.appAppleId != null) { "app.storeKit.appAppleId is required when StoreKit is enabled" } require(storeKit.products.isNotEmpty()) { "app.storeKit.products is required when StoreKit is enabled" } } val deepSeekModel = config.valueOrDefault( "app.providers.deepseek.model", "deepseek-v4-flash", ) val providers = ProvidersConfig( volcengine = VolcengineConfig( endpoint = config.valueOrDefault( "app.providers.volcengine.endpoint", "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel", ), appId = config.optionalValue("app.providers.volcengine.appId"), accessToken = config.optionalValue("app.providers.volcengine.accessToken"), apiKey = config.optionalValue("app.providers.volcengine.apiKey"), resourceId = config.valueOrDefault( "app.providers.volcengine.resourceId", "volc.seedasr.sauc.duration", ), ), deepSeek = DeepSeekConfig( endpoint = config.valueOrDefault( "app.providers.deepseek.endpoint", "https://api.deepseek.com/v1", ), apiKey = config.optionalValue("app.providers.deepseek.apiKey"), model = deepSeekModel, reasoningModel = config.optionalValue("app.providers.deepseek.reasoningModel") ?: deepSeekModel, ), ) val integrity = IntegrityConfig( deviceCheckPolicy = IntegrityPolicy.fromEnforced( config.boolean("app.integrity.enforceDeviceCheck"), ), appAttestPolicy = IntegrityPolicy.fromEnforced( config.boolean("app.integrity.enforceAppAttest"), ), appleEnvironment = AppleServiceEnvironment.parse( config.valueOrDefault( "app.integrity.appleEnvironment", if (production) "production" else "development", ), ), allowDevelopmentAppAttest = config.booleanOrDefault( "app.integrity.allowDevelopmentAppAttest", false, ), challengeLifetimeSeconds = config.positiveLong( "app.integrity.challengeLifetimeSeconds", 300, ), ) val adminEnabled = config.booleanOrDefault("app.admin.enabled", false) val adminBootstrapEnabled = config.booleanOrDefault( "app.admin.bootstrapEnabled", false, ) require(!adminBootstrapEnabled || adminEnabled) { "app.admin.bootstrapEnabled requires app.admin.enabled" } val admin = AdminConfig( enabled = adminEnabled, mtlsRequired = config.booleanOrDefault("app.admin.mtlsRequired", true), bootstrapEnabled = adminBootstrapEnabled, bootstrapOperatorId = config.optionalValue("app.admin.bootstrapOperatorId") ?.let { runCatching { UUID.fromString(it) }.getOrElse { cause -> throw ConfigValidationException( "app.admin.bootstrapOperatorId must be a UUID", cause, ) } }, bootstrapUsername = config.optionalValue("app.admin.bootstrapUsername"), bootstrapPasswordHash = config.optionalLiteralSecret( "app.admin.bootstrapPasswordHash", production && adminBootstrapEnabled, ), bootstrapTotpSecretBase32 = config.optionalSecret( "app.admin.bootstrapTotpSecretBase32", production && adminBootstrapEnabled, ), sessionHours = config.positiveLong("app.admin.sessionHours", 8), maximumManualGrant = config.positiveLong( "app.admin.maximumManualGrant", 100_000, ), ) val hintFeed = HintFeedConfig( enabled = config.booleanOrDefault("app.hintFeed.enabled", false), topHubApiKey = config.optionalSecret( "app.hintFeed.topHubApiKey", production = false, ), zoneId = config.valueOrDefault("app.hintFeed.zoneId", "UTC").let { raw -> runCatching { ZoneId.of(raw) }.getOrElse { cause -> throw ConfigValidationException("app.hintFeed.zoneId must be a valid time zone", cause) } }, ) require(session.hmacSecret.size >= MIN_HMAC_SECRET_BYTES) { "app.session.secret must contain at least $MIN_HMAC_SECRET_BYTES bytes" } require(encryption.key.size == AES_256_KEY_BYTES) { "app.encryption.keyBase64 must decode to exactly $AES_256_KEY_BYTES bytes" } require(antiAbuse.identityHmacKey.size >= MIN_HMAC_SECRET_BYTES) { "app.antiAbuse.identityHmacKeyBase64 must decode to at least $MIN_HMAC_SECRET_BYTES bytes" } require( !session.hmacSecret.contentEquals(encryption.key) && !session.hmacSecret.contentEquals(antiAbuse.identityHmacKey) && !encryption.key.contentEquals(antiAbuse.identityHmacKey) ) { "Session, field-encryption, and identity-HMAC secrets must be distinct" } require(database.jdbcUrl.startsWith("jdbc:mysql:")) { "app.database.jdbcUrl must use MySQL" } require( !production || (!database.migrationUsername.isPlaceholder() && database.migrationUsername != database.username) ) { "Production migration and runtime database users must be distinct" } require(!production || database.migrationPassword != database.password) { "Production migration and runtime database passwords must be distinct" } require(database.maximumPoolSize in 1..100) { "app.database.maximumPoolSize must be between 1 and 100" } require(session.accessMinutes in 1..60) { "app.session.accessMinutes must be between 1 and 60" } require(session.refreshDays in 1..365) { "app.session.refreshDays must be between 1 and 365" } require(session.legacyRefreshReplaySeconds in 5..120) { "app.session.legacyRefreshReplaySeconds must be between 5 and 120" } require(!production || providers.volcengine.credentialsAvailable) { "Production Volcengine credentials are missing" } require(!production || providers.deepSeek.credentialsAvailable) { "Production DeepSeek credentials are missing" } if (production) { requireExactProviderEndpoint( providers.deepSeek.endpoint, "https", "api.deepseek.com", "DeepSeek", ) requireExactProviderEndpoint( providers.volcengine.endpoint, "wss", "openspeech.bytedance.com", "Volcengine", ) } require(integrity.challengeLifetimeSeconds in 30..600) { "app.integrity.challengeLifetimeSeconds must be between 30 and 600 seconds" } require(!production || integrity.appleEnvironment == AppleServiceEnvironment.PRODUCTION) { "Production must use the Apple production integrity environment" } require( !production || ( integrity.deviceCheckPolicy == IntegrityPolicy.ENFORCE && integrity.appAttestPolicy == IntegrityPolicy.ENFORCE ) ) { "Production must enforce both DeviceCheck and App Attest" } require(!production || apple.teamId == APP_ATTEST_TEAM_ID) { "Production Apple team ID must be $APP_ATTEST_TEAM_ID" } require(!production || apple.clientId == APP_ATTEST_BUNDLE_ID) { "Production Apple client ID must be $APP_ATTEST_BUNDLE_ID" } require(admin.sessionHours in 1..24) { "app.admin.sessionHours must be between 1 and 24" } require(admin.maximumManualGrant in 1..100_000_000) { "app.admin.maximumManualGrant must be between 1 and 100000000" } if (admin.bootstrapEnabled) { requireNotNull(admin.bootstrapOperatorId) { "app.admin.bootstrapOperatorId is required when admin bootstrap is enabled" } require(!admin.bootstrapUsername.isNullOrBlank()) { "app.admin.bootstrapUsername is required when admin bootstrap is enabled" } require(!admin.bootstrapPasswordHash.isNullOrBlank()) { "app.admin.bootstrapPasswordHash is required when admin bootstrap is enabled" } require(!admin.bootstrapTotpSecretBase32.isNullOrBlank()) { "app.admin.bootstrapTotpSecretBase32 is required when admin bootstrap is enabled" } } if (production) { requireExactAppleEndpoint(apple.jwksUrl, "/auth/keys", "JWKS") requireExactAppleEndpoint(apple.tokenUrl, "/auth/token", "token") requireExactAppleEndpoint(apple.revokeUrl, "/auth/revoke", "revoke") } val publicBaseUrl = config.productionValueOrDefault( "app.publicBaseUrl", "http://localhost:8080", production, ).validatedExternalUrl("app.publicBaseUrl", production) val inviteBaseUrl = config.productionValueOrDefault( "app.inviteBaseUrl", "https://osglab.com/i", production, ).validatedExternalUrl("app.inviteBaseUrl", production) val appStoreUrl = config.productionValueOrDefault( "app.appStoreUrl", "https://apps.apple.com", production, ).validatedExternalUrl("app.appStoreUrl", production) if (production) { requireExactExternalUrl(publicBaseUrl, "account.osglab.com", "", "PUBLIC_BASE_URL") requireExactExternalUrl(inviteBaseUrl, "osglab.com", "/i", "INVITE_BASE_URL") requireOfficialAppStoreUrl(appStoreUrl) } return AppConfig( environment = environment, publicBaseUrl = publicBaseUrl, inviteBaseUrl = inviteBaseUrl, appStoreUrl = appStoreUrl, database = database, session = session, encryption = encryption, antiAbuse = antiAbuse, apple = apple, credits = credits, storeKit = storeKit, providers = providers, integrity = integrity, admin = admin, hintFeed = hintFeed, ) } } } enum class Environment { DEVELOPMENT, TEST, PRODUCTION; companion object { fun parse(value: String): Environment = entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: throw ConfigValidationException("Unsupported app.environment: $value") } } data class DatabaseConfig( val jdbcUrl: String, val username: String, val password: String, val maximumPoolSize: Int, val migrationUsername: String = username, val migrationPassword: String = password, ) data class SessionConfig( val issuer: String, val audience: String, val hmacSecret: ByteArray, val accessMinutes: Long, val refreshDays: Long, val legacyRefreshReplaySeconds: Long = 30, val gatewayGrantDays: Long = 30, ) data class EncryptionConfig(val key: ByteArray) data class AntiAbuseConfig( val identityHmacKey: ByteArray, val tombstoneRetentionDays: Long, ) data class HintFeedConfig( val enabled: Boolean = false, val topHubApiKey: String? = null, val zoneId: ZoneId = ZoneId.of("UTC"), ) data class AppleConfig( val teamId: String?, val keyId: String?, val clientId: String, val privateKeyPem: String?, val jwksUrl: String, val tokenUrl: String, val revokeUrl: String, ) { val clientCredentialsAvailable: Boolean get() = teamId != null && keyId != null && privateKeyPem != null } data class CreditsConfig( val signupTrial: Long, val referralInviter: Long, val referralInvitee: Long, val referralBindingDays: Long, ) data class StoreKitConfig( val enabled: Boolean = false, val bundleId: String = "com.osgkeyboard.ios", val appAppleId: Long? = null, val products: List = emptyList(), ) data class ProvidersConfig( val volcengine: VolcengineConfig, val deepSeek: DeepSeekConfig, ) data class VolcengineConfig( val endpoint: String, val appId: String?, val accessToken: String?, val apiKey: String?, val resourceId: String, ) { val credentialsAvailable: Boolean get() = !apiKey.isNullOrBlank() || (!appId.isNullOrBlank() && !accessToken.isNullOrBlank()) } data class DeepSeekConfig( val endpoint: String, val apiKey: String?, val model: String, val reasoningModel: String = model, ) { val credentialsAvailable: Boolean get() = !apiKey.isNullOrBlank() } data class IntegrityConfig( val deviceCheckPolicy: IntegrityPolicy, val appAttestPolicy: IntegrityPolicy, val appleEnvironment: AppleServiceEnvironment = AppleServiceEnvironment.DEVELOPMENT, val allowDevelopmentAppAttest: Boolean = false, val challengeLifetimeSeconds: Long = 300, val appAttestTeamId: String = APP_ATTEST_TEAM_ID, val appAttestBundleId: String = APP_ATTEST_BUNDLE_ID, ) data class AdminConfig( val enabled: Boolean = false, val mtlsRequired: Boolean = true, val bootstrapEnabled: Boolean = false, val bootstrapOperatorId: UUID? = null, val bootstrapUsername: String? = null, val bootstrapPasswordHash: String? = null, val bootstrapTotpSecretBase32: String? = null, val sessionHours: Long = 8, val maximumManualGrant: Long = 100_000, ) enum class IntegrityPolicy { MONITOR, ENFORCE; companion object { fun fromEnforced(enforced: Boolean): IntegrityPolicy = if (enforced) ENFORCE else MONITOR } } enum class AppleServiceEnvironment { DEVELOPMENT, PRODUCTION; companion object { fun parse(value: String): AppleServiceEnvironment = entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: throw ConfigValidationException("Unsupported Apple integrity environment: $value") } } class ConfigValidationException(message: String, cause: Throwable? = null) : IllegalStateException(message, cause) private const val MIN_HMAC_SECRET_BYTES = 32 private const val AES_256_KEY_BYTES = 32 const val APP_ATTEST_TEAM_ID = "X329MZU23S" const val APP_ATTEST_BUNDLE_ID = "com.osgkeyboard.ios" private val PLACEHOLDER_MARKERS = listOf("replace-with", "change-me", "\${", "$") private fun ApplicationConfig.required(path: String): String = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?: throw ConfigValidationException("Missing required configuration: $path") private fun ApplicationConfig.valueOrDefault(path: String, default: String): String = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?: default private fun ApplicationConfig.productionValueOrDefault( path: String, default: String, production: Boolean, ): String = if (production) required(path) else valueOrDefault(path, default) private fun ApplicationConfig.optionalValue(path: String): String? = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) ?.takeUnless(String::isPlaceholder) private fun ApplicationConfig.secret(path: String, production: Boolean): String = optionalSecret(path, production) ?: throw ConfigValidationException("Missing required secret: $path") private fun ApplicationConfig.optionalSecret(path: String, production: Boolean): String? { val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) if (production && (value == null || value.isPlaceholder())) { throw ConfigValidationException("Production secret is missing or uses a placeholder: $path") } return value?.takeUnless(String::isPlaceholder) } private fun ApplicationConfig.optionalLiteralSecret(path: String, production: Boolean): String? { val value = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty) val placeholder = value?.let { it.contains("replace-with", ignoreCase = true) || it.contains("change-me", ignoreCase = true) || it.contains("\${") } == true if (production && (value == null || placeholder)) { throw ConfigValidationException("Production secret is missing or uses a placeholder: $path") } return value?.takeUnless { placeholder } } private fun String.isPlaceholder(): Boolean = PLACEHOLDER_MARKERS.any { marker -> contains(marker, ignoreCase = true) } private fun ApplicationConfig.positiveInt(path: String): Int = required(path).toIntOrNull()?.takeIf { it > 0 } ?: throw ConfigValidationException("$path must be a positive integer") private fun ApplicationConfig.positiveLong(path: String): Long = required(path).toLongOrNull()?.takeIf { it > 0 } ?: throw ConfigValidationException("$path must be a positive integer") private fun ApplicationConfig.positiveLong(path: String, default: Long): Long = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let { it.toLongOrNull()?.takeIf { value -> value > 0 } ?: throw ConfigValidationException("$path must be a positive integer") } ?: default private fun ApplicationConfig.storeKitProducts(path: String): List { val raw = optionalValue(path) ?: return emptyList() val products = raw.split(',').map { entry -> val parts = entry.split(':', limit = 2).map(String::trim) if (parts.size != 2) { throw ConfigValidationException("$path must use productId:credits entries") } val credits = parts[1].toLongOrNull()?.takeIf { it > 0 } ?: throw ConfigValidationException("$path credits must be positive integers") runCatching { StoreKitProduct(productId = parts[0], credits = credits) } .getOrElse { throw ConfigValidationException("$path contains an invalid product", it) } } if (products.map(StoreKitProduct::productId).distinct().size != products.size) { throw ConfigValidationException("$path contains duplicate product IDs") } return products } private fun ApplicationConfig.boolean(path: String): Boolean = required(path).let { when (it.lowercase()) { "true" -> true "false" -> false else -> throw ConfigValidationException("$path must be true or false") } } private fun ApplicationConfig.booleanOrDefault(path: String, default: Boolean): Boolean = propertyOrNull(path)?.getString()?.trim()?.takeIf(String::isNotEmpty)?.let { when (it.lowercase()) { "true" -> true "false" -> false else -> throw ConfigValidationException("$path must be true or false") } } ?: default private fun ApplicationConfig.base64Key(path: String, production: Boolean): ByteArray { val encoded = secret(path, production) return try { Base64.getDecoder().decode(encoded) } catch (exception: IllegalArgumentException) { throw ConfigValidationException("$path must be valid Base64", exception) } } private fun ApplicationConfig.httpsUrl(path: String, production: Boolean): String { val value = required(path) val uri = runCatching { URI(value) } .getOrElse { throw ConfigValidationException("$path must be a valid URL", it) } require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) { "$path must be an absolute URL without user information" } require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) { "$path must use HTTP or HTTPS" } require(!production || uri.scheme.equals("https", ignoreCase = true)) { "$path must use HTTPS in production" } return value } private fun String.validatedExternalUrl(path: String, production: Boolean): String { val uri = runCatching { URI(this) } .getOrElse { throw ConfigValidationException("$path must be a valid URL", it) } require(uri.isAbsolute && !uri.host.isNullOrBlank() && uri.userInfo == null) { "$path must be an absolute URL without user information" } require(uri.scheme.equals("https", true) || (!production && uri.scheme.equals("http", true))) { "$path must use HTTPS${if (production) "" else " or HTTP"}" } return this } private fun requireExactAppleEndpoint(value: String, path: String, name: String) { val uri = URI(value) require( uri.scheme.equals("https", true) && uri.host.equals("appleid.apple.com", true) && uri.port == -1 && uri.path == path && uri.rawQuery == null && uri.rawFragment == null ) { "Apple $name endpoint must be https://appleid.apple.com$path" } } private fun requireExactProviderEndpoint( value: String, scheme: String, host: String, provider: String, ) { val uri = runCatching { URI(value) } .getOrElse { throw ConfigValidationException("$provider endpoint is invalid", it) } require( uri.scheme.equals(scheme, ignoreCase = true) && uri.host.equals(host, ignoreCase = true) && uri.userInfo == null && (uri.port == -1 || uri.port == 443) ) { "$provider endpoint must use $scheme://$host on the default TLS port" } } private fun requireExactExternalUrl(value: String, host: String, path: String, name: String) { val uri = URI(value) require( uri.scheme.equals("https", ignoreCase = true) && uri.host.equals(host, ignoreCase = true) && uri.port == -1 && uri.path.trimEnd('/') == path && uri.rawQuery == null && uri.rawFragment == null ) { "$name must be https://$host$path" } } private fun requireOfficialAppStoreUrl(value: String) { val uri = URI(value) require( uri.scheme.equals("https", ignoreCase = true) && uri.host.equals("apps.apple.com", ignoreCase = true) && uri.port == -1 && uri.userInfo == null && uri.rawFragment == null && APP_STORE_APP_PATH.matches(uri.path) ) { "APP_STORE_URL must be an official apps.apple.com URL ending in a non-zero numeric App ID" } } private val APP_STORE_APP_PATH = Regex("/.+/id[1-9][0-9]*")