Files
OSGAccountServer/src/main/kotlin/com/osglab/account/Application.kt
T
Rocky 9fb947aa7d
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled
Add runtime provider controls and searchable AI routing
Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
2026-08-22 16:33:17 +08:00

763 lines
33 KiB
Kotlin

package com.osglab.account
import com.osglab.account.common.api.installApiStatusPages
import com.osglab.account.common.security.FieldEncryptor
import com.osglab.account.common.security.IdentityFingerprint
import com.osglab.account.common.security.SessionJwt
import com.osglab.account.common.security.installSessionAuthentication
import com.osglab.account.config.AppConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.grants.services.AdminGrantService
import com.osglab.account.features.admin.repositories.AdminRepository
import com.osglab.account.features.admin.repositories.ExposedAdminRepository
import com.osglab.account.features.admin.routes.adminApiRoutes
import com.osglab.account.features.admin.routes.adminWebRoutes
import com.osglab.account.features.admin.security.AdminPasswordHasher
import com.osglab.account.features.admin.security.AdminTotpVerifier
import com.osglab.account.features.admin.security.BouncyCastleArgon2idPasswordHasher
import com.osglab.account.features.admin.security.HmacTotpVerifier
import com.osglab.account.features.admin.services.AdminAuthService
import com.osglab.account.features.admin.services.AdminAuditService
import com.osglab.account.features.admin.services.AdminBootstrapConfig
import com.osglab.account.features.admin.services.AdminBootstrapService
import com.osglab.account.features.admin.services.AdminOperatorService
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.repositories.AdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.AdminProductAnalyticsRepository
import com.osglab.account.features.admin.stats.repositories.ExposedAdminStatsRepository
import com.osglab.account.features.admin.stats.repositories.ExposedAdminProductAnalyticsRepository
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.account.AccountOperations
import com.osglab.account.features.account.AccountReauthenticator
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountService
import com.osglab.account.features.account.AppleAccountReauthenticator
import com.osglab.account.features.account.AppleRevocationOutboxProcessor
import com.osglab.account.features.account.ExposedAccountRepository
import com.osglab.account.features.account.accountRoutes
import com.osglab.account.features.analytics.repositories.AnalyticsRepository
import com.osglab.account.features.analytics.repositories.ExposedAnalyticsRepository
import com.osglab.account.features.analytics.routes.analyticsRoutes
import com.osglab.account.features.analytics.services.AnalyticsMaintenanceService
import com.osglab.account.features.analytics.services.AnalyticsService
import com.osglab.account.features.analytics.services.DefaultAnalyticsService
import com.osglab.account.features.appleevents.AppleEventService
import com.osglab.account.features.appleevents.AppleEventRepository
import com.osglab.account.features.appleevents.AppleEventVerifier
import com.osglab.account.features.appleevents.ExposedAppleEventRepository
import com.osglab.account.features.appleevents.appleEventRoutes
import com.osglab.account.features.auth.AccountProvisioner
import com.osglab.account.features.auth.AppleIdentityTokenVerifier
import com.osglab.account.features.auth.AppleJwksProvider
import com.osglab.account.features.auth.AppleTokenClient
import com.osglab.account.features.auth.AuthRepository
import com.osglab.account.features.auth.ExposedAuthRepository
import com.osglab.account.features.auth.RemoteAppleJwksProvider
import com.osglab.account.features.auth.SessionService
import com.osglab.account.features.auth.SessionAccessAuthenticator
import com.osglab.account.features.auth.authRoutes
import com.osglab.account.features.auth.createAppleTokenClient
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.credits.repositories.ExposedBillingTransactionRunner
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.creditRoutes
import com.osglab.account.features.credits.services.CreditOperations
import com.osglab.account.features.credits.services.CreditService
import com.osglab.account.features.credits.services.ReferralRewardConfig
import com.osglab.account.features.credits.services.signupTrialIdempotencyKey
import com.osglab.account.features.content.repositories.ContentRepository
import com.osglab.account.features.content.repositories.ExposedContentRepository
import com.osglab.account.features.content.routes.contentRoutes
import com.osglab.account.features.content.services.ContentService
import com.osglab.account.features.content.feed.ExposedHintFeedRepository
import com.osglab.account.features.content.feed.HintFeedRepository
import com.osglab.account.features.content.feed.HintFeedGenerationLock
import com.osglab.account.features.content.feed.HintFeedScheduler
import com.osglab.account.features.content.feed.HintFeedService
import com.osglab.account.features.content.feed.MysqlHintFeedGenerationLock
import com.osglab.account.features.content.feed.sources.BaselineHintSource
import com.osglab.account.features.content.feed.sources.GoogleFeedHintSource
import com.osglab.account.features.content.feed.sources.HolidayHintSource
import com.osglab.account.features.content.feed.sources.TopHubHintSource
import com.osglab.account.features.content.feed.sources.WeatherHintSource
import com.osglab.account.features.gateway.adapters.CreditReservationAdapter
import com.osglab.account.features.gateway.adapters.SessionIdentityAdapter
import com.osglab.account.features.gateway.GatewaySettings
import com.osglab.account.features.gateway.asr.AsrStreamingService
import com.osglab.account.features.gateway.credentials.DatabaseProviderApiKeyResolver
import com.osglab.account.features.gateway.credentials.EnvironmentProviderCredentials
import com.osglab.account.features.gateway.credentials.ExposedGatewayCredentialRepository
import com.osglab.account.features.gateway.credentials.GatewayCredentialRepository
import com.osglab.account.features.gateway.credentials.GatewayCredentialService
import com.osglab.account.features.gateway.credentials.ProviderApiKeyResolver
import com.osglab.account.features.gateway.ports.CreditReservationPort
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
import com.osglab.account.features.gateway.ports.GatewayAccessTokenPort
import com.osglab.account.features.gateway.ports.GatewayGrantPort
import com.osglab.account.features.gateway.ports.GatewayGrantRepository
import com.osglab.account.features.gateway.ports.GatewayIdentityPort
import com.osglab.account.features.gateway.ports.GatewayUsagePort
import com.osglab.account.features.gateway.providers.GatewayProvider
import com.osglab.account.features.gateway.providers.ProviderCatalog
import com.osglab.account.features.gateway.providers.deepseek.DeepSeekConfig
import com.osglab.account.features.gateway.providers.deepseek.DeepSeekProvider
import com.osglab.account.features.gateway.providers.volcengine.KtorVolcengineAsrTransport
import com.osglab.account.features.gateway.providers.volcengine.VolcengineAsrConfig
import com.osglab.account.features.gateway.providers.volcengine.VolcengineAsrProvider
import com.osglab.account.features.gateway.repositories.ExposedGatewayRepository
import com.osglab.account.features.gateway.routes.configureGatewayRoutes
import com.osglab.account.features.gateway.services.GatewayBearerIdentity
import com.osglab.account.features.gateway.services.GatewayGrantService
import com.osglab.account.features.gateway.services.GatewayReconciliationService
import com.osglab.account.features.gateway.services.GatewayService
import com.osglab.account.features.health.healthRoutes
import com.osglab.account.features.integrity.AppAttestCrypto
import com.osglab.account.features.integrity.AppAttestRepository
import com.osglab.account.features.integrity.AppAttestService
import com.osglab.account.features.integrity.AppAttestVerifier
import com.osglab.account.features.integrity.AppleDeviceCheckClient
import com.osglab.account.features.integrity.BundledAppleAppAttestTrust
import com.osglab.account.features.integrity.DeviceCheckTrialClaimRepository
import com.osglab.account.features.integrity.DeviceCheckTrialService
import com.osglab.account.features.integrity.DeviceCheckVerifier
import com.osglab.account.features.integrity.ExposedAppAttestRepository
import com.osglab.account.features.integrity.ExposedDeviceCheckTrialClaimRepository
import com.osglab.account.features.integrity.IntegrityService
import com.osglab.account.features.integrity.LibraryAppAttestCrypto
import com.osglab.account.features.integrity.MysqlDeviceCheckTrialMutex
import com.osglab.account.features.integrity.RemoteDeviceCheckVerifier
import com.osglab.account.features.integrity.TrialCreditGranter
import com.osglab.account.features.integrity.UnavailableAppleDeviceCheckClient
import com.osglab.account.features.integrity.createDeviceCheckClient
import com.osglab.account.features.integrity.integrityRoutes
import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.InviteOpenRecorder
import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.oobe.ExposedOobeRepository
import com.osglab.account.features.oobe.OobeGrantService
import com.osglab.account.features.oobe.OobeRepository
import com.osglab.account.features.oobe.OobeTokenSettings
import com.osglab.account.features.oobe.oobeRoutes
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.routes.storeKitRoutes
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.WebSockets as ClientWebSockets
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationStopped
import io.ktor.server.application.install
import io.ktor.server.plugins.callid.CallId
import io.ktor.server.plugins.callid.callIdMdc
import io.ktor.server.plugins.calllogging.CallLogging
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
import io.ktor.server.plugins.forwardedheaders.XForwardedHeaders
import io.ktor.server.plugins.ratelimit.RateLimit
import io.ktor.server.plugins.ratelimit.RateLimitName
import io.ktor.server.plugins.ratelimit.rateLimit
import io.ktor.server.request.httpMethod
import io.ktor.server.response.respond
import io.ktor.server.routing.routing
import io.ktor.server.websocket.WebSockets
import kotlinx.serialization.json.Json
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.module.Module
import org.koin.dsl.module
import org.koin.ktor.ext.getKoin
import org.koin.ktor.plugin.Koin
import org.koin.logger.slf4jLogger
import java.time.Duration
import java.time.Instant
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.time.Duration.Companion.minutes
private val JSON = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
fun Application.module() {
val appConfig = AppConfig.from(environment.config)
install(ContentNegotiation) {
json(JSON)
}
install(DefaultHeaders) {
header("X-Content-Type-Options", "nosniff")
header("X-Frame-Options", "DENY")
header("Referrer-Policy", "no-referrer")
if (appConfig.isProduction) {
header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
install(CallId) {
retrieveFromHeader("X-Request-ID")
verify { it.matches(REQUEST_ID) }
generate { UUID.randomUUID().toString() }
}
install(CallLogging) {
callIdMdc("requestId")
// Never include paths, headers or bodies: they may contain invitation or session tokens.
format { call -> "${call.request.httpMethod.value} status=${call.response.status()}" }
}
install(WebSockets) {
maxFrameSize = 4L * 1024 * 1024
masking = false
}
install(XForwardedHeaders)
install(RateLimit) {
register(AUTH_RATE_LIMIT) {
rateLimiter(limit = 10, refillPeriod = 1.minutes)
}
register(ACCOUNT_RATE_LIMIT) {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
register(GATEWAY_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
register(PUBLIC_RATE_LIMIT) {
rateLimiter(limit = 120, refillPeriod = 1.minutes)
}
register(ADMIN_AUTH_RATE_LIMIT) {
rateLimiter(limit = 5, refillPeriod = 1.minutes)
}
register(ADMIN_API_RATE_LIMIT) {
rateLimiter(limit = 60, refillPeriod = 1.minutes)
}
}
installApiStatusPages()
install(Koin) {
slf4jLogger()
modules(accountServerModule(appConfig))
}
val koin = getKoin()
// Fail startup before accepting traffic if migrations or database connectivity fail.
koin.get<DatabaseFactory>().database
if (appConfig.admin.bootstrapEnabled) {
runBlocking {
koin.get<AdminBootstrapService>().initialize(
AdminBootstrapConfig(
enabled = true,
operatorId = appConfig.admin.bootstrapOperatorId,
username = appConfig.admin.bootstrapUsername,
passwordHash = appConfig.admin.bootstrapPasswordHash,
totpSecretBase32 = appConfig.admin.bootstrapTotpSecretBase32,
),
)
}
}
val sessionAuthenticator = koin.get<SessionAccessAuthenticator>()
installSessionAuthentication(sessionAuthenticator::authenticate)
val asrStreaming = if (appConfig.providers.volcengine.credentialsAvailable) {
val providerConfig = appConfig.providers.volcengine.toProviderConfig()
AsrStreamingService(
gateway = koin.get(),
upstream = KtorVolcengineAsrTransport(
client = koin.get(),
config = providerConfig,
credentialResolver = koin.get(),
),
scope = this,
)
} else {
null
}
if (appConfig.hintFeed.enabled) {
launch {
koin.get<HintFeedScheduler>().run()
}
}
launch {
while (isActive) {
try {
koin.get<AppleRevocationOutboxProcessor>().processPending()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Durable outbox state is retried; never log sensitive token material.
}
try {
koin.get<AnalyticsMaintenanceService>().purgeStaleAnonymousInstallations()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Anonymous analytics cleanup is bounded and retried on the next cycle.
}
try {
koin.get<GatewayReconciliationService>().reconcile()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Durable settlement state is retried without logging provider data.
}
if (appConfig.admin.enabled) {
try {
koin.get<AdminSessionService>().cleanupInactive()
} catch (exception: CancellationException) {
throw exception
} catch (_: Exception) {
// Expired sessions are retried in bounded batches on the next cycle.
}
}
delay(60_000)
}
}
monitor.subscribe(ApplicationStopped) {
koin.get<HttpClient>().close()
koin.get<DatabaseFactory>().close()
}
routing {
healthRoutes(koin.get())
rateLimit(AUTH_RATE_LIMIT) {
authRoutes(koin.get())
oobeRoutes(koin.get())
}
rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
service = koin.get(),
appIdentity = koin.get<GatewayIdentityPort>(),
gatewayIdentity = koin.get<GatewayAccessTokenPort>(),
grantService = koin.get(),
asrStreaming = asrStreaming,
)
}
rateLimit(PUBLIC_RATE_LIMIT) {
appleEventRoutes(koin.get())
analyticsRoutes(koin.get())
configureInviteWebRoutes(koin.get(), koin.get(), koin.get())
integrityRoutes(koin.get())
contentRoutes(koin.get())
}
if (appConfig.admin.enabled) {
adminWebRoutes(appConfig)
rateLimit(ADMIN_API_RATE_LIMIT) {
adminApiRoutes(
config = appConfig,
authService = koin.get(),
sessionService = koin.get(),
statsService = koin.get(),
productAnalyticsService = koin.get(),
usersService = koin.get(),
grantService = koin.get(),
operatorService = koin.get(),
auditService = koin.get(),
credentialService = koin.get(),
contentService = koin.get(),
hintFeedService = koin.get(),
)
}
}
}
}
fun accountServerModule(config: AppConfig): Module = module {
single { config }
single { DatabaseFactory(config.database) }
single { get<DatabaseFactory>().database }
single {
HttpClient(CIO) {
followRedirects = false
install(HttpTimeout) {
connectTimeoutMillis = 10_000
socketTimeoutMillis = 60_000
requestTimeoutMillis = 360_000
}
install(ClientContentNegotiation) {
json(JSON)
}
install(ClientWebSockets) {
maxFrameSize = 4L * 1024 * 1024
}
}
}
single { SessionJwt(config.session) }
single { FieldEncryptor(config.encryption.key) }
single { IdentityFingerprint(config.antiAbuse.identityHmacKey) }
single<GatewayCredentialRepository> { ExposedGatewayCredentialRepository(get()) }
single {
EnvironmentProviderCredentials(
deepSeekApiKey = config.providers.deepSeek.apiKey,
volcengineApiKey = config.providers.volcengine.apiKey,
volcengineLegacyConfigured =
!config.providers.volcengine.appId.isNullOrBlank() &&
!config.providers.volcengine.accessToken.isNullOrBlank(),
)
}
single { DatabaseProviderApiKeyResolver(get(), get(), get()) }
single<ProviderApiKeyResolver> { get<DatabaseProviderApiKeyResolver>() }
single { GatewayCredentialService(get(), get(), get()) }
single<AdminRepository> { ExposedAdminRepository(get()) }
single<AdminPasswordHasher> { BouncyCastleArgon2idPasswordHasher() }
single<AdminTotpVerifier> { HmacTotpVerifier() }
single {
val dummyPassword = "invalid-admin-password-constant-work".toCharArray()
try {
AdminAuthService(
repository = get(),
passwordHasher = get(),
dummyPasswordHash = get<AdminPasswordHasher>().hash(dummyPassword),
totpVerifier = get(),
fieldEncryptor = get(),
sessionTtl = Duration.ofHours(config.admin.sessionHours),
)
} finally {
dummyPassword.fill('\u0000')
}
}
single { AdminSessionService(get()) }
single { AdminBootstrapService(get(), get()) }
single { AdminOperatorService(get(), get(), get()) }
single { AdminAuditService(get()) }
single<AdminStatsRepository> { ExposedAdminStatsRepository(get()) }
single { AdminStatsService(get()) }
single<AdminProductAnalyticsRepository> { ExposedAdminProductAnalyticsRepository(get()) }
single { AdminProductAnalyticsService(get()) }
single<AdminUsersRepository> { ExposedAdminUsersRepository(get()) }
single { AdminUsersService(get()) }
single { AdminGrantService(get()) }
single<ContentRepository> { ExposedContentRepository(get()) }
single { ContentService(get()) }
single<HintFeedRepository> { ExposedHintFeedRepository(get()) }
single<HintFeedGenerationLock> { MysqlHintFeedGenerationLock(get()) }
single {
val client = get<HttpClient>()
HintFeedService(
repository = get(),
contentService = get(),
generationLock = get(),
sources = listOf(
BaselineHintSource(),
HolidayHintSource(client),
WeatherHintSource(client),
TopHubHintSource(client, config.hintFeed.topHubApiKey),
GoogleFeedHintSource(client),
),
config = config.hintFeed,
)
}
single { HintFeedScheduler(get()) }
single<AppleJwksProvider> {
RemoteAppleJwksProvider(get(), config.apple.jwksUrl)
}
single { AppleIdentityTokenVerifier(config.apple, get()) }
single<AppleTokenClient> { createAppleTokenClient(get(), config.apple) }
single<AppleDeviceCheckClient> {
createDeviceCheckClient(get(), config.apple, config.integrity.appleEnvironment)
?: UnavailableAppleDeviceCheckClient()
}
single<DeviceCheckVerifier> { RemoteDeviceCheckVerifier(get()) }
single<AppAttestRepository> { ExposedAppAttestRepository(get()) }
single<AppAttestCrypto> {
LibraryAppAttestCrypto(
config = config.integrity,
certificateValidator = BundledAppleAppAttestTrust.validator(),
)
}
single { AppAttestService(get(), get(), config.integrity) }
single<AppAttestVerifier> { get<AppAttestService>() }
single { IntegrityService(config.integrity, get(), get()) }
single<DeviceCheckTrialClaimRepository> { ExposedDeviceCheckTrialClaimRepository(get()) }
single { MysqlDeviceCheckTrialMutex(get()) }
single<AuthRepository> { ExposedAuthRepository(get()) }
single { SessionAccessAuthenticator(get(), get()) }
single<AccountRepository> { ExposedAccountRepository(get(), get()) }
single<AnalyticsRepository> { ExposedAnalyticsRepository(get()) }
single<AnalyticsService> { DefaultAnalyticsService(get()) }
single { AnalyticsMaintenanceService(get()) }
single<InviteOpenRecorder> {
InviteOpenRecorder {
get<AnalyticsRepository>().recordInvitePageOpen(Instant.now())
}
}
single<AppleEventRepository> { ExposedAppleEventRepository(get(), get(), config.antiAbuse) }
single { AppleEventVerifier(config.apple, get()) }
single { AppleEventService(get(), get()) }
single<BillingTransactionRunner> {
ExposedBillingTransactionRunner(get())
}
single {
CreditService(
transactions = get(),
referralRewards = ReferralRewardConfig(
inviterCredits = config.credits.referralInviter,
inviteeCredits = config.credits.referralInvitee,
),
)
}
single<CreditOperations> { get<CreditService>() }
single<StoreKitTransactionVerifier> {
if (config.storeKit.enabled) {
AppleStoreKitTransactionVerifier(
bundleId = config.storeKit.bundleId,
appAppleId = requireNotNull(config.storeKit.appAppleId),
)
} else {
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
}
}
single {
StoreKitService(
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
verifier = get(),
transactions = get(),
)
}
single<TrialCreditGranter> {
val creditService = get<CreditService>()
object : TrialCreditGranter {
override suspend fun grant(accountId: UUID) {
creditService.grantSignupTrial(
userId = accountId,
credits = config.credits.signupTrial,
idempotencyKey = signupTrialIdempotencyKey(accountId),
)
}
override suspend fun wasGranted(accountId: UUID): Boolean =
creditService.hasSignupTrial(accountId)
}
}
single {
DeviceCheckTrialService(
repository = get(),
client = get(),
creditGranter = get(),
policy = config.integrity.deviceCheckPolicy,
mutex = get<MysqlDeviceCheckTrialMutex>(),
)
}
single { ExposedGatewayRepository(get()) }
single<GatewayGrantRepository> { get<ExposedGatewayRepository>() }
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<ComplimentaryRequestPort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken, displayName ->
val trial = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (trial.shouldRestrictAccount) {
get<AuthRepository>().restrictAccountForAntiAbuse(
accountId,
java.time.Instant.now(),
)
}
get<AccountService>().seedDisplayName(accountId, displayName)
// Referral provisioning is intentionally handled by /v1/referrals/me
// after authentication so referral storage can never block sign-in.
}
}
single {
SessionService(
repository = get(),
appleIdentityVerifier = get(),
appleTokenClient = get(),
integrityService = get(),
sessionJwt = get(),
fieldEncryptor = get(),
identityFingerprint = get(),
sessionConfig = config.session,
accountProvisioner = get(),
)
}
single { AppleRevocationOutboxProcessor(get(), get(), get()) }
single<AccountReauthenticator> {
AppleAccountReauthenticator(
identityVerifier = get(),
appleTokenClient = get(),
identityFingerprint = get(),
)
}
single {
AccountService(
repository = get(),
fieldEncryptor = get(),
antiAbuseConfig = config.antiAbuse,
revocationProcessor = get(),
reauthenticator = get(),
)
}
single<AccountOperations> { get<AccountService>() }
single<UserRegistrationTimeProvider> {
UserRegistrationTimeProvider { accountId ->
get<AccountRepository>().findById(accountId)?.createdAt
}
}
single<ReferralRiskProvider> {
ReferralRiskProvider { accountId ->
get<AccountRepository>().findById(accountId)?.let {
ReferralRiskIdentity(it.identityFingerprint, it.antiAbuseRestricted)
}
}
}
single {
ReferralService(
transactions = get(),
registrationTimeProvider = get(),
riskProvider = get(),
bindingWindow = Duration.ofDays(config.credits.referralBindingDays),
)
}
single<ReferralOperations> { get<ReferralService>() }
single<ReferralLookupPort> {
val transactions = get<BillingTransactionRunner>()
ReferralLookupPort { code ->
transactions.inTransaction { unit ->
// Invitation codes are permanent account identifiers. Campaign
// availability is evaluated only when an invitee redeems one.
unit.referrals.findCode(code) != null
}
}
}
single { SessionIdentityAdapter(get()) }
single<AuthenticatedUserExtractor> { get<SessionIdentityAdapter>() }
single<GatewayIdentityPort> { get<SessionIdentityAdapter>() }
single {
GatewaySettings(
issuer = config.session.issuer,
audience = "${config.session.audience}-gateway",
accessTokenHmacSecret = deriveGatewaySecret(config.session.hmacSecret, "gateway-access"),
refreshTokenHmacSecret = deriveGatewaySecret(config.session.hmacSecret, "gateway-refresh"),
accessTokenLifetime = Duration.ofMinutes(5),
refreshTokenLifetime = Duration.ofDays(config.session.refreshDays),
maximumGrantLifetime = Duration.ofDays(config.session.gatewayGrantDays),
)
}
single<OobeRepository> { ExposedOobeRepository(get()) }
single {
OobeTokenSettings(
issuer = config.session.issuer,
audience = "${config.session.audience}-gateway",
accessTokenHmacSecret = deriveGatewaySecret(
config.session.hmacSecret,
"oobe-gateway-access",
),
refreshTokenHmacSecret = deriveGatewaySecret(
config.session.hmacSecret,
"oobe-gateway-refresh",
),
)
}
single { OobeGrantService(get(), get(), get()) }
single { GatewayGrantService(get(), get()) }
single<GatewayAccessTokenPort> {
val oobeGrants = get<OobeGrantService>()
GatewayBearerIdentity(get(), oobeGrants::authenticate)
}
single<CreditReservationPort> {
CreditReservationAdapter(
creditService = get(),
llmModel = config.providers.deepSeek.model,
asrModel = config.providers.volcengine.resourceId,
)
}
single {
ProviderCatalog(configuredProviders(config, get(), get()))
}
single { GatewayService(get(), get(), get(), get(), get(), get()) }
single { GatewayReconciliationService(get(), get()) }
single {
InviteWebConfig(
appStoreUrl = config.appStoreUrl,
appleAppId = "${config.integrity.appAttestTeamId}.${config.integrity.appAttestBundleId}",
universalLinkBaseUrl = config.inviteBaseUrl,
)
}
}
private fun configuredProviders(
config: AppConfig,
client: HttpClient,
credentialResolver: ProviderApiKeyResolver,
): List<GatewayProvider> =
buildList {
config.providers.deepSeek.apiKey?.let { apiKey ->
add(
DeepSeekProvider(
client,
DeepSeekConfig(
endpoint = config.providers.deepSeek.endpoint,
apiKey = apiKey,
model = config.providers.deepSeek.model,
reasoningModel = config.providers.deepSeek.reasoningModel,
),
credentialResolver = credentialResolver,
),
)
}
if (config.providers.volcengine.credentialsAvailable) {
val providerConfig = config.providers.volcengine.toProviderConfig()
add(
VolcengineAsrProvider(
KtorVolcengineAsrTransport(
client = client,
config = providerConfig,
credentialResolver = credentialResolver,
),
),
)
}
}
private fun com.osglab.account.config.VolcengineConfig.toProviderConfig() =
VolcengineAsrConfig(
endpoint = endpoint,
resourceId = resourceId,
appId = appId,
accessToken = accessToken,
apiKey = apiKey,
)
private fun deriveGatewaySecret(master: ByteArray, context: String): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(master, "HmacSHA256"))
doFinal("osg-account-server:$context".toByteArray(Charsets.UTF_8))
}
private val REQUEST_ID = Regex("[A-Za-z0-9_-]{8,64}")
private val AUTH_RATE_LIMIT = RateLimitName("auth")
private val ACCOUNT_RATE_LIMIT = RateLimitName("account")
private val GATEWAY_RATE_LIMIT = RateLimitName("gateway")
private val PUBLIC_RATE_LIMIT = RateLimitName("public")
private val ADMIN_AUTH_RATE_LIMIT = RateLimitName("admin-auth")
private val ADMIN_API_RATE_LIMIT = RateLimitName("admin-api")