checkpoint before checking out feature/account-managed-gateway
This commit is contained in:
@@ -28,8 +28,9 @@ 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.AccountRepository
|
||||
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
|
||||
@@ -103,12 +104,18 @@ import com.osglab.account.features.integrity.integrityRoutes
|
||||
import com.osglab.account.features.inviteweb.InviteWebConfig
|
||||
import com.osglab.account.features.inviteweb.ReferralLookupPort
|
||||
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
|
||||
import com.osglab.account.features.referrals.domain.ReferralException
|
||||
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
|
||||
@@ -288,6 +295,7 @@ fun Application.module() {
|
||||
accountRoutes(koin.get())
|
||||
creditRoutes(koin.get(), koin.get())
|
||||
referralRoutes(koin.get(), koin.get())
|
||||
storeKitRoutes(koin.get(), koin.get())
|
||||
}
|
||||
rateLimit(GATEWAY_RATE_LIMIT) {
|
||||
configureGatewayRoutes(
|
||||
@@ -431,6 +439,23 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
)
|
||||
}
|
||||
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> {
|
||||
TrialCreditGranter { accountId ->
|
||||
get<CreditService>().grantSignupTrial(
|
||||
@@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
|
||||
single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
|
||||
single<AccountProvisioner> {
|
||||
AccountProvisioner { accountId, deviceCheckToken ->
|
||||
AccountProvisioner { accountId, deviceCheckToken, displayName ->
|
||||
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
|
||||
if (deviceCheckToken != null && !granted) {
|
||||
get<AuthRepository>().restrictAccountForAntiAbuse(
|
||||
@@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
java.time.Instant.now(),
|
||||
)
|
||||
}
|
||||
get<AccountService>().seedDisplayName(accountId, displayName)
|
||||
try {
|
||||
get<ReferralOperations>().getOrCreateCode(accountId)
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
} catch (_: ReferralException) {
|
||||
// Referral eligibility must not make account sign-in unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
single {
|
||||
@@ -494,6 +527,7 @@ fun accountServerModule(config: AppConfig): Module = module {
|
||||
reauthenticator = get(),
|
||||
)
|
||||
}
|
||||
single<AccountOperations> { get<AccountService>() }
|
||||
|
||||
single<UserRegistrationTimeProvider> {
|
||||
UserRegistrationTimeProvider { accountId ->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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.util.Base64
|
||||
@@ -16,6 +17,7 @@ data class AppConfig(
|
||||
val antiAbuse: AntiAbuseConfig,
|
||||
val apple: AppleConfig,
|
||||
val credits: CreditsConfig,
|
||||
val storeKit: StoreKitConfig = StoreKitConfig(),
|
||||
val providers: ProvidersConfig,
|
||||
val integrity: IntegrityConfig,
|
||||
val admin: AdminConfig = AdminConfig(),
|
||||
@@ -76,10 +78,32 @@ data class AppConfig(
|
||||
)
|
||||
val credits = CreditsConfig(
|
||||
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
|
||||
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000),
|
||||
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_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 providers = ProvidersConfig(
|
||||
volcengine = VolcengineConfig(
|
||||
endpoint = config.valueOrDefault(
|
||||
@@ -294,6 +318,7 @@ data class AppConfig(
|
||||
antiAbuse = antiAbuse,
|
||||
apple = apple,
|
||||
credits = credits,
|
||||
storeKit = storeKit,
|
||||
providers = providers,
|
||||
integrity = integrity,
|
||||
admin = admin,
|
||||
@@ -359,6 +384,13 @@ data class CreditsConfig(
|
||||
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,
|
||||
@@ -492,6 +524,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
|
||||
?: 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()) {
|
||||
|
||||
@@ -26,11 +26,13 @@ data class AccountRecord(
|
||||
val identityFingerprint: String,
|
||||
val antiAbuseRestricted: Boolean,
|
||||
val encryptedAppleRefreshToken: String?,
|
||||
val encryptedDisplayName: String? = null,
|
||||
val createdAt: Instant,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
|
||||
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
|
||||
"encryptedDisplayName=[REDACTED], " +
|
||||
"createdAt=$createdAt)"
|
||||
}
|
||||
|
||||
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private object AccountProfilesTable : Table("account_profiles") {
|
||||
val accountId = varchar("account_id", 36)
|
||||
val encryptedDisplayName = text("encrypted_display_name")
|
||||
val createdAt = timestamp("created_at")
|
||||
val updatedAt = timestamp("updated_at")
|
||||
override val primaryKey = PrimaryKey(accountId)
|
||||
}
|
||||
|
||||
interface AccountRepository {
|
||||
suspend fun findById(accountId: UUID): AccountRecord?
|
||||
suspend fun seedDisplayNameIfAbsent(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||
suspend fun updateDisplayName(accountId: UUID, encryptedDisplayName: String, now: Instant)
|
||||
suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
|
||||
.where { AppleCredentialsTable.accountId eq accountId.toString() }
|
||||
.singleOrNull()
|
||||
?.get(AppleCredentialsTable.encryptedRefreshToken)
|
||||
val encryptedDisplayName = AccountProfilesTable.selectAll()
|
||||
.where { AccountProfilesTable.accountId eq accountId.toString() }
|
||||
.singleOrNull()
|
||||
?.get(AccountProfilesTable.encryptedDisplayName)
|
||||
row.let {
|
||||
AccountRecord(
|
||||
id = UUID.fromString(it[AccountsTable.id]),
|
||||
identityFingerprint = fingerprint,
|
||||
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
|
||||
encryptedAppleRefreshToken = encryptedRefreshToken,
|
||||
encryptedDisplayName = encryptedDisplayName,
|
||||
createdAt = it[AccountsTable.createdAt],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun seedDisplayNameIfAbsent(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
AccountProfilesTable.insertIgnore {
|
||||
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
databaseFactory.query {
|
||||
AccountProfilesTable.insertIgnore {
|
||||
it[AccountProfilesTable.accountId] = accountId.toString()
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
AccountProfilesTable.update({
|
||||
AccountProfilesTable.accountId eq accountId.toString()
|
||||
}) {
|
||||
it[AccountProfilesTable.encryptedDisplayName] = encryptedDisplayName
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
|
||||
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.patch
|
||||
import io.ktor.server.routing.route
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
|
||||
data class AccountResponse(
|
||||
val id: String,
|
||||
val createdAtEpochSeconds: Long,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateAccountProfileRequest(val displayName: String)
|
||||
|
||||
@Serializable
|
||||
data class DeleteAccountRequest(
|
||||
val identityToken: String,
|
||||
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
|
||||
}
|
||||
|
||||
class AccountRoutes(
|
||||
private val accountService: AccountService,
|
||||
private val accountService: AccountOperations,
|
||||
) {
|
||||
fun register(parent: Route) {
|
||||
with(parent) {
|
||||
@@ -49,14 +54,17 @@ class AccountRoutes(
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
?: throw UnauthorizedException()
|
||||
val account = accountService.get(principal.userId)
|
||||
call.respond(
|
||||
ApiResponse(
|
||||
data = AccountResponse(
|
||||
id = account.id.toString(),
|
||||
createdAtEpochSeconds = account.createdAt.epochSecond,
|
||||
),
|
||||
),
|
||||
call.respond(ApiResponse(data = account.toResponse()))
|
||||
}
|
||||
patch {
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
?: throw UnauthorizedException()
|
||||
val request = call.receive<UpdateAccountProfileRequest>()
|
||||
val account = accountService.updateDisplayName(
|
||||
principal.userId,
|
||||
request.displayName,
|
||||
)
|
||||
call.respond(ApiResponse(data = account.toResponse()))
|
||||
}
|
||||
delete {
|
||||
val principal = call.principal<AccountPrincipal>()
|
||||
@@ -73,5 +81,11 @@ class AccountRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
fun Route.accountRoutes(accountService: AccountService) =
|
||||
fun Route.accountRoutes(accountService: AccountOperations) =
|
||||
AccountRoutes(accountService).register(this)
|
||||
|
||||
private fun AccountView.toResponse() = AccountResponse(
|
||||
id = id.toString(),
|
||||
createdAtEpochSeconds = createdAt.epochSecond,
|
||||
displayName = displayName,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.osglab.account.features.account
|
||||
|
||||
import com.osglab.account.common.errors.ExternalServiceUnavailableException
|
||||
import com.osglab.account.common.errors.InvalidRequestException
|
||||
import com.osglab.account.common.errors.UnauthorizedException
|
||||
import com.osglab.account.common.security.FieldDecryptionException
|
||||
import com.osglab.account.common.security.FieldEncryptor
|
||||
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.text.Normalizer
|
||||
import java.util.UUID
|
||||
|
||||
data class AccountView(
|
||||
val id: UUID,
|
||||
val createdAt: Instant,
|
||||
val displayName: String?,
|
||||
)
|
||||
|
||||
data class AppleReauthenticationProof(
|
||||
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
|
||||
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String
|
||||
}
|
||||
|
||||
interface AccountOperations {
|
||||
suspend fun get(accountId: UUID): AccountView
|
||||
suspend fun seedDisplayName(accountId: UUID, candidate: String?)
|
||||
suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView
|
||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof)
|
||||
}
|
||||
|
||||
class AppleAccountReauthenticator(
|
||||
private val identityVerifier: AppleIdentityTokenVerifier,
|
||||
private val appleTokenClient: AppleTokenClient,
|
||||
@@ -94,13 +104,34 @@ class AccountService(
|
||||
private val revocationProcessor: AppleRevocationOutboxProcessor,
|
||||
private val reauthenticator: AccountReauthenticator,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
suspend fun get(accountId: UUID): AccountView {
|
||||
) : AccountOperations {
|
||||
override suspend fun get(accountId: UUID): AccountView {
|
||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||
return AccountView(account.id, account.createdAt)
|
||||
return account.toView()
|
||||
}
|
||||
|
||||
suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) {
|
||||
val displayName = candidate?.let(::normalizedDisplayNameOrNull) ?: return
|
||||
val account = repository.findById(accountId) ?: return
|
||||
repository.seedDisplayNameIfAbsent(
|
||||
accountId,
|
||||
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||
clock.instant(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
|
||||
val account = repository.findById(accountId) ?: throw UnauthorizedException()
|
||||
val displayName = normalizedDisplayName(candidate)
|
||||
repository.updateDisplayName(
|
||||
accountId,
|
||||
fieldEncryptor.encrypt(displayName, accountProfileContext(account.id)),
|
||||
clock.instant(),
|
||||
)
|
||||
return requireNotNull(repository.findById(accountId)).toView()
|
||||
}
|
||||
|
||||
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
val account = repository.findById(accountId) ?: return
|
||||
val now = clock.instant()
|
||||
val currentRefreshToken = reauthenticator.verify(account, proof)
|
||||
@@ -126,6 +157,14 @@ class AccountService(
|
||||
// Local deletion is final. The durable outbox retry loop handles Apple outages.
|
||||
}
|
||||
}
|
||||
|
||||
private fun AccountRecord.toView(): AccountView = AccountView(
|
||||
id = id,
|
||||
createdAt = createdAt,
|
||||
displayName = encryptedDisplayName?.let {
|
||||
fieldEncryptor.decrypt(it, accountProfileContext(id))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
class AppleRevocationOutboxProcessor(
|
||||
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
|
||||
}
|
||||
|
||||
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
|
||||
|
||||
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
|
||||
|
||||
private fun normalizedDisplayNameOrNull(candidate: String): String? =
|
||||
runCatching { normalizedDisplayName(candidate) }.getOrNull()
|
||||
|
||||
private fun normalizedDisplayName(candidate: String): String {
|
||||
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
|
||||
.replace(WHITESPACE_REGEX, " ")
|
||||
if (
|
||||
normalized.isBlank() ||
|
||||
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
|
||||
normalized.any { it.isISOControl() }
|
||||
) {
|
||||
throw InvalidRequestException("Display name is invalid")
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private val WHITESPACE_REGEX = Regex("\\s+")
|
||||
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
|
||||
|
||||
@@ -21,12 +21,14 @@ data class AppleSignInRequest(
|
||||
val identityToken: String,
|
||||
val authorizationCode: String,
|
||||
val nonce: String,
|
||||
val displayName: String? = null,
|
||||
val deviceCheckToken: String? = null,
|
||||
val appAttest: AppAttestRequest? = null,
|
||||
) {
|
||||
override fun toString(): String =
|
||||
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
|
||||
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])"
|
||||
"nonce=[REDACTED], displayName=[REDACTED], deviceCheckToken=[REDACTED], " +
|
||||
"appAttest=[REDACTED])"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -73,6 +75,7 @@ class AuthRoutes(
|
||||
identityToken = request.identityToken,
|
||||
authorizationCode = request.authorizationCode,
|
||||
nonce = request.nonce,
|
||||
displayName = request.displayName,
|
||||
integrityEvidence = IntegrityEvidence(
|
||||
deviceCheckToken = request.deviceCheckToken,
|
||||
appAttest = request.appAttest?.let {
|
||||
|
||||
@@ -34,7 +34,7 @@ data class SessionTokens(
|
||||
}
|
||||
|
||||
fun interface AccountProvisioner {
|
||||
suspend fun provision(accountId: UUID, deviceCheckToken: String?)
|
||||
suspend fun provision(accountId: UUID, deviceCheckToken: String?, displayName: String?)
|
||||
}
|
||||
|
||||
class SessionService(
|
||||
@@ -46,7 +46,7 @@ class SessionService(
|
||||
private val fieldEncryptor: FieldEncryptor,
|
||||
private val identityFingerprint: IdentityFingerprint,
|
||||
private val sessionConfig: SessionConfig,
|
||||
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> },
|
||||
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _, _ -> },
|
||||
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
) {
|
||||
@@ -55,6 +55,7 @@ class SessionService(
|
||||
authorizationCode: String,
|
||||
nonce: String,
|
||||
integrityEvidence: IntegrityEvidence,
|
||||
displayName: String? = null,
|
||||
): SessionTokens {
|
||||
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
|
||||
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
|
||||
@@ -90,6 +91,7 @@ class SessionService(
|
||||
accountProvisioner.provision(
|
||||
account.id,
|
||||
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
|
||||
displayName,
|
||||
)
|
||||
return createSession(account.id, now)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ data class CreditAccount(
|
||||
val updatedAt: Instant,
|
||||
)
|
||||
|
||||
data class CreditAccountSummary(
|
||||
val account: CreditAccount,
|
||||
val lifetimeUsed: Long,
|
||||
)
|
||||
|
||||
data class LedgerEntry(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.osglab.account.features.credits.models
|
||||
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditAccountSummary
|
||||
import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
import com.osglab.account.features.credits.domain.CreditReservation
|
||||
import com.osglab.account.features.credits.domain.InvalidCreditRequest
|
||||
@@ -64,13 +64,15 @@ data class SettleCreditsRequest(
|
||||
data class CreditAccountDto(
|
||||
val userId: String,
|
||||
val balance: Long,
|
||||
val lifetimeUsed: Long,
|
||||
val updatedAt: String,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(account: CreditAccount) = CreditAccountDto(
|
||||
userId = account.userId.toString(),
|
||||
balance = account.balance,
|
||||
updatedAt = account.updatedAt.toString(),
|
||||
fun fromDomain(summary: CreditAccountSummary) = CreditAccountDto(
|
||||
userId = summary.account.userId.toString(),
|
||||
balance = summary.account.balance,
|
||||
lifetimeUsed = summary.lifetimeUsed,
|
||||
updatedAt = summary.account.updatedAt.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditUsageRecord
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.UsageKind
|
||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@@ -28,6 +29,8 @@ interface CreditsRepository {
|
||||
|
||||
fun insertUsageRecord(record: CreditUsageRecord)
|
||||
|
||||
fun lifetimeUsedCredits(userId: UUID): Long
|
||||
|
||||
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
|
||||
|
||||
fun lockReservation(id: UUID): CreditReservation?
|
||||
@@ -52,6 +55,7 @@ interface BillingUnitOfWork {
|
||||
val credits: CreditsRepository
|
||||
val referrals: ReferralsRepository
|
||||
val adminCreditGrants: AdminCreditGrantRepository
|
||||
val storeKit: StoreKitRepository
|
||||
}
|
||||
|
||||
interface BillingTransactionRunner {
|
||||
|
||||
+23
@@ -19,6 +19,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
|
||||
import com.osglab.account.features.referrals.domain.ReferralCode
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||
import com.osglab.account.features.storekit.repositories.ExposedStoreKitRepository
|
||||
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
@@ -27,6 +29,7 @@ import org.jetbrains.exposed.v1.jdbc.Database
|
||||
import org.jetbrains.exposed.v1.jdbc.andWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.select
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
@@ -184,6 +187,7 @@ private object ExposedBillingUnitOfWork : BillingUnitOfWork {
|
||||
override val credits: CreditsRepository = ExposedCreditsRepository
|
||||
override val referrals: ReferralsRepository = ExposedReferralsRepository
|
||||
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
|
||||
override val storeKit: StoreKitRepository = ExposedStoreKitRepository
|
||||
}
|
||||
|
||||
private object ExposedCreditsRepository : CreditsRepository {
|
||||
@@ -282,6 +286,25 @@ private object ExposedCreditsRepository : CreditsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
override fun lifetimeUsedCredits(userId: UUID): Long {
|
||||
val chargedTotal = CreditUsageRecords.chargedCredits.sum()
|
||||
val charged = CreditUsageRecords
|
||||
.select(chargedTotal)
|
||||
.where { CreditUsageRecords.userId eq userId.toString() }
|
||||
.single()[chargedTotal] ?: 0
|
||||
val refundTotal = CreditLedger.amountDelta.sum()
|
||||
val refunded = CreditLedger
|
||||
.select(refundTotal)
|
||||
.where {
|
||||
(CreditLedger.userId eq userId.toString()) and
|
||||
(CreditLedger.entryType eq LedgerEntryType.USAGE_REFUND)
|
||||
}
|
||||
.single()[refundTotal] ?: 0
|
||||
return Math.subtractExact(charged, refunded).also {
|
||||
check(it >= 0) { "Refunded credits exceed settled usage" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun findReservationByReserveKey(
|
||||
userId: UUID,
|
||||
idempotencyKey: String,
|
||||
|
||||
@@ -36,7 +36,7 @@ class CreditRouteInstaller(
|
||||
parent.route("/v1/credits") {
|
||||
get("/balance") {
|
||||
call.creditCall(authenticatedUser) { userId ->
|
||||
CreditAccountDto.fromDomain(service.getAccount(userId))
|
||||
CreditAccountDto.fromDomain(service.getAccountSummary(userId))
|
||||
}
|
||||
}
|
||||
get("/ledger") {
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.osglab.account.features.admin.models.AdminAuditAction
|
||||
import com.osglab.account.features.admin.models.AdminAuditOutcome
|
||||
import com.osglab.account.features.admin.models.NewAdminAuditEvent
|
||||
import com.osglab.account.features.credits.domain.CreditAccount
|
||||
import com.osglab.account.features.credits.domain.CreditAccountSummary
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.CreditCostCalculator
|
||||
import com.osglab.account.features.credits.domain.CreditNotFound
|
||||
@@ -46,6 +47,8 @@ data class ReferralRewardConfig(
|
||||
interface CreditOperations {
|
||||
suspend fun getAccount(userId: UUID): CreditAccount
|
||||
|
||||
suspend fun getAccountSummary(userId: UUID): CreditAccountSummary
|
||||
|
||||
suspend fun listEffectiveRates(): List<CreditRateVersion>
|
||||
|
||||
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
|
||||
@@ -107,6 +110,15 @@ class CreditService(
|
||||
unit.credits.lockAccount(userId)
|
||||
}
|
||||
|
||||
override suspend fun getAccountSummary(userId: UUID): CreditAccountSummary =
|
||||
transactions.inTransaction { unit ->
|
||||
unit.credits.createAccountIfAbsent(userId, clock.instant())
|
||||
CreditAccountSummary(
|
||||
account = unit.credits.lockAccount(userId),
|
||||
lifetimeUsed = unit.credits.lifetimeUsedCredits(userId),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun listEffectiveRates(): List<CreditRateVersion> =
|
||||
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.time.Clock
|
||||
@@ -283,10 +284,13 @@ class AppAttestService(
|
||||
)
|
||||
IntegrityVerification.Verified
|
||||
} catch (exception: AppAttestRejectedException) {
|
||||
APP_ATTEST_LOG.warn("App Attest assertion rejected: {}", exception.message)
|
||||
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
|
||||
} catch (exception: InvalidRequestException) {
|
||||
APP_ATTEST_LOG.warn("App Attest assertion request rejected: {}", exception.message)
|
||||
IntegrityVerification.Rejected(exception.message)
|
||||
} catch (exception: AppAttestUnavailableException) {
|
||||
APP_ATTEST_LOG.error("App Attest assertion verification unavailable", exception)
|
||||
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
|
||||
} catch (exception: CancellationException) {
|
||||
throw exception
|
||||
@@ -392,6 +396,7 @@ class AppAttestService(
|
||||
const val SHA256_BYTES = 32
|
||||
const val MAX_ATTESTATION_BYTES = 256 * 1024
|
||||
const val MAX_ASSERTION_BYTES = 64 * 1024
|
||||
val APP_ATTEST_LOG = LoggerFactory.getLogger(AppAttestService::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,11 +251,11 @@ class LibraryAppAttestCrypto(
|
||||
} catch (exception: Exception) {
|
||||
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
|
||||
}
|
||||
val signedBytes = authenticatorDataBytes + clientDataHash
|
||||
val nonce = sha256(authenticatorDataBytes + clientDataHash)
|
||||
val verified = try {
|
||||
Signature.getInstance("SHA256withECDSA").run {
|
||||
initVerify(key)
|
||||
update(signedBytes)
|
||||
update(nonce)
|
||||
verify(signatureBytes)
|
||||
}
|
||||
} catch (exception: Exception) {
|
||||
@@ -299,9 +299,10 @@ class LibraryAppAttestCrypto(
|
||||
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
|
||||
val flags = buffer.get().toInt() and 0xff
|
||||
val count = buffer.int.toLong() and UINT32_MASK
|
||||
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 ||
|
||||
(flags and FLAG_EXTENSION_DATA) != 0
|
||||
) {
|
||||
// Production App Attest assertions can set AT while still using
|
||||
// Apple's fixed 37-byte assertion profile. Exact-length validation
|
||||
// above ensures no attested credential bytes are appended.
|
||||
if ((flags and FLAG_EXTENSION_DATA) != 0) {
|
||||
throw AppAttestRejectedException("App Attest assertion flags are invalid")
|
||||
}
|
||||
return AssertionAuthenticatorData(rpHash, flags, count)
|
||||
|
||||
@@ -32,6 +32,7 @@ import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insertIgnore
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.security.KeyFactory
|
||||
import java.security.interfaces.ECPrivateKey
|
||||
import java.security.spec.PKCS8EncodedKeySpec
|
||||
@@ -233,7 +234,7 @@ class KtorAppleDeviceCheckClient(
|
||||
|
||||
private companion object {
|
||||
const val MAX_DEVICE_TOKEN_LENGTH = 8_192
|
||||
const val DEFAULT_TIMEOUT_MILLIS = 5_000L
|
||||
const val DEFAULT_TIMEOUT_MILLIS = 15_000L
|
||||
const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
|
||||
val JSON = Json { ignoreUnknownKeys = true }
|
||||
}
|
||||
@@ -263,8 +264,13 @@ class RemoteDeviceCheckVerifier(
|
||||
} catch (exception: DeviceCheckRejectedException) {
|
||||
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
|
||||
} catch (exception: DeviceCheckUnavailableException) {
|
||||
LOG.warn("DeviceCheck verification unavailable: {}", exception.message)
|
||||
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val LOG = LoggerFactory.getLogger(RemoteDeviceCheckVerifier::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
open class DeviceCheckException(message: String, cause: Throwable? = null) :
|
||||
|
||||
@@ -177,13 +177,15 @@ class ReferralService(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getProfile(userId: UUID): ReferralProfile =
|
||||
transactions.inTransaction { unit ->
|
||||
override suspend fun getProfile(userId: UUID): ReferralProfile {
|
||||
val activeCode = getOrCreateCode(userId)
|
||||
return transactions.inTransaction { unit ->
|
||||
ReferralProfile(
|
||||
code = unit.referrals.findCodeByOwner(userId),
|
||||
code = activeCode,
|
||||
binding = unit.referrals.findBinding(userId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
|
||||
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.osglab.account.features.storekit.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class StoreKitEnvironment {
|
||||
SANDBOX,
|
||||
PRODUCTION,
|
||||
}
|
||||
|
||||
data class StoreKitProduct(
|
||||
val productId: String,
|
||||
val credits: Long,
|
||||
) {
|
||||
init {
|
||||
require(PRODUCT_ID.matches(productId)) { "StoreKit product ID is invalid" }
|
||||
require(credits > 0) { "StoreKit product credits must be positive" }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PRODUCT_ID = Regex("[A-Za-z0-9._-]{3,128}")
|
||||
}
|
||||
}
|
||||
|
||||
data class VerifiedStoreKitTransaction(
|
||||
val transactionId: String,
|
||||
val originalTransactionId: String,
|
||||
val appAccountToken: UUID,
|
||||
val productId: String,
|
||||
val environment: StoreKitEnvironment,
|
||||
val purchasedAt: Instant,
|
||||
val signedAt: Instant,
|
||||
val revokedAt: Instant?,
|
||||
)
|
||||
|
||||
data class StoreKitCreditPurchase(
|
||||
val id: UUID,
|
||||
val transactionId: String,
|
||||
val originalTransactionId: String,
|
||||
val userId: UUID,
|
||||
val appAccountToken: UUID,
|
||||
val productId: String,
|
||||
val environment: StoreKitEnvironment,
|
||||
val creditsGranted: Long,
|
||||
val ledgerEntryId: UUID,
|
||||
val signedTransactionSha256: String,
|
||||
val purchasedAt: Instant,
|
||||
val signedAt: Instant,
|
||||
val createdAt: Instant,
|
||||
)
|
||||
|
||||
data class StoreKitPurchaseResult(
|
||||
val purchase: StoreKitCreditPurchase,
|
||||
val balanceAfter: Long,
|
||||
val replayed: Boolean,
|
||||
)
|
||||
|
||||
sealed class StoreKitException(message: String) : RuntimeException(message)
|
||||
|
||||
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
|
||||
|
||||
class InvalidStoreKitRequest(message: String) : StoreKitException(message)
|
||||
|
||||
class StoreKitVerificationFailed : StoreKitException("The App Store transaction could not be verified")
|
||||
|
||||
class StoreKitPurchaseConflict : StoreKitException("The App Store transaction conflicts with an existing purchase")
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.osglab.account.features.storekit.models
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class StoreKitProductDto(
|
||||
val productId: String,
|
||||
val credits: Long,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(product: StoreKitProduct): StoreKitProductDto =
|
||||
StoreKitProductDto(product.productId, product.credits)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StoreKitSubmitRequest(
|
||||
val signedTransaction: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StoreKitPurchaseResponse(
|
||||
val transactionId: String,
|
||||
val productId: String,
|
||||
val creditsGranted: Long,
|
||||
val balanceAfter: Long,
|
||||
val replayed: Boolean,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(result: StoreKitPurchaseResult): StoreKitPurchaseResponse =
|
||||
StoreKitPurchaseResponse(
|
||||
transactionId = result.purchase.transactionId,
|
||||
productId = result.purchase.productId,
|
||||
creditsGranted = result.purchase.creditsGranted,
|
||||
balanceAfter = result.balanceAfter,
|
||||
replayed = result.replayed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.osglab.account.features.storekit.repositories
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import java.util.UUID
|
||||
import org.jetbrains.exposed.v1.core.*
|
||||
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||
import org.jetbrains.exposed.v1.jdbc.insert
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
|
||||
interface StoreKitRepository {
|
||||
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
|
||||
|
||||
fun insert(purchase: StoreKitCreditPurchase)
|
||||
}
|
||||
|
||||
object ExposedStoreKitRepository : StoreKitRepository {
|
||||
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
|
||||
StoreKitCreditPurchases
|
||||
.selectAll()
|
||||
.where { StoreKitCreditPurchases.transactionId eq transactionId }
|
||||
.singleOrNull()
|
||||
?.toStoreKitCreditPurchase()
|
||||
|
||||
override fun insert(purchase: StoreKitCreditPurchase) {
|
||||
StoreKitCreditPurchases.insert {
|
||||
it[id] = purchase.id.toString()
|
||||
it[transactionId] = purchase.transactionId
|
||||
it[originalTransactionId] = purchase.originalTransactionId
|
||||
it[userId] = purchase.userId.toString()
|
||||
it[appAccountToken] = purchase.appAccountToken.toString()
|
||||
it[productId] = purchase.productId
|
||||
it[environment] = purchase.environment
|
||||
it[creditsGranted] = purchase.creditsGranted
|
||||
it[ledgerEntryId] = purchase.ledgerEntryId.toString()
|
||||
it[signedTransactionSha256] = purchase.signedTransactionSha256
|
||||
it[purchasedAt] = purchase.purchasedAt
|
||||
it[signedAt] = purchase.signedAt
|
||||
it[createdAt] = purchase.createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
|
||||
val id = varchar("id", 36)
|
||||
val transactionId = varchar("transaction_id", 64)
|
||||
val originalTransactionId = varchar("original_transaction_id", 64)
|
||||
val userId = varchar("user_id", 36)
|
||||
val appAccountToken = varchar("app_account_token", 36)
|
||||
val productId = varchar("product_id", 128)
|
||||
val environment = enumerationByName<StoreKitEnvironment>("environment", 16)
|
||||
val creditsGranted = long("credits_granted")
|
||||
val ledgerEntryId = varchar("ledger_entry_id", 36)
|
||||
val signedTransactionSha256 = char("signed_transaction_sha256", 64)
|
||||
val purchasedAt = timestamp("purchased_at")
|
||||
val signedAt = timestamp("signed_at")
|
||||
val createdAt = timestamp("created_at")
|
||||
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
}
|
||||
|
||||
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
|
||||
StoreKitCreditPurchase(
|
||||
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
|
||||
transactionId = this[StoreKitCreditPurchases.transactionId],
|
||||
originalTransactionId = this[StoreKitCreditPurchases.originalTransactionId],
|
||||
userId = UUID.fromString(this[StoreKitCreditPurchases.userId]),
|
||||
appAccountToken = UUID.fromString(this[StoreKitCreditPurchases.appAccountToken]),
|
||||
productId = this[StoreKitCreditPurchases.productId],
|
||||
environment = this[StoreKitCreditPurchases.environment],
|
||||
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
|
||||
ledgerEntryId = UUID.fromString(this[StoreKitCreditPurchases.ledgerEntryId]),
|
||||
signedTransactionSha256 = this[StoreKitCreditPurchases.signedTransactionSha256],
|
||||
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
|
||||
signedAt = this[StoreKitCreditPurchases.signedAt],
|
||||
createdAt = this[StoreKitCreditPurchases.createdAt],
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.osglab.account.features.storekit.routes
|
||||
|
||||
import com.osglab.account.common.api.ApiError
|
||||
import com.osglab.account.common.api.ApiErrorResponse
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
|
||||
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||
import com.osglab.account.features.storekit.models.StoreKitProductDto
|
||||
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
|
||||
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
|
||||
fun Route.storeKitRoutes(
|
||||
service: StoreKitService,
|
||||
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
|
||||
) {
|
||||
route("/v1/storekit") {
|
||||
get("/products") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@get
|
||||
}
|
||||
call.respond(service.products().map(StoreKitProductDto::fromDomain))
|
||||
}
|
||||
post("/transactions") {
|
||||
val userId = authenticatedUser.extract(call)
|
||||
if (userId == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.Unauthorized,
|
||||
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
val request = call.receive<StoreKitSubmitRequest>()
|
||||
try {
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
StoreKitPurchaseResponse.fromDomain(
|
||||
service.submit(userId, request.signedTransaction)
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitUnavailable) {
|
||||
call.respond(
|
||||
HttpStatusCode.ServiceUnavailable,
|
||||
ApiErrorResponse(
|
||||
ApiError("external_service_unavailable", "Credit purchases are unavailable")
|
||||
),
|
||||
)
|
||||
} catch (_: InvalidStoreKitRequest) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
|
||||
)
|
||||
} catch (_: StoreKitVerificationFailed) {
|
||||
call.respond(
|
||||
HttpStatusCode.UnprocessableEntity,
|
||||
ApiErrorResponse(
|
||||
ApiError("transaction_invalid", "The App Store transaction is invalid")
|
||||
),
|
||||
)
|
||||
} catch (_: StoreKitPurchaseConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
} catch (_: CreditConflict) {
|
||||
call.respond(
|
||||
HttpStatusCode.Conflict,
|
||||
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.osglab.account.features.storekit.services
|
||||
|
||||
import com.osglab.account.features.credits.domain.CreditConflict
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
|
||||
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import java.security.MessageDigest
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class StoreKitService(
|
||||
products: List<StoreKitProduct>,
|
||||
private val verifier: StoreKitTransactionVerifier,
|
||||
private val transactions: BillingTransactionRunner,
|
||||
private val clock: Clock = Clock.systemUTC(),
|
||||
private val newId: () -> UUID = UUID::randomUUID,
|
||||
) {
|
||||
private val productsById = products.associateBy(StoreKitProduct::productId)
|
||||
|
||||
init {
|
||||
require(productsById.size == products.size) { "StoreKit product IDs must be unique" }
|
||||
}
|
||||
|
||||
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
|
||||
|
||||
suspend fun submit(
|
||||
userId: UUID,
|
||||
signedTransaction: String,
|
||||
): StoreKitPurchaseResult {
|
||||
if (productsById.isEmpty()) {
|
||||
throw StoreKitUnavailable()
|
||||
}
|
||||
if (
|
||||
signedTransaction.length !in MIN_SIGNED_TRANSACTION_LENGTH..MAX_SIGNED_TRANSACTION_LENGTH ||
|
||||
signedTransaction != signedTransaction.trim()
|
||||
) {
|
||||
throw InvalidStoreKitRequest("signedTransaction is invalid")
|
||||
}
|
||||
val verified = verifier.verify(signedTransaction)
|
||||
val product = productsById[verified.productId] ?: throw StoreKitPurchaseConflict()
|
||||
validateTransaction(userId, verified)
|
||||
val digest = signedTransaction.sha256Hex()
|
||||
val idempotencyKey = "storekit:${verified.transactionId}"
|
||||
|
||||
return transactions.inTransaction { unit ->
|
||||
val now = clock.instant()
|
||||
unit.credits.createAccountIfAbsent(userId, now)
|
||||
val account = unit.credits.lockAccount(userId)
|
||||
unit.storeKit.findByTransactionId(verified.transactionId)?.let { existing ->
|
||||
requireReplayMatches(existing, verified, product)
|
||||
val ledger = unit.credits.findLedgerEntry(userId, idempotencyKey)
|
||||
?: throw StoreKitPurchaseConflict()
|
||||
if (
|
||||
ledger.id != existing.ledgerEntryId ||
|
||||
ledger.type != LedgerEntryType.STOREKIT_PURCHASE ||
|
||||
ledger.amountDelta != existing.creditsGranted ||
|
||||
ledger.referenceId != existing.id
|
||||
) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
return@inTransaction StoreKitPurchaseResult(existing, ledger.balanceAfter, replayed = true)
|
||||
}
|
||||
if (unit.credits.findLedgerEntry(userId, idempotencyKey) != null) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
|
||||
val balanceAfter = try {
|
||||
Math.addExact(account.balance, product.credits)
|
||||
} catch (_: ArithmeticException) {
|
||||
throw CreditConflict("StoreKit credit balance overflow")
|
||||
}
|
||||
val purchaseId = newId()
|
||||
val ledgerEntryId = newId()
|
||||
val purchase = StoreKitCreditPurchase(
|
||||
id = purchaseId,
|
||||
transactionId = verified.transactionId,
|
||||
originalTransactionId = verified.originalTransactionId,
|
||||
userId = userId,
|
||||
appAccountToken = verified.appAccountToken,
|
||||
productId = product.productId,
|
||||
environment = verified.environment,
|
||||
creditsGranted = product.credits,
|
||||
ledgerEntryId = ledgerEntryId,
|
||||
signedTransactionSha256 = digest,
|
||||
purchasedAt = verified.purchasedAt,
|
||||
signedAt = verified.signedAt,
|
||||
createdAt = now,
|
||||
)
|
||||
unit.credits.updateAccountBalance(userId, balanceAfter, now)
|
||||
unit.credits.insertLedgerEntry(
|
||||
LedgerEntry(
|
||||
id = ledgerEntryId,
|
||||
userId = userId,
|
||||
type = LedgerEntryType.STOREKIT_PURCHASE,
|
||||
amountDelta = product.credits,
|
||||
balanceAfter = balanceAfter,
|
||||
idempotencyKey = idempotencyKey,
|
||||
referenceId = purchaseId,
|
||||
createdAt = now,
|
||||
)
|
||||
)
|
||||
unit.storeKit.insert(purchase)
|
||||
StoreKitPurchaseResult(purchase, balanceAfter, replayed = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateTransaction(
|
||||
userId: UUID,
|
||||
transaction: VerifiedStoreKitTransaction,
|
||||
) {
|
||||
if (transaction.appAccountToken != userId || transaction.revokedAt != null) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
val now = clock.instant()
|
||||
if (
|
||||
transaction.purchasedAt > transaction.signedAt ||
|
||||
transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
|
||||
) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireReplayMatches(
|
||||
existing: StoreKitCreditPurchase,
|
||||
verified: VerifiedStoreKitTransaction,
|
||||
product: StoreKitProduct,
|
||||
) {
|
||||
if (
|
||||
existing.userId != verified.appAccountToken ||
|
||||
existing.originalTransactionId != verified.originalTransactionId ||
|
||||
existing.productId != verified.productId ||
|
||||
existing.environment != verified.environment ||
|
||||
existing.creditsGranted != product.credits ||
|
||||
existing.purchasedAt != verified.purchasedAt
|
||||
) {
|
||||
throw StoreKitPurchaseConflict()
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.sha256Hex(): String =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(toByteArray(Charsets.UTF_8))
|
||||
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||
|
||||
private companion object {
|
||||
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
|
||||
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
|
||||
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.osglab.account.features.storekit.verification
|
||||
|
||||
import com.apple.itunes.storekit.model.Environment
|
||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload
|
||||
import com.apple.itunes.storekit.model.Type
|
||||
import com.apple.itunes.storekit.verification.SignedDataVerifier
|
||||
import com.apple.itunes.storekit.verification.VerificationException
|
||||
import com.apple.itunes.storekit.verification.VerificationStatus
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import java.io.InputStream
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
fun interface StoreKitTransactionVerifier {
|
||||
suspend fun verify(signedTransaction: String): VerifiedStoreKitTransaction
|
||||
}
|
||||
|
||||
class AppleStoreKitTransactionVerifier(
|
||||
bundleId: String,
|
||||
appAppleId: Long,
|
||||
rootCertificateLoader: (String) -> InputStream? = {
|
||||
AppleStoreKitTransactionVerifier::class.java.getResourceAsStream(it)
|
||||
},
|
||||
enableOnlineChecks: Boolean = true,
|
||||
) : StoreKitTransactionVerifier {
|
||||
private val production: SignedDataVerifier
|
||||
private val sandbox: SignedDataVerifier
|
||||
|
||||
init {
|
||||
val certificateBytes = ROOT_CERTIFICATES.map { path ->
|
||||
rootCertificateLoader(path)?.use(InputStream::readAllBytes)
|
||||
?: error("Missing Apple root certificate: $path")
|
||||
}
|
||||
fun verifier(environment: Environment): SignedDataVerifier {
|
||||
val streams = certificateBytes.map(ByteArray::inputStream).toSet()
|
||||
return SignedDataVerifier(
|
||||
streams,
|
||||
bundleId,
|
||||
if (environment == Environment.PRODUCTION) appAppleId else null,
|
||||
environment,
|
||||
enableOnlineChecks,
|
||||
).also { streams.forEach(InputStream::close) }
|
||||
}
|
||||
production = verifier(Environment.PRODUCTION)
|
||||
sandbox = verifier(Environment.SANDBOX)
|
||||
}
|
||||
|
||||
override suspend fun verify(
|
||||
signedTransaction: String
|
||||
): VerifiedStoreKitTransaction = withContext(Dispatchers.IO) {
|
||||
if (signedTransaction.length !in MIN_JWS_LENGTH..MAX_JWS_LENGTH) {
|
||||
throw StoreKitVerificationFailed()
|
||||
}
|
||||
var retryableFailure = false
|
||||
val payload = try {
|
||||
production.verifyAndDecodeTransaction(signedTransaction)
|
||||
} catch (exception: VerificationException) {
|
||||
retryableFailure = exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
|
||||
null
|
||||
} ?: try {
|
||||
sandbox.verifyAndDecodeTransaction(signedTransaction)
|
||||
} catch (exception: VerificationException) {
|
||||
retryableFailure = retryableFailure ||
|
||||
exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
|
||||
null
|
||||
}
|
||||
if (payload == null && retryableFailure) {
|
||||
throw StoreKitUnavailable()
|
||||
}
|
||||
if (payload == null) {
|
||||
throw StoreKitVerificationFailed()
|
||||
}
|
||||
payload.toVerifiedTransaction()
|
||||
}
|
||||
|
||||
private fun JWSTransactionDecodedPayload.toVerifiedTransaction(): VerifiedStoreKitTransaction {
|
||||
val transaction = transactionId?.takeIf(TRANSACTION_ID::matches)
|
||||
?: throw StoreKitVerificationFailed()
|
||||
val original = originalTransactionId?.takeIf(TRANSACTION_ID::matches)
|
||||
?: throw StoreKitVerificationFailed()
|
||||
val accountToken = appAccountToken ?: throw StoreKitVerificationFailed()
|
||||
val product = productId?.takeIf { it.length in 3..128 }
|
||||
?: throw StoreKitVerificationFailed()
|
||||
val purchaseMillis = purchaseDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
|
||||
val signedMillis = signedDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
|
||||
if (type != Type.CONSUMABLE || quantity != 1) {
|
||||
throw StoreKitVerificationFailed()
|
||||
}
|
||||
val verifiedEnvironment = when (environment) {
|
||||
Environment.PRODUCTION -> StoreKitEnvironment.PRODUCTION
|
||||
Environment.SANDBOX -> StoreKitEnvironment.SANDBOX
|
||||
else -> throw StoreKitVerificationFailed()
|
||||
}
|
||||
return VerifiedStoreKitTransaction(
|
||||
transactionId = transaction,
|
||||
originalTransactionId = original,
|
||||
appAccountToken = accountToken,
|
||||
productId = product,
|
||||
environment = verifiedEnvironment,
|
||||
purchasedAt = Instant.ofEpochMilli(purchaseMillis),
|
||||
signedAt = Instant.ofEpochMilli(signedMillis),
|
||||
revokedAt = revocationDate?.let(Instant::ofEpochMilli),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MIN_JWS_LENGTH = 100
|
||||
const val MAX_JWS_LENGTH = 32_768
|
||||
val TRANSACTION_ID = Regex("[0-9]{1,64}")
|
||||
val ROOT_CERTIFICATES = listOf(
|
||||
"/apple-pki/AppleRootCA-G2.cer",
|
||||
"/apple-pki/AppleRootCA-G3.cer",
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -49,9 +49,14 @@ app:
|
||||
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
|
||||
credits:
|
||||
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
|
||||
referralInviter: "$REFERRAL_INVITER_CREDITS:3000"
|
||||
referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000"
|
||||
referralInviter: "$REFERRAL_INVITER_CREDITS:1000"
|
||||
referralInvitee: "$REFERRAL_INVITEE_CREDITS:1000"
|
||||
referralBindingDays: "$REFERRAL_BINDING_DAYS:7"
|
||||
storeKit:
|
||||
enabled: "$STOREKIT_ENABLED:false"
|
||||
bundleId: "$STOREKIT_BUNDLE_ID:com.osgkeyboard.ios"
|
||||
appAppleId: "$STOREKIT_APP_APPLE_ID:"
|
||||
products: "$STOREKIT_PRODUCTS:"
|
||||
providers:
|
||||
volcengine:
|
||||
endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- Close the initial immutable rates and activate the smaller credit unit.
|
||||
-- ASR bills one credit per started three-second interval.
|
||||
-- DeepSeek bills input/output dimensions independently and rounds each upward.
|
||||
SET @new_credit_rate_effective_from = UTC_TIMESTAMP(6);
|
||||
|
||||
UPDATE credit_rate_versions
|
||||
SET effective_until = @new_credit_rate_effective_from
|
||||
WHERE id IN (
|
||||
'10000000-0000-0000-0000-000000000001',
|
||||
'10000000-0000-0000-0000-000000000002'
|
||||
)
|
||||
AND effective_until IS NULL;
|
||||
|
||||
INSERT INTO credit_rate_versions (
|
||||
id,
|
||||
kind,
|
||||
provider,
|
||||
model,
|
||||
effective_from,
|
||||
effective_until,
|
||||
asr_credits_numerator,
|
||||
asr_millis_denominator,
|
||||
created_at
|
||||
) VALUES (
|
||||
'10000000-0000-0000-0000-000000000003',
|
||||
'ASR',
|
||||
'volcengine-sauc-v3',
|
||||
'volc.seedasr.sauc.duration',
|
||||
@new_credit_rate_effective_from,
|
||||
NULL,
|
||||
1,
|
||||
3000,
|
||||
@new_credit_rate_effective_from
|
||||
);
|
||||
|
||||
INSERT INTO credit_rate_versions (
|
||||
id,
|
||||
kind,
|
||||
provider,
|
||||
model,
|
||||
effective_from,
|
||||
effective_until,
|
||||
input_credits_numerator,
|
||||
input_tokens_denominator,
|
||||
output_credits_numerator,
|
||||
output_tokens_denominator,
|
||||
created_at
|
||||
) VALUES (
|
||||
'10000000-0000-0000-0000-000000000004',
|
||||
'LLM',
|
||||
'deepseek',
|
||||
'deepseek-v4-flash',
|
||||
@new_credit_rate_effective_from,
|
||||
NULL,
|
||||
1,
|
||||
1000,
|
||||
1,
|
||||
400,
|
||||
@new_credit_rate_effective_from
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE account_profiles (
|
||||
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
encrypted_display_name MEDIUMTEXT NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (account_id),
|
||||
CONSTRAINT fk_account_profiles_account
|
||||
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB;
|
||||
@@ -0,0 +1,4 @@
|
||||
UPDATE referral_campaigns
|
||||
SET inviter_reward_credits = 1000,
|
||||
invitee_reward_credits = 1000
|
||||
WHERE id = '00000000-0000-0000-0000-000000000001';
|
||||
@@ -0,0 +1,24 @@
|
||||
CREATE TABLE storekit_credit_purchases (
|
||||
id CHAR(36) NOT NULL,
|
||||
transaction_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
original_transaction_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
app_account_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
product_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
environment VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
credits_granted BIGINT NOT NULL,
|
||||
ledger_entry_id CHAR(36) NOT NULL,
|
||||
signed_transaction_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
purchased_at DATETIME(6) NOT NULL,
|
||||
signed_at DATETIME(6) NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_storekit_purchase_transaction (transaction_id),
|
||||
UNIQUE KEY uk_storekit_purchase_ledger (ledger_entry_id),
|
||||
INDEX idx_storekit_purchase_user_created (user_id, created_at, id),
|
||||
CONSTRAINT chk_storekit_purchase_credits CHECK (credits_granted > 0),
|
||||
CONSTRAINT chk_storekit_purchase_environment
|
||||
CHECK (environment IN ('SANDBOX', 'PRODUCTION')),
|
||||
CONSTRAINT fk_storekit_purchase_ledger
|
||||
FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger(id)
|
||||
) ENGINE = InnoDB;
|
||||
@@ -14,6 +14,9 @@ class AppConfigTest : FunSpec({
|
||||
config.environment shouldBe Environment.TEST
|
||||
config.apple.clientCredentialsAvailable shouldBe false
|
||||
config.encryption.key.size shouldBe 32
|
||||
config.credits.signupTrial shouldBe 1_000
|
||||
config.credits.referralInviter shouldBe 1_000
|
||||
config.credits.referralInvitee shouldBe 1_000
|
||||
}
|
||||
|
||||
test("production rejects placeholder secrets") {
|
||||
@@ -76,6 +79,43 @@ class AppConfigTest : FunSpec({
|
||||
}.message.orEmpty() shouldContain "bootstrapEnabled requires"
|
||||
}
|
||||
|
||||
test("StoreKit requires an app identifier and dedicated credit product when enabled") {
|
||||
val missingAppId = validProductionConfig().apply {
|
||||
put("app.storeKit.enabled", "true")
|
||||
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
|
||||
}
|
||||
shouldThrow<IllegalArgumentException> {
|
||||
AppConfig.from(missingAppId)
|
||||
}.message.orEmpty() shouldContain "appAppleId is required"
|
||||
|
||||
val enabled = validProductionConfig().apply {
|
||||
put("app.storeKit.enabled", "true")
|
||||
put("app.storeKit.appAppleId", "6781553267")
|
||||
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
|
||||
}
|
||||
|
||||
val storeKit = AppConfig.from(enabled).storeKit
|
||||
storeKit.enabled shouldBe true
|
||||
storeKit.appAppleId shouldBe 6_781_553_267
|
||||
storeKit.products.map { it.productId to it.credits } shouldBe listOf(
|
||||
"500tks" to 500,
|
||||
"1500tks" to 1_500,
|
||||
"3000tks" to 3_000,
|
||||
)
|
||||
}
|
||||
|
||||
test("StoreKit rejects duplicate product mappings") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.storeKit.enabled", "true")
|
||||
put("app.storeKit.appAppleId", "6781553267")
|
||||
put("app.storeKit.products", "500tks:500,500tks:3000")
|
||||
}
|
||||
|
||||
shouldThrow<ConfigValidationException> {
|
||||
AppConfig.from(config)
|
||||
}.message.orEmpty() shouldContain "duplicate product IDs"
|
||||
}
|
||||
|
||||
test("production fails fast when Apple signing credentials are missing") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.apple.keyId", "")
|
||||
|
||||
@@ -61,6 +61,44 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
openApi shouldContain "unpadded Base64URL"
|
||||
}
|
||||
|
||||
test("StoreKit catalog and smaller immutable rates stay aligned") {
|
||||
listOf(root.read(".env.example"), root.read("compose.yaml")).forEach { configuration ->
|
||||
configuration shouldContain "STOREKIT_PRODUCTS"
|
||||
configuration shouldContain "500tks:500,1500tks:1500,3000tks:3000"
|
||||
configuration shouldNotContain "STOREKIT_PRODUCT_CREDITS"
|
||||
configuration shouldContain "SIGNUP_TRIAL_CREDITS"
|
||||
configuration shouldContain "REFERRAL_INVITER_CREDITS"
|
||||
}
|
||||
|
||||
val rates = root.read("src/main/resources/db/migration/V10__smaller_credit_units.sql")
|
||||
rates shouldContain "'10000000-0000-0000-0000-000000000003'"
|
||||
rates shouldContain "'10000000-0000-0000-0000-000000000004'"
|
||||
rates shouldContain "1,\n 3000,"
|
||||
rates shouldContain "1,\n 1000,\n 1,\n 400,"
|
||||
}
|
||||
|
||||
test("account profiles cascade on deletion and grants stay aligned") {
|
||||
val profileMigration = root.read(
|
||||
"src/main/resources/db/migration/V11__account_profiles.sql",
|
||||
)
|
||||
val referralMigration = root.read(
|
||||
"src/main/resources/db/migration/V12__align_referral_rewards.sql",
|
||||
)
|
||||
profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL"
|
||||
profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
|
||||
referralMigration shouldContain "inviter_reward_credits = 1000"
|
||||
referralMigration shouldContain "invitee_reward_credits = 1000"
|
||||
|
||||
listOf(
|
||||
root.read("src/main/resources/application.yaml"),
|
||||
root.read(".env.example"),
|
||||
root.read("compose.yaml"),
|
||||
).forEach { configuration ->
|
||||
configuration shouldContain "1000"
|
||||
configuration shouldNotContain "SIGNUP_TRIAL_CREDITS=334"
|
||||
}
|
||||
}
|
||||
|
||||
test("production Compose reuses private MySQL and hardens the application container") {
|
||||
val compose = root.read("compose.yaml")
|
||||
|
||||
@@ -81,6 +119,7 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
test("admin bootstrap is one-time and runtime database grants stay explicit") {
|
||||
val compose = root.read("compose.yaml")
|
||||
val privileges = root.read("docs/mysql-minimum-privileges.sql")
|
||||
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
|
||||
|
||||
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
|
||||
@@ -91,10 +130,16 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
|
||||
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.account_profiles"
|
||||
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.account_profiles"
|
||||
privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log"
|
||||
privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.storekit_credit_purchases"
|
||||
privileges shouldContain "GRANT INSERT ON osg_account.storekit_credit_purchases"
|
||||
privileges shouldNotContain "UPDATE ON osg_account.admin_audit_log"
|
||||
privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants"
|
||||
smokePrivileges shouldContain "GRANT SELECT ON osg_account_smoke.account_profiles"
|
||||
smokePrivileges shouldContain "GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles"
|
||||
}
|
||||
|
||||
test("container image remains non-root and read-only compatible") {
|
||||
@@ -162,6 +207,8 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
|
||||
"/v1/credits/balance",
|
||||
"/v1/credits/ledger",
|
||||
"/v1/credits/rates",
|
||||
"/v1/storekit/products",
|
||||
"/v1/storekit/transactions",
|
||||
"/v1/referrals",
|
||||
"/v1/referrals/me",
|
||||
"/v1/referrals/code",
|
||||
|
||||
@@ -37,9 +37,13 @@ class SmokeDeploymentTest : FunSpec({
|
||||
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
|
||||
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
|
||||
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V8"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V12"
|
||||
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
|
||||
runner shouldContain "active smaller credit rates did not match the V10 contract"
|
||||
runner shouldContain "first ledger page omitted nextCursor"
|
||||
runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()"
|
||||
runner shouldContain "UPDATE storekit_credit_purchases SET credits_granted = credits_granted"
|
||||
runner shouldContain "DELETE FROM storekit_credit_purchases WHERE 1 = 0"
|
||||
runner shouldNotContain "appleid.apple.com"
|
||||
runner shouldNotContain "api.deepseek.com"
|
||||
runner shouldNotContain "openspeech.bytedance.com"
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.osglab.account.features.account
|
||||
|
||||
import com.osglab.account.common.security.AccountPrincipal
|
||||
import com.osglab.account.common.security.SESSION_AUTH_NAME
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.ktor.client.request.delete
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.patch
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.auth.Authentication
|
||||
import io.ktor.server.auth.bearer
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
|
||||
class AccountRoutesTest {
|
||||
private val accountId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||
private val sessionId = UUID.fromString("20000000-0000-0000-0000-000000000010")
|
||||
|
||||
@Test
|
||||
fun `profile read update and destructive deletion require the authenticated account`() =
|
||||
testApplication {
|
||||
val operations = RecordingAccountOperations(accountId)
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
install(Authentication) {
|
||||
bearer(SESSION_AUTH_NAME) {
|
||||
authenticate { AccountPrincipal(accountId, sessionId) }
|
||||
}
|
||||
}
|
||||
routing { accountRoutes(operations) }
|
||||
}
|
||||
|
||||
val profile = client.get("/v1/account") {
|
||||
header(HttpHeaders.Authorization, "Bearer test")
|
||||
}
|
||||
val updated = client.patch("/v1/account") {
|
||||
header(HttpHeaders.Authorization, "Bearer test")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"displayName":"OSG 用户"}""")
|
||||
}
|
||||
val deleted = client.delete("/v1/account") {
|
||||
header(HttpHeaders.Authorization, "Bearer test")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(
|
||||
"""{"identityToken":"identity","authorizationCode":"code","nonce":"nonce"}""",
|
||||
)
|
||||
}
|
||||
|
||||
profile.status shouldBe HttpStatusCode.OK
|
||||
profile.bodyAsText() shouldContain """"displayName":"Rocky""""
|
||||
updated.status shouldBe HttpStatusCode.OK
|
||||
updated.bodyAsText() shouldContain """"displayName":"OSG 用户""""
|
||||
deleted.status shouldBe HttpStatusCode.NoContent
|
||||
operations.deletedAccountId shouldBe accountId
|
||||
operations.deletionProof shouldBe AppleReauthenticationProof("identity", "code", "nonce")
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingAccountOperations(
|
||||
private val accountId: UUID,
|
||||
) : AccountOperations {
|
||||
private var displayName = "Rocky"
|
||||
var deletedAccountId: UUID? = null
|
||||
var deletionProof: AppleReauthenticationProof? = null
|
||||
|
||||
override suspend fun get(accountId: UUID): AccountView =
|
||||
AccountView(accountId, Instant.EPOCH, displayName)
|
||||
|
||||
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) = Unit
|
||||
|
||||
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
|
||||
displayName = candidate
|
||||
return get(accountId)
|
||||
}
|
||||
|
||||
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
|
||||
deletedAccountId = accountId
|
||||
deletionProof = proof
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,40 @@ import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AccountServiceTest : FunSpec({
|
||||
test("Apple name seeds once and the user can update the nickname") {
|
||||
val accountId = UUID.randomUUID()
|
||||
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
|
||||
val repository = RecordingAccountRepository(
|
||||
AccountRecord(
|
||||
id = accountId,
|
||||
identityFingerprint = "d".repeat(64),
|
||||
antiAbuseRestricted = false,
|
||||
encryptedAppleRefreshToken = null,
|
||||
createdAt = Instant.parse("2026-08-18T00:00:00Z"),
|
||||
),
|
||||
mutableListOf(),
|
||||
)
|
||||
val service = AccountService(
|
||||
repository,
|
||||
encryptor,
|
||||
AntiAbuseConfig(ByteArray(32) { 9 }, 365),
|
||||
AppleRevocationOutboxProcessor(
|
||||
repository,
|
||||
RecordingAppleTokenClient(mutableListOf()),
|
||||
encryptor,
|
||||
),
|
||||
AccountReauthenticator { _, _ -> "unused" },
|
||||
)
|
||||
|
||||
service.seedDisplayName(accountId, " Rocky Chen ")
|
||||
service.seedDisplayName(accountId, "Ignored")
|
||||
service.get(accountId).displayName shouldBe "Rocky Chen"
|
||||
|
||||
service.updateDisplayName(accountId, "OSG 用户")
|
||||
|
||||
service.get(accountId).displayName shouldBe "OSG 用户"
|
||||
}
|
||||
|
||||
test("account deletion commits locally before reliably revoking the Apple token") {
|
||||
val accountId = UUID.randomUUID()
|
||||
val encryptor = FieldEncryptor(ByteArray(32) { 5 })
|
||||
@@ -117,15 +151,34 @@ private val REAUTH_PROOF = AppleReauthenticationProof(
|
||||
)
|
||||
|
||||
private class RecordingAccountRepository(
|
||||
private val account: AccountRecord,
|
||||
account: AccountRecord,
|
||||
private val events: MutableList<String>,
|
||||
) : AccountRepository {
|
||||
private var account = account
|
||||
var deleted = false
|
||||
private var pending: AppleRevocationOutboxRecord? = null
|
||||
val pendingCount: Int get() = if (pending == null) 0 else 1
|
||||
|
||||
override suspend fun findById(accountId: UUID): AccountRecord? = account
|
||||
|
||||
override suspend fun seedDisplayNameIfAbsent(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
if (account.encryptedDisplayName == null) {
|
||||
account = account.copy(encryptedDisplayName = encryptedDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updateDisplayName(
|
||||
accountId: UUID,
|
||||
encryptedDisplayName: String,
|
||||
now: Instant,
|
||||
) {
|
||||
account = account.copy(encryptedDisplayName = encryptedDisplayName)
|
||||
}
|
||||
|
||||
override suspend fun deleteById(
|
||||
accountId: UUID,
|
||||
deletedAt: Instant,
|
||||
|
||||
@@ -48,6 +48,33 @@ class CreditServiceTest : FunSpec({
|
||||
) shouldBeExactly 4
|
||||
}
|
||||
|
||||
test("smaller production unit bills each started interval") {
|
||||
val productionAsr = asrRate(now).copy(asrMillisDenominator = 3_000)
|
||||
CreditCostCalculator.calculate(
|
||||
productionAsr,
|
||||
UsageMeasurement.Asr(durationMillis = 1),
|
||||
) shouldBeExactly 1
|
||||
CreditCostCalculator.calculate(
|
||||
productionAsr,
|
||||
UsageMeasurement.Asr(durationMillis = 3_000),
|
||||
) shouldBeExactly 1
|
||||
CreditCostCalculator.calculate(
|
||||
productionAsr,
|
||||
UsageMeasurement.Asr(durationMillis = 3_001),
|
||||
) shouldBeExactly 2
|
||||
|
||||
val productionLlm = llmRate(now).copy(
|
||||
inputCreditsNumerator = 1,
|
||||
inputTokensDenominator = 1_000,
|
||||
outputCreditsNumerator = 1,
|
||||
outputTokensDenominator = 400,
|
||||
)
|
||||
CreditCostCalculator.calculate(
|
||||
productionLlm,
|
||||
UsageMeasurement.Llm(inputTokens = 1_001, outputTokens = 401),
|
||||
) shouldBeExactly 4
|
||||
}
|
||||
|
||||
test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") {
|
||||
CreditCostCalculator.calculate(
|
||||
asrRate(now).copy(
|
||||
@@ -602,6 +629,43 @@ class CreditServiceTest : FunSpec({
|
||||
} shouldBe true
|
||||
}
|
||||
|
||||
test("account summary reports settled usage separately from remaining credits") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
val userId = UUID.randomUUID()
|
||||
service.grantSignupTrial(userId, 100, "summary-signup-key")
|
||||
val reservation = service.reserve(
|
||||
userId,
|
||||
"asr-provider",
|
||||
"asr-model",
|
||||
UsageMeasurement.Asr(1_000),
|
||||
managedCall = false,
|
||||
idempotencyKey = "summary-reserve-key",
|
||||
)
|
||||
val reservedSummary = service.getAccountSummary(userId)
|
||||
|
||||
reservedSummary.account.balance shouldBeExactly 90
|
||||
reservedSummary.lifetimeUsed shouldBeExactly 0
|
||||
|
||||
service.settle(
|
||||
userId,
|
||||
reservation.id,
|
||||
UsageMeasurement.Asr(500),
|
||||
"summary-settle-key",
|
||||
)
|
||||
|
||||
val summary = service.getAccountSummary(userId)
|
||||
|
||||
summary.account.balance shouldBeExactly 95
|
||||
summary.lifetimeUsed shouldBeExactly 5
|
||||
|
||||
service.refund(userId, reservation.id, "summary-refund-key")
|
||||
val refundedSummary = service.getAccountSummary(userId)
|
||||
|
||||
refundedSummary.account.balance shouldBeExactly 100
|
||||
refundedSummary.lifetimeUsed shouldBeExactly 0
|
||||
}
|
||||
|
||||
test("release and refund restore only the corresponding debit") {
|
||||
val store = storeWithRates(now)
|
||||
val service = service(store, now)
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditRateVersion
|
||||
import com.osglab.account.features.credits.domain.CreditReservation
|
||||
import com.osglab.account.features.credits.domain.CreditUsageRecord
|
||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.credits.domain.ManualCreditGrant
|
||||
import com.osglab.account.features.credits.domain.UsageKind
|
||||
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
|
||||
@@ -20,6 +21,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
|
||||
import com.osglab.account.features.referrals.domain.ReferralCode
|
||||
import com.osglab.account.features.referrals.domain.ReferralRewardStatus
|
||||
import com.osglab.account.features.referrals.repositories.ReferralsRepository
|
||||
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||
import com.osglab.account.features.storekit.repositories.StoreKitRepository
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
@@ -37,6 +40,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
val rates = mutableMapOf<UUID, CreditRateVersion>()
|
||||
val codes = mutableMapOf<UUID, ReferralCode>()
|
||||
val bindings = mutableMapOf<UUID, ReferralBinding>()
|
||||
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
|
||||
val campaigns = mutableMapOf(
|
||||
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign(
|
||||
id = DEFAULT_REFERRAL_CAMPAIGN_ID,
|
||||
@@ -63,6 +67,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
override val credits: CreditsRepository = Credits()
|
||||
override val referrals: ReferralsRepository = Referrals()
|
||||
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
|
||||
override val storeKit: StoreKitRepository = StoreKitPurchases()
|
||||
|
||||
var failNextManualGrantInsert = false
|
||||
|
||||
@@ -78,6 +83,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
val codeSnapshot = codes.toMap()
|
||||
val bindingSnapshot = bindings.toMap()
|
||||
val budgetSnapshot = campaignBudgets.toMap()
|
||||
val storeKitSnapshot = storeKitPurchases.toMap()
|
||||
try {
|
||||
block(this)
|
||||
} catch (failure: Throwable) {
|
||||
@@ -91,6 +97,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
codes.replaceWith(codeSnapshot)
|
||||
bindings.replaceWith(bindingSnapshot)
|
||||
campaignBudgets.replaceWith(budgetSnapshot)
|
||||
storeKitPurchases.replaceWith(storeKitSnapshot)
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
@@ -134,6 +141,15 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
usageRecords += record
|
||||
}
|
||||
|
||||
override fun lifetimeUsedCredits(userId: UUID): Long {
|
||||
val charged = usageRecords.filter { it.userId == userId }
|
||||
.fold(0L) { total, record -> Math.addExact(total, record.chargedCredits) }
|
||||
val refunded = ledger.filter {
|
||||
it.userId == userId && it.type == LedgerEntryType.USAGE_REFUND
|
||||
}.fold(0L) { total, entry -> Math.addExact(total, entry.amountDelta) }
|
||||
return Math.subtractExact(charged, refunded)
|
||||
}
|
||||
|
||||
override fun findReservationByReserveKey(
|
||||
userId: UUID,
|
||||
idempotencyKey: String,
|
||||
@@ -271,6 +287,15 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
|
||||
entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET))
|
||||
}
|
||||
}
|
||||
|
||||
private inner class StoreKitPurchases : StoreKitRepository {
|
||||
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
|
||||
storeKitPurchases[transactionId]
|
||||
|
||||
override fun insert(purchase: StoreKitCreditPurchase) {
|
||||
check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
|
||||
|
||||
@@ -93,6 +93,40 @@ class AppAttestCryptoTest : FunSpec({
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("assertion accepts the production fixed profile with AT set") {
|
||||
val fixture = AppAttestFixture()
|
||||
val hash = sha256ForTest("production-request".toByteArray())
|
||||
|
||||
fixture.crypto().validateAssertion(
|
||||
assertionObject = fixture.assertionObject(
|
||||
counter = 1,
|
||||
clientDataHash = hash,
|
||||
flags = 0x40,
|
||||
),
|
||||
clientDataHash = hash,
|
||||
publicKey = fixture.keyPair.public.encoded,
|
||||
lastCounter = 0,
|
||||
) shouldBe 1L
|
||||
}
|
||||
|
||||
test("assertion rejects the extension flag without extension data") {
|
||||
val fixture = AppAttestFixture()
|
||||
val hash = sha256ForTest("extension-request".toByteArray())
|
||||
|
||||
shouldThrow<AppAttestRejectedException> {
|
||||
fixture.crypto().validateAssertion(
|
||||
assertionObject = fixture.assertionObject(
|
||||
counter = 1,
|
||||
clientDataHash = hash,
|
||||
flags = 0x80,
|
||||
),
|
||||
clientDataHash = hash,
|
||||
publicKey = fixture.keyPair.public.encoded,
|
||||
lastCounter = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private class AppAttestFixture {
|
||||
@@ -134,15 +168,19 @@ private class AppAttestFixture {
|
||||
.EncodeToBytes()
|
||||
}
|
||||
|
||||
fun assertionObject(counter: Int, clientDataHash: ByteArray): ByteArray {
|
||||
fun assertionObject(
|
||||
counter: Int,
|
||||
clientDataHash: ByteArray,
|
||||
flags: Int = 0,
|
||||
): ByteArray {
|
||||
val authData = ByteBuffer.allocate(37).order(ByteOrder.BIG_ENDIAN)
|
||||
.put(rpIdHash)
|
||||
.put(0)
|
||||
.put(flags.toByte())
|
||||
.putInt(counter)
|
||||
.array()
|
||||
val signature = Signature.getInstance("SHA256withECDSA").run {
|
||||
initSign(keyPair.private)
|
||||
update(authData + clientDataHash)
|
||||
update(sha256ForTest(authData + clientDataHash))
|
||||
sign()
|
||||
}
|
||||
return CBORObject.NewMap()
|
||||
|
||||
@@ -38,6 +38,19 @@ class ReferralServiceTest : FunSpec({
|
||||
store.codes.size shouldBe 1
|
||||
}
|
||||
|
||||
test("profile lookup automatically provisions a stable invitation code") {
|
||||
val store = TestBillingStore()
|
||||
val owner = UUID.randomUUID()
|
||||
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
|
||||
|
||||
val first = service.getProfile(owner)
|
||||
val second = service.getProfile(owner)
|
||||
|
||||
first.code shouldBe second.code
|
||||
first.code?.code?.length shouldBe 22
|
||||
store.codes.size shouldBe 1
|
||||
}
|
||||
|
||||
test("an account binds once and repeated same binding is idempotent") {
|
||||
val store = TestBillingStore()
|
||||
val inviter = UUID.randomUUID()
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.osglab.account.features.storekit
|
||||
|
||||
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
|
||||
class AppleStoreKitTransactionVerifierTest : FunSpec({
|
||||
test("bundled Apple roots load and malformed JWS is rejected") {
|
||||
val verifier = AppleStoreKitTransactionVerifier(
|
||||
bundleId = "com.osgkeyboard.ios",
|
||||
appAppleId = 6_781_553_267,
|
||||
enableOnlineChecks = false,
|
||||
)
|
||||
|
||||
shouldThrow<StoreKitVerificationFailed> {
|
||||
verifier.verify("not-a-jws".repeat(20))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.osglab.account.features.storekit
|
||||
|
||||
import com.osglab.account.features.credits.TestBillingStore
|
||||
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.routes.storeKitRoutes
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.ktor.client.request.get
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.testing.testApplication
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
|
||||
class StoreKitRoutesTest {
|
||||
private val now = Instant.parse("2026-08-18T08:00:00Z")
|
||||
private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||
private val signedTransaction = "s".repeat(100)
|
||||
|
||||
@Test
|
||||
fun `authenticated transaction submission grants once and replays safely`() = testApplication {
|
||||
val service = service()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
routing {
|
||||
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
|
||||
}
|
||||
}
|
||||
|
||||
val catalog = client.get("/v1/storekit/products")
|
||||
val first = client.post("/v1/storekit/transactions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||
}
|
||||
val replay = client.post("/v1/storekit/transactions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||
}
|
||||
|
||||
catalog.status shouldBe HttpStatusCode.OK
|
||||
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
|
||||
catalog.bodyAsText() shouldContain """"productId":"1500tks","credits":1500"""
|
||||
catalog.bodyAsText() shouldContain """"productId":"3000tks","credits":3000"""
|
||||
first.status shouldBe HttpStatusCode.OK
|
||||
first.bodyAsText() shouldContain """"creditsGranted":3000"""
|
||||
first.bodyAsText() shouldContain """"replayed":false"""
|
||||
replay.status shouldBe HttpStatusCode.OK
|
||||
replay.bodyAsText() shouldContain """"replayed":true"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `product catalog and transaction submission require authentication`() = testApplication {
|
||||
val service = service()
|
||||
application {
|
||||
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||
routing {
|
||||
storeKitRoutes(service, AuthenticatedUserExtractor { null })
|
||||
}
|
||||
}
|
||||
|
||||
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
|
||||
client.post("/v1/storekit/transactions") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||
}.status shouldBe HttpStatusCode.Unauthorized
|
||||
}
|
||||
|
||||
private fun service(): StoreKitService {
|
||||
val verified = VerifiedStoreKitTransaction(
|
||||
transactionId = "2000000000001",
|
||||
originalTransactionId = "2000000000001",
|
||||
appAccountToken = userId,
|
||||
productId = "3000tks",
|
||||
environment = StoreKitEnvironment.SANDBOX,
|
||||
purchasedAt = now.minusSeconds(10),
|
||||
signedAt = now.minusSeconds(5),
|
||||
revokedAt = null,
|
||||
)
|
||||
return StoreKitService(
|
||||
products = listOf(
|
||||
StoreKitProduct("500tks", 500),
|
||||
StoreKitProduct("1500tks", 1_500),
|
||||
StoreKitProduct(verified.productId, 3_000),
|
||||
),
|
||||
verifier = StoreKitTransactionVerifier { verified },
|
||||
transactions = TestBillingStore(),
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.osglab.account.features.storekit
|
||||
|
||||
import com.osglab.account.features.credits.TestBillingStore
|
||||
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||
import com.osglab.account.features.storekit.services.StoreKitService
|
||||
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.collections.shouldHaveSize
|
||||
import io.kotest.matchers.longs.shouldBeExactly
|
||||
import io.kotest.matchers.shouldBe
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
||||
class StoreKitServiceTest : FunSpec({
|
||||
val now = Instant.parse("2026-08-18T08:00:00Z")
|
||||
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||
val product = StoreKitProduct("3000tks", 3_000)
|
||||
val signedTransaction = "s".repeat(100)
|
||||
|
||||
fun transaction(
|
||||
transactionId: String = "2000000000001",
|
||||
accountToken: UUID = userId,
|
||||
productId: String = product.productId,
|
||||
revokedAt: Instant? = null,
|
||||
) = VerifiedStoreKitTransaction(
|
||||
transactionId = transactionId,
|
||||
originalTransactionId = transactionId,
|
||||
appAccountToken = accountToken,
|
||||
productId = productId,
|
||||
environment = StoreKitEnvironment.SANDBOX,
|
||||
purchasedAt = now.minusSeconds(10),
|
||||
signedAt = now.minusSeconds(5),
|
||||
revokedAt = revokedAt,
|
||||
)
|
||||
|
||||
fun service(
|
||||
store: TestBillingStore,
|
||||
verified: VerifiedStoreKitTransaction = transaction(),
|
||||
) = StoreKitService(
|
||||
products = listOf(product),
|
||||
verifier = StoreKitTransactionVerifier { verified },
|
||||
transactions = store,
|
||||
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
|
||||
test("verified consumable grants integer credits and appends immutable records") {
|
||||
val store = TestBillingStore()
|
||||
|
||||
val result = service(store).submit(userId, signedTransaction)
|
||||
|
||||
result.balanceAfter shouldBeExactly 3_000
|
||||
result.replayed shouldBe false
|
||||
store.storeKitPurchases.values shouldHaveSize 1
|
||||
store.ledger.single().type shouldBe LedgerEntryType.STOREKIT_PURCHASE
|
||||
store.ledger.single().referenceId shouldBe result.purchase.id
|
||||
}
|
||||
|
||||
test("same transaction replay returns the original grant without double crediting") {
|
||||
val store = TestBillingStore()
|
||||
val service = service(store)
|
||||
|
||||
service.submit(userId, signedTransaction)
|
||||
val replay = service.submit(userId, signedTransaction)
|
||||
|
||||
replay.replayed shouldBe true
|
||||
replay.balanceAfter shouldBeExactly 3_000
|
||||
store.ledger shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("concurrent transaction replay grants credits exactly once") {
|
||||
val store = TestBillingStore()
|
||||
val service = service(store)
|
||||
|
||||
val results = coroutineScope {
|
||||
(1..20).map {
|
||||
async { service.submit(userId, signedTransaction) }
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
results.count { !it.replayed } shouldBe 1
|
||||
store.balance(userId) shouldBeExactly 3_000
|
||||
store.ledger shouldHaveSize 1
|
||||
}
|
||||
|
||||
test("transaction must be bound to the authenticated account") {
|
||||
val store = TestBillingStore()
|
||||
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
|
||||
|
||||
shouldThrow<StoreKitPurchaseConflict> {
|
||||
service(store, transaction(accountToken = otherUser))
|
||||
.submit(userId, signedTransaction)
|
||||
}
|
||||
|
||||
store.ledger shouldHaveSize 0
|
||||
}
|
||||
|
||||
test("malformed signed transaction is rejected before verification or persistence") {
|
||||
val store = TestBillingStore()
|
||||
|
||||
shouldThrow<InvalidStoreKitRequest> {
|
||||
service(store).submit(userId, "too-short")
|
||||
}
|
||||
|
||||
store.ledger shouldHaveSize 0
|
||||
}
|
||||
|
||||
test("unknown or revoked products never grant credits") {
|
||||
val unknownStore = TestBillingStore()
|
||||
shouldThrow<StoreKitPurchaseConflict> {
|
||||
service(unknownStore, transaction(productId = "com.osgkeyboard.credits.unknown"))
|
||||
.submit(userId, signedTransaction)
|
||||
}
|
||||
unknownStore.ledger shouldHaveSize 0
|
||||
|
||||
val revokedStore = TestBillingStore()
|
||||
shouldThrow<StoreKitPurchaseConflict> {
|
||||
service(revokedStore, transaction(revokedAt = now))
|
||||
.submit(userId, signedTransaction)
|
||||
}
|
||||
revokedStore.ledger shouldHaveSize 0
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user