checkpoint before checking out feature/account-managed-gateway

This commit is contained in:
Rocky
2026-08-19 15:53:08 +08:00
parent 25cfbfa4e6
commit 1737106560
51 changed files with 1837 additions and 52 deletions
+9 -2
View File
@@ -64,10 +64,17 @@ DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
SIGNUP_TRIAL_CREDITS=1000
REFERRAL_INVITER_CREDITS=3000
REFERRAL_INVITEE_CREDITS=3000
REFERRAL_INVITER_CREDITS=1000
REFERRAL_INVITEE_CREDITS=1000
REFERRAL_BINDING_DAYS=7
# Keep voluntary tips separate. Every entry must be a dedicated consumable in
# productId:credits format and use an appAccountToken supplied by the app.
STOREKIT_ENABLED=false
STOREKIT_BUNDLE_ID=com.osgkeyboard.ios
STOREKIT_APP_APPLE_ID=6781553267
STOREKIT_PRODUCTS=500tks:500,1500tks:1500,3000tks:3000
# Production startup requires both flags and the production Apple environment.
ENFORCE_DEVICE_CHECK=false
ENFORCE_APP_ATTEST=false
+1
View File
@@ -134,6 +134,7 @@ dependencies {
implementation("com.auth0:java-jwt:4.6.0")
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1")
implementation("com.apple.itunes.storekit:app-store-server-library:5.2.0")
implementation("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
implementation("com.upokecenter:cbor:4.5.6")
+6 -2
View File
@@ -57,9 +57,13 @@ services:
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000}
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000}
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-1000}
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-1000}
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7}
STOREKIT_ENABLED: ${STOREKIT_ENABLED:-false}
STOREKIT_BUNDLE_ID: ${STOREKIT_BUNDLE_ID:-com.osgkeyboard.ios}
STOREKIT_APP_APPLE_ID: ${STOREKIT_APP_APPLE_ID:-6781553267}
STOREKIT_PRODUCTS: ${STOREKIT_PRODUCTS:-500tks:500,1500tks:1500,3000tks:3000}
ports:
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
read_only: true
+39 -2
View File
@@ -319,6 +319,12 @@ verify_immutable_history_denials() {
expect_runtime_denied \
"admin grant DELETE" \
"DELETE FROM admin_credit_grants WHERE 1 = 0"
expect_runtime_denied \
"StoreKit purchase UPDATE" \
"UPDATE storekit_credit_purchases SET credits_granted = credits_granted WHERE 1 = 0"
expect_runtime_denied \
"StoreKit purchase DELETE" \
"DELETE FROM storekit_credit_purchases WHERE 1 = 0"
expect_runtime_denied \
"Flyway metadata read" \
"SELECT version FROM flyway_schema_history LIMIT 1"
@@ -464,9 +470,40 @@ WHERE version IS NOT NULL
ORDER BY installed_rank;
SQL
)"
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1'
EXPECTED_MIGRATIONS=$'1:1\n2:1\n3:1\n4:1\n5:1\n6:1\n7:1\n8:1\n9:1\n10:1\n11:1\n12:1'
[[ "$MIGRATIONS" == "$EXPECTED_MIGRATIONS" ]] ||
fail "Flyway history was not exactly successful V1-V8"
fail "Flyway history was not exactly successful V1-V12"
REFERRAL_REWARDS="$(
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
SELECT CONCAT(inviter_reward_credits, ':', invitee_reward_credits)
FROM referral_campaigns
WHERE id = '00000000-0000-0000-0000-000000000001';
SQL
)"
[[ "$REFERRAL_REWARDS" == "1000:1000" ]] ||
fail "default referral rewards were not 1000 credits for both accounts"
ACTIVE_RATES="$(
mysql_root --batch --skip-column-names osg_account_smoke <<'SQL'
SELECT CONCAT_WS(
':',
kind,
provider,
model,
COALESCE(asr_credits_numerator, '-'),
COALESCE(asr_millis_denominator, '-'),
COALESCE(input_credits_numerator, '-'),
COALESCE(input_tokens_denominator, '-'),
COALESCE(output_credits_numerator, '-'),
COALESCE(output_tokens_denominator, '-')
)
FROM credit_rate_versions
WHERE effective_until IS NULL
ORDER BY kind, provider, model;
SQL
)"
EXPECTED_ACTIVE_RATES=$'ASR:volcengine-sauc-v3:volc.seedasr.sauc.duration:1:3000:-:-:-:-\nLLM:deepseek:deepseek-v4-flash:-:-:1:1000:1:400'
[[ "$ACTIVE_RATES" == "$EXPECTED_ACTIVE_RATES" ]] ||
fail "active smaller credit rates did not match the V10 contract"
compose --profile setup stop schema-migrator >/dev/null
log "installing exact runtime grants and disposable fixture"
+2
View File
@@ -22,6 +22,7 @@ GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
@@ -48,6 +49,7 @@ GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_ru
GRANT INSERT, UPDATE ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.account_identity_tombstones TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.apple_revocation_outbox TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.account_profiles TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE ON osg_account_smoke.admin_operators TO 'osg_smoke_runtime'@'%';
GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions TO 'osg_smoke_runtime'@'%';
GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
+10
View File
@@ -0,0 +1,10 @@
# Account data lifecycle
- Apple subjects, refresh tokens, and account nicknames are encrypted at rest.
- Apple email and avatar data are not requested or stored.
- Deleting an account removes its session, profile, referral, grant, and mutable
account records in the same local transaction before Apple revocation is retried.
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
and time-limited anti-abuse tombstones remain after deletion where required to
prevent replay, preserve financial integrity, and stop repeated trial abuse.
- Logs must never include Apple subjects, credentials, tokens, or nicknames.
+49
View File
@@ -0,0 +1,49 @@
# StoreKit credit product
The voluntary `ByRockyACoffee` tip remains independent and never grants credits.
Credit products are separate consumables:
- Product ID `500tks`: 500 integer credits at USD 0.99
- Product ID `1500tks`: 1,500 integer credits at USD 1.99 / CNY 18
- Product ID `3000tks`: 3,000 integer credits at USD 2.99 / CNY 28
- Territory prices remain controlled by App Store Connect.
- Restore Purchases: not offered for this consumable
## Cost basis
Reviewed on 2026-08-18 against the provider pricing pages:
- DeepSeek V4 Flash peak pricing is CNY 3 per million cache-miss input tokens
and CNY 9 per million output tokens. Off-peak pricing is half.
<https://api-docs.deepseek.com/zh-cn/quick_start/pricing>
- Doubao SeedASR 2.0 streaming recognition is CNY 4.5 per hour.
<https://ai.volcengine.com/model>
The V10 immutable rate card charges:
- ASR: one credit per started three-second interval. The 3,000-credit pack
provides up to 150 minutes and has a worst-case provider cost of CNY 11.25.
- DeepSeek: one credit per 1,000 input tokens plus one credit per 400 output
tokens, with each dimension rounded upward. At peak pricing, using all 3,000
credits exclusively on input or output costs at most about CNY 9.00 or
CNY 10.80 respectively.
- New signup, inviter and invitee grants are 1,000 credits each. Existing
immutable balances are adjusted only through explicit admin grants.
- Existing immutable ledger balances are grandfathered and are not rewritten
during the denomination change.
At a CNY 28 sale price, the ASR-heavy worst case leaves CNY 12.55 after a 15%
App Store commission, or CNY 8.35 after a 30% commission, before tax and
infrastructure costs. USD 2.99 territories are tighter at the worst-case ASR
mix and require ongoing margin monitoring.
## Transaction rules
- The app supplies the authenticated account UUID as StoreKit `appAccountToken`.
- The server verifies Apple's JWS signature, certificate chain, bundle ID,
App Apple ID, environment, consumable type, account token, and product ID.
- The App Store transaction ID is globally unique and idempotent.
- Credit balance and append-only purchase/ledger records commit in one database
transaction. A client retry returns the original grant.
- The app finishes the StoreKit transaction only after server acknowledgement.
- Signed transaction bodies and Apple certificate contents are never logged.
+4
View File
@@ -34,10 +34,12 @@ GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.2
GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
GRANT SELECT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.accounts TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_credentials TO 'osg_account_runtime'@'10.20.%';
@@ -60,12 +62,14 @@ GRANT INSERT, UPDATE ON osg_account.app_attest_challenges TO 'osg_account_runtim
GRANT INSERT, UPDATE ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.account_identity_tombstones TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.apple_revocation_outbox TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE ON osg_account.account_profiles TO 'osg_account_runtime'@'10.20.%';
-- Operators and sessions are mutable authentication state. Audit and grant
-- records remain append-only and deliberately receive no UPDATE or DELETE.
GRANT INSERT, UPDATE ON osg_account.admin_operators TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT, UPDATE, DELETE ON osg_account.admin_sessions TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.admin_audit_log TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.admin_credit_grants TO 'osg_account_runtime'@'10.20.%';
GRANT INSERT ON osg_account.storekit_credit_purchases TO 'osg_account_runtime'@'10.20.%';
-- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
+106 -4
View File
@@ -75,7 +75,7 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/account:
get:
summary: Return the minimal account profile
summary: Return the account profile
responses:
"200":
description: Account profile
@@ -83,6 +83,20 @@ paths:
application/json:
schema: { $ref: "#/components/schemas/AccountEnvelope" }
default: { $ref: "#/components/responses/Error" }
patch:
summary: Update the current account nickname
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UpdateAccountProfileRequest" }
responses:
"200":
description: Updated account profile
content:
application/json:
schema: { $ref: "#/components/schemas/AccountEnvelope" }
default: { $ref: "#/components/responses/Error" }
delete:
summary: Reauthenticate with Apple, delete the account, and revoke authorization
requestBody:
@@ -112,7 +126,7 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/credits/balance:
get:
summary: Return available integer credits
summary: Return available and consumed integer credits
responses:
"200":
description: Credit account
@@ -146,6 +160,44 @@ paths:
type: array
items: { type: object, additionalProperties: true }
default: { $ref: "#/components/responses/Error" }
/v1/storekit/products:
get:
summary: Return enabled consumable credit products
description: |
Current catalog: `500tks` grants 500 credits, `1500tks` grants 1,500
credits, and `3000tks` grants 3,000 credits. Localized prices are
supplied by StoreKit.
responses:
"200":
description: StoreKit credit product catalog
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/StoreKitProduct" }
default: { $ref: "#/components/responses/Error" }
/v1/storekit/transactions:
post:
summary: Verify an App Store transaction and idempotently grant credits
description: |
Submit the StoreKit 2 `VerificationResult.jwsRepresentation` before
finishing the consumable transaction. The purchase must include an
`appAccountToken` equal to the authenticated account UUID. Replaying
the same App Store transaction returns the original grant.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/StoreKitTransactionRequest" }
responses:
"200":
description: Verified purchase grant or idempotent replay
content:
application/json:
schema: { $ref: "#/components/schemas/StoreKitPurchase" }
"409": { $ref: "#/components/responses/Error" }
"422": { $ref: "#/components/responses/Error" }
default: { $ref: "#/components/responses/Error" }
/v1/referrals:
get:
summary: List invitees without exposing their Apple identity
@@ -162,7 +214,7 @@ paths:
default: { $ref: "#/components/responses/Error" }
/v1/referrals/me:
get:
summary: Return the current referral code and binding
summary: Return the referral profile and idempotently provision its invite code
responses:
"200":
description: Referral profile
@@ -1088,6 +1140,10 @@ components:
nonce:
type: string
description: Raw nonce whose lowercase SHA-256 hex digest was sent to Apple.
displayName:
type: ["string", "null"]
maxLength: 128
description: Optional first-authorization Apple name used only to seed the nickname.
deviceCheckToken:
type: ["string", "null"]
description: Ephemeral DeviceCheck token; never persisted in plaintext.
@@ -1143,12 +1199,24 @@ components:
properties:
id: { type: string, format: uuid }
createdAtEpochSeconds: { type: integer, format: int64 }
displayName:
type: ["string", "null"]
maxLength: 64
AccountEnvelope:
type: object
additionalProperties: false
required: [data]
properties:
data: { $ref: "#/components/schemas/Account" }
UpdateAccountProfileRequest:
type: object
additionalProperties: false
required: [displayName]
properties:
displayName:
type: string
minLength: 1
maxLength: 64
DeleteAccountRequest:
type: object
additionalProperties: false
@@ -1160,10 +1228,15 @@ components:
CreditAccount:
type: object
additionalProperties: true
required: [userId, balance]
required: [userId, balance, lifetimeUsed]
properties:
userId: { type: string, format: uuid }
balance: { type: integer, format: int64, minimum: 0 }
lifetimeUsed:
type: integer
format: int64
minimum: 0
description: Settled usage minus credits returned by refunds
LedgerEntry:
type: object
additionalProperties: true
@@ -1172,6 +1245,35 @@ components:
id: { type: string, format: uuid }
amountDelta: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
StoreKitProduct:
type: object
additionalProperties: false
required: [productId, credits]
properties:
productId:
type: string
enum: [500tks, 1500tks, 3000tks]
credits: { type: integer, format: int64, minimum: 1 }
StoreKitTransactionRequest:
type: object
additionalProperties: false
required: [signedTransaction]
properties:
signedTransaction:
type: string
minLength: 100
maxLength: 32768
description: StoreKit 2 VerificationResult.jwsRepresentation
StoreKitPurchase:
type: object
additionalProperties: false
required: [transactionId, productId, creditsGranted, balanceAfter, replayed]
properties:
transactionId: { type: string, pattern: "^[0-9]{1,64}$" }
productId: { type: string, minLength: 3, maxLength: 128 }
creditsGranted: { type: integer, format: int64, minimum: 1 }
balanceAfter: { type: integer, format: int64, minimum: 0 }
replayed: { type: boolean }
AppleAppSiteAssociation:
type: object
additionalProperties: false
@@ -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(),
)
}
}
@@ -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 {
@@ -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,
)
}
}
@@ -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)
}
}
@@ -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.
+7 -2
View File
@@ -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()
@@ -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
}
})