4c9e5feec0
Preserve the successor session for legitimate refresh retries so transient failures no longer revoke the user's session family.
704 lines
29 KiB
Kotlin
704 lines
29 KiB
Kotlin
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<StoreKitProduct> = 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<StoreKitProduct> {
|
|
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]*")
|