diff --git a/.env.example b/.env.example index eadb952..c83be15 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index a876073..194519d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") diff --git a/compose.yaml b/compose.yaml index 9fe498e..e46d73f 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/deploy/smoke-local.sh b/deploy/smoke-local.sh index c9d0641..6c00277 100755 --- a/deploy/smoke-local.sh +++ b/deploy/smoke-local.sh @@ -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" diff --git a/deploy/smoke/runtime-grants.sql b/deploy/smoke/runtime-grants.sql index cbbad20..cebbf7c 100644 --- a/deploy/smoke/runtime-grants.sql +++ b/deploy/smoke/runtime-grants.sql @@ -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'@'%'; diff --git a/docs/ACCOUNT_DATA.md b/docs/ACCOUNT_DATA.md new file mode 100644 index 0000000..013cf2b --- /dev/null +++ b/docs/ACCOUNT_DATA.md @@ -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. diff --git a/docs/STOREKIT_CREDITS.md b/docs/STOREKIT_CREDITS.md new file mode 100644 index 0000000..12f9e59 --- /dev/null +++ b/docs/STOREKIT_CREDITS.md @@ -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. + +- Doubao SeedASR 2.0 streaming recognition is CNY 4.5 per hour. + + +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. diff --git a/docs/mysql-minimum-privileges.sql b/docs/mysql-minimum-privileges.sql index ebc48c5..1e9404a 100644 --- a/docs/mysql-minimum-privileges.sql +++ b/docs/mysql-minimum-privileges.sql @@ -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. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d08ab8f..e2b871b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 diff --git a/src/main/kotlin/com/osglab/account/Application.kt b/src/main/kotlin/com/osglab/account/Application.kt index 9734d65..a7e0976 100644 --- a/src/main/kotlin/com/osglab/account/Application.kt +++ b/src/main/kotlin/com/osglab/account/Application.kt @@ -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 { get() } + single { + 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 { accountId -> get().grantSignupTrial( @@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module { single { get() } single { get() } single { - AccountProvisioner { accountId, deviceCheckToken -> + AccountProvisioner { accountId, deviceCheckToken, displayName -> val granted = get().claimAndGrant(accountId, deviceCheckToken) if (deviceCheckToken != null && !granted) { get().restrictAccountForAntiAbuse( @@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module { java.time.Instant.now(), ) } + get().seedDisplayName(accountId, displayName) + try { + get().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 { get() } single { UserRegistrationTimeProvider { accountId -> diff --git a/src/main/kotlin/com/osglab/account/config/AppConfig.kt b/src/main/kotlin/com/osglab/account/config/AppConfig.kt index 22cdc7e..e7ed60f 100644 --- a/src/main/kotlin/com/osglab/account/config/AppConfig.kt +++ b/src/main/kotlin/com/osglab/account/config/AppConfig.kt @@ -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 = 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 { + 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()) { diff --git a/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt b/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt index faf4294..489fe55 100644 --- a/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt +++ b/src/main/kotlin/com/osglab/account/features/account/AccountRepository.kt @@ -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, diff --git a/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt b/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt index c386f88..f07b9da 100644 --- a/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/account/AccountRoutes.kt @@ -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() ?: 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() + ?: throw UnauthorizedException() + val request = call.receive() + val account = accountService.updateDisplayName( + principal.userId, + request.displayName, ) + call.respond(ApiResponse(data = account.toResponse())) } delete { val principal = call.principal() @@ -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, +) diff --git a/src/main/kotlin/com/osglab/account/features/account/AccountService.kt b/src/main/kotlin/com/osglab/account/features/account/AccountService.kt index 4b5ea3e..209e528 100644 --- a/src/main/kotlin/com/osglab/account/features/account/AccountService.kt +++ b/src/main/kotlin/com/osglab/account/features/account/AccountService.kt @@ -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 diff --git a/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt b/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt index fffdae8..9cab52d 100644 --- a/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/auth/AuthRoutes.kt @@ -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 { diff --git a/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt b/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt index f7ac3b1..e61008c 100644 --- a/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt +++ b/src/main/kotlin/com/osglab/account/features/auth/SessionService.kt @@ -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) } diff --git a/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt b/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt index 5578d3e..d00b0d3 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/domain/CreditDomain.kt @@ -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, diff --git a/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt b/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt index 92ccbfb..53daba6 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/models/CreditDtos.kt @@ -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(), ) } } diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt index a123a72..43d7978 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/BillingRepositories.kt @@ -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 { diff --git a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt index 74a5ba9..8b1fa9d 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/repositories/ExposedBillingTransactionRunner.kt @@ -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, diff --git a/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt b/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt index d8924ef..9033087 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/routes/CreditRoutes.kt @@ -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") { diff --git a/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt b/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt index 0a76af6..d13c2fb 100644 --- a/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt +++ b/src/main/kotlin/com/osglab/account/features/credits/services/CreditService.kt @@ -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 suspend fun listLedger(userId: UUID, limit: Int = 50): List @@ -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 = transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) } diff --git a/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt b/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt index 279a4fe..19f9fb7 100644 --- a/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt +++ b/src/main/kotlin/com/osglab/account/features/integrity/AppAttest.kt @@ -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) } } diff --git a/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt b/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt index 7980f69..7c244c1 100644 --- a/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt +++ b/src/main/kotlin/com/osglab/account/features/integrity/AppAttestCrypto.kt @@ -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) diff --git a/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt b/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt index 5c2ac4d..8c8364f 100644 --- a/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt +++ b/src/main/kotlin/com/osglab/account/features/integrity/DeviceCheck.kt @@ -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) : diff --git a/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt b/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt index 0fe8b0c..030d2b8 100644 --- a/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt +++ b/src/main/kotlin/com/osglab/account/features/referrals/services/ReferralService.kt @@ -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 = transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) } diff --git a/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt b/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt new file mode 100644 index 0000000..e3062f4 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/storekit/domain/StoreKitDomain.kt @@ -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") diff --git a/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt b/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt new file mode 100644 index 0000000..b25df15 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/storekit/models/StoreKitDtos.kt @@ -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, + ) + } +} + diff --git a/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt b/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt new file mode 100644 index 0000000..cdd47a2 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/storekit/repositories/StoreKitRepository.kt @@ -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("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], + ) diff --git a/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt b/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt new file mode 100644 index 0000000..4a4f2e5 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/storekit/routes/StoreKitRoutes.kt @@ -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() + 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")), + ) + } + } + } +} diff --git a/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt b/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt new file mode 100644 index 0000000..80599c7 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/storekit/services/StoreKitService.kt @@ -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, + 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 = 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) + } +} diff --git a/src/main/kotlin/com/osglab/account/features/storekit/verification/StoreKitTransactionVerifier.kt b/src/main/kotlin/com/osglab/account/features/storekit/verification/StoreKitTransactionVerifier.kt new file mode 100644 index 0000000..9bc5227 --- /dev/null +++ b/src/main/kotlin/com/osglab/account/features/storekit/verification/StoreKitTransactionVerifier.kt @@ -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", + ) + } +} diff --git a/src/main/resources/apple-pki/AppleRootCA-G2.cer b/src/main/resources/apple-pki/AppleRootCA-G2.cer new file mode 100644 index 0000000..739b814 Binary files /dev/null and b/src/main/resources/apple-pki/AppleRootCA-G2.cer differ diff --git a/src/main/resources/apple-pki/AppleRootCA-G3.cer b/src/main/resources/apple-pki/AppleRootCA-G3.cer new file mode 100644 index 0000000..228bfa3 Binary files /dev/null and b/src/main/resources/apple-pki/AppleRootCA-G3.cer differ diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 27ecf66..28d44d2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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" diff --git a/src/main/resources/db/migration/V10__smaller_credit_units.sql b/src/main/resources/db/migration/V10__smaller_credit_units.sql new file mode 100644 index 0000000..84379dd --- /dev/null +++ b/src/main/resources/db/migration/V10__smaller_credit_units.sql @@ -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 +); diff --git a/src/main/resources/db/migration/V11__account_profiles.sql b/src/main/resources/db/migration/V11__account_profiles.sql new file mode 100644 index 0000000..7c9ed05 --- /dev/null +++ b/src/main/resources/db/migration/V11__account_profiles.sql @@ -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; diff --git a/src/main/resources/db/migration/V12__align_referral_rewards.sql b/src/main/resources/db/migration/V12__align_referral_rewards.sql new file mode 100644 index 0000000..0f24056 --- /dev/null +++ b/src/main/resources/db/migration/V12__align_referral_rewards.sql @@ -0,0 +1,4 @@ +UPDATE referral_campaigns +SET inviter_reward_credits = 1000, + invitee_reward_credits = 1000 +WHERE id = '00000000-0000-0000-0000-000000000001'; diff --git a/src/main/resources/db/migration/V9__storekit_credit_purchases.sql b/src/main/resources/db/migration/V9__storekit_credit_purchases.sql new file mode 100644 index 0000000..f5d9317 --- /dev/null +++ b/src/main/resources/db/migration/V9__storekit_credit_purchases.sql @@ -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; diff --git a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt index e47facd..d17e6a5 100644 --- a/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt +++ b/src/test/kotlin/com/osglab/account/config/AppConfigTest.kt @@ -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 { + 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 { + 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", "") diff --git a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt index 86cda23..75805d1 100644 --- a/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt +++ b/src/test/kotlin/com/osglab/account/config/DeploymentConsistencyTest.kt @@ -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", diff --git a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt index 0d64652..4d367e0 100644 --- a/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt +++ b/src/test/kotlin/com/osglab/account/config/SmokeDeploymentTest.kt @@ -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" diff --git a/src/test/kotlin/com/osglab/account/features/account/AccountRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/account/AccountRoutesTest.kt new file mode 100644 index 0000000..2893b80 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/account/AccountRoutesTest.kt @@ -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 + } +} diff --git a/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt b/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt index 893b9bb..55c2e40 100644 --- a/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/account/AccountServiceTest.kt @@ -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, ) : 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, diff --git a/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt b/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt index 9c8ad7e..c56b60b 100644 --- a/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/credits/CreditServiceTest.kt @@ -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) diff --git a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt index 1d1fcf1..5abfd05 100644 --- a/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt +++ b/src/test/kotlin/com/osglab/account/features/credits/TestBillingStore.kt @@ -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() val codes = mutableMapOf() val bindings = mutableMapOf() + val storeKitPurchases = mutableMapOf() 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 MutableMap.replaceWith(snapshot: Map) { diff --git a/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt index dab6d2e..1f6c9a8 100644 --- a/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt +++ b/src/test/kotlin/com/osglab/account/features/integrity/AppAttestCryptoTest.kt @@ -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 { + 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() diff --git a/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt b/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt index dd47254..c6b54ce 100644 --- a/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt +++ b/src/test/kotlin/com/osglab/account/features/referrals/ReferralServiceTest.kt @@ -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() diff --git a/src/test/kotlin/com/osglab/account/features/storekit/AppleStoreKitTransactionVerifierTest.kt b/src/test/kotlin/com/osglab/account/features/storekit/AppleStoreKitTransactionVerifierTest.kt new file mode 100644 index 0000000..d87936a --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/storekit/AppleStoreKitTransactionVerifierTest.kt @@ -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 { + verifier.verify("not-a-jws".repeat(20)) + } + } +}) diff --git a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt new file mode 100644 index 0000000..78e7b2c --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitRoutesTest.kt @@ -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), + ) + } +} diff --git a/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt new file mode 100644 index 0000000..cf0f822 --- /dev/null +++ b/src/test/kotlin/com/osglab/account/features/storekit/StoreKitServiceTest.kt @@ -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 { + 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 { + service(store).submit(userId, "too-short") + } + + store.ledger shouldHaveSize 0 + } + + test("unknown or revoked products never grant credits") { + val unknownStore = TestBillingStore() + shouldThrow { + service(unknownStore, transaction(productId = "com.osgkeyboard.credits.unknown")) + .submit(userId, signedTransaction) + } + unknownStore.ledger shouldHaveSize 0 + + val revokedStore = TestBillingStore() + shouldThrow { + service(revokedStore, transaction(revokedAt = now)) + .submit(userId, signedTransaction) + } + revokedStore.ledger shouldHaveSize 0 + } +})