checkpoint before checking out feature/account-managed-gateway

This commit is contained in:
Rocky
2026-08-19 15:53:08 +08:00
parent 25cfbfa4e6
commit 1737106560
51 changed files with 1837 additions and 52 deletions
+9 -2
View File
@@ -64,10 +64,17 @@ DEEPSEEK_MODEL=deepseek-v4-flash
DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1 DEEPSEEK_ENDPOINT=https://api.deepseek.com/v1
SIGNUP_TRIAL_CREDITS=1000 SIGNUP_TRIAL_CREDITS=1000
REFERRAL_INVITER_CREDITS=3000 REFERRAL_INVITER_CREDITS=1000
REFERRAL_INVITEE_CREDITS=3000 REFERRAL_INVITEE_CREDITS=1000
REFERRAL_BINDING_DAYS=7 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. # Production startup requires both flags and the production Apple environment.
ENFORCE_DEVICE_CHECK=false ENFORCE_DEVICE_CHECK=false
ENFORCE_APP_ATTEST=false ENFORCE_APP_ATTEST=false
+1
View File
@@ -134,6 +134,7 @@ dependencies {
implementation("com.auth0:java-jwt:4.6.0") implementation("com.auth0:java-jwt:4.6.0")
implementation("com.nimbusds:nimbus-jose-jwt:10.9.1") 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("ch.veehait.devicecheck:devicecheck-appattest:0.9.6")
implementation("org.bouncycastle:bcprov-jdk18on:1.85.2") implementation("org.bouncycastle:bcprov-jdk18on:1.85.2")
implementation("com.upokecenter:cbor:4.5.6") implementation("com.upokecenter:cbor:4.5.6")
+6 -2
View File
@@ -57,9 +57,13 @@ services:
DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1} DEEPSEEK_ENDPOINT: ${DEEPSEEK_ENDPOINT:-https://api.deepseek.com/v1}
SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000} SIGNUP_TRIAL_CREDITS: ${SIGNUP_TRIAL_CREDITS:-1000}
REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-3000} REFERRAL_INVITER_CREDITS: ${REFERRAL_INVITER_CREDITS:-1000}
REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-3000} REFERRAL_INVITEE_CREDITS: ${REFERRAL_INVITEE_CREDITS:-1000}
REFERRAL_BINDING_DAYS: ${REFERRAL_BINDING_DAYS:-7} 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: ports:
- "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080" - "127.0.0.1:${ACCOUNT_BIND_PORT:-18080}:8080"
read_only: true read_only: true
+39 -2
View File
@@ -319,6 +319,12 @@ verify_immutable_history_denials() {
expect_runtime_denied \ expect_runtime_denied \
"admin grant DELETE" \ "admin grant DELETE" \
"DELETE FROM admin_credit_grants WHERE 1 = 0" "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 \ expect_runtime_denied \
"Flyway metadata read" \ "Flyway metadata read" \
"SELECT version FROM flyway_schema_history LIMIT 1" "SELECT version FROM flyway_schema_history LIMIT 1"
@@ -464,9 +470,40 @@ WHERE version IS NOT NULL
ORDER BY installed_rank; ORDER BY installed_rank;
SQL 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" ]] || [[ "$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 compose --profile setup stop schema-migrator >/dev/null
log "installing exact runtime grants and disposable fixture" log "installing exact runtime grants and disposable fixture"
+2
View File
@@ -22,6 +22,7 @@ GRANT SELECT ON osg_account_smoke.app_attest_challenges TO 'osg_smoke_runtime'@'
GRANT SELECT ON osg_account_smoke.app_attest_keys TO 'osg_smoke_runtime'@'%'; GRANT SELECT ON osg_account_smoke.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.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.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_operators TO 'osg_smoke_runtime'@'%';
GRANT SELECT ON osg_account_smoke.admin_sessions 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'@'%'; 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.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.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.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 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, 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'@'%'; GRANT INSERT ON osg_account_smoke.admin_audit_log TO 'osg_smoke_runtime'@'%';
+10
View File
@@ -0,0 +1,10 @@
# Account data lifecycle
- Apple subjects, refresh tokens, and account nicknames are encrypted at rest.
- Apple email and avatar data are not requested or stored.
- Deleting an account removes its session, profile, referral, grant, and mutable
account records in the same local transaction before Apple revocation is retried.
- Pseudonymous immutable credit-ledger entries, StoreKit transaction audit data,
and time-limited anti-abuse tombstones remain after deletion where required to
prevent replay, preserve financial integrity, and stop repeated trial abuse.
- Logs must never include Apple subjects, credentials, tokens, or nicknames.
+49
View File
@@ -0,0 +1,49 @@
# StoreKit credit product
The voluntary `ByRockyACoffee` tip remains independent and never grants credits.
Credit products are separate consumables:
- Product ID `500tks`: 500 integer credits at USD 0.99
- Product ID `1500tks`: 1,500 integer credits at USD 1.99 / CNY 18
- Product ID `3000tks`: 3,000 integer credits at USD 2.99 / CNY 28
- Territory prices remain controlled by App Store Connect.
- Restore Purchases: not offered for this consumable
## Cost basis
Reviewed on 2026-08-18 against the provider pricing pages:
- DeepSeek V4 Flash peak pricing is CNY 3 per million cache-miss input tokens
and CNY 9 per million output tokens. Off-peak pricing is half.
<https://api-docs.deepseek.com/zh-cn/quick_start/pricing>
- Doubao SeedASR 2.0 streaming recognition is CNY 4.5 per hour.
<https://ai.volcengine.com/model>
The V10 immutable rate card charges:
- ASR: one credit per started three-second interval. The 3,000-credit pack
provides up to 150 minutes and has a worst-case provider cost of CNY 11.25.
- DeepSeek: one credit per 1,000 input tokens plus one credit per 400 output
tokens, with each dimension rounded upward. At peak pricing, using all 3,000
credits exclusively on input or output costs at most about CNY 9.00 or
CNY 10.80 respectively.
- New signup, inviter and invitee grants are 1,000 credits each. Existing
immutable balances are adjusted only through explicit admin grants.
- Existing immutable ledger balances are grandfathered and are not rewritten
during the denomination change.
At a CNY 28 sale price, the ASR-heavy worst case leaves CNY 12.55 after a 15%
App Store commission, or CNY 8.35 after a 30% commission, before tax and
infrastructure costs. USD 2.99 territories are tighter at the worst-case ASR
mix and require ongoing margin monitoring.
## Transaction rules
- The app supplies the authenticated account UUID as StoreKit `appAccountToken`.
- The server verifies Apple's JWS signature, certificate chain, bundle ID,
App Apple ID, environment, consumable type, account token, and product ID.
- The App Store transaction ID is globally unique and idempotent.
- Credit balance and append-only purchase/ledger records commit in one database
transaction. A client retry returns the original grant.
- The app finishes the StoreKit transaction only after server acknowledgement.
- Signed transaction bodies and Apple certificate contents are never logged.
+4
View File
@@ -34,10 +34,12 @@ GRANT SELECT ON osg_account.app_attest_challenges TO 'osg_account_runtime'@'10.2
GRANT SELECT ON osg_account.app_attest_keys TO 'osg_account_runtime'@'10.20.%'; GRANT SELECT ON osg_account.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.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.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_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_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_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.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, 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.%'; 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.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.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.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 -- Operators and sessions are mutable authentication state. Audit and grant
-- records remain append-only and deliberately receive no UPDATE or DELETE. -- 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 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, 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_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.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, -- Deliberately absent: global privileges, GRANT OPTION, FILE, PROCESS, SUPER,
-- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables. -- CREATE USER, and UPDATE/DELETE on immutable ledger or usage-history tables.
+106 -4
View File
@@ -75,7 +75,7 @@ paths:
default: { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" }
/v1/account: /v1/account:
get: get:
summary: Return the minimal account profile summary: Return the account profile
responses: responses:
"200": "200":
description: Account profile description: Account profile
@@ -83,6 +83,20 @@ paths:
application/json: application/json:
schema: { $ref: "#/components/schemas/AccountEnvelope" } schema: { $ref: "#/components/schemas/AccountEnvelope" }
default: { $ref: "#/components/responses/Error" } 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: delete:
summary: Reauthenticate with Apple, delete the account, and revoke authorization summary: Reauthenticate with Apple, delete the account, and revoke authorization
requestBody: requestBody:
@@ -112,7 +126,7 @@ paths:
default: { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" }
/v1/credits/balance: /v1/credits/balance:
get: get:
summary: Return available integer credits summary: Return available and consumed integer credits
responses: responses:
"200": "200":
description: Credit account description: Credit account
@@ -146,6 +160,44 @@ paths:
type: array type: array
items: { type: object, additionalProperties: true } items: { type: object, additionalProperties: true }
default: { $ref: "#/components/responses/Error" } 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: /v1/referrals:
get: get:
summary: List invitees without exposing their Apple identity summary: List invitees without exposing their Apple identity
@@ -162,7 +214,7 @@ paths:
default: { $ref: "#/components/responses/Error" } default: { $ref: "#/components/responses/Error" }
/v1/referrals/me: /v1/referrals/me:
get: get:
summary: Return the current referral code and binding summary: Return the referral profile and idempotently provision its invite code
responses: responses:
"200": "200":
description: Referral profile description: Referral profile
@@ -1088,6 +1140,10 @@ components:
nonce: nonce:
type: string type: string
description: Raw nonce whose lowercase SHA-256 hex digest was sent to Apple. 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: deviceCheckToken:
type: ["string", "null"] type: ["string", "null"]
description: Ephemeral DeviceCheck token; never persisted in plaintext. description: Ephemeral DeviceCheck token; never persisted in plaintext.
@@ -1143,12 +1199,24 @@ components:
properties: properties:
id: { type: string, format: uuid } id: { type: string, format: uuid }
createdAtEpochSeconds: { type: integer, format: int64 } createdAtEpochSeconds: { type: integer, format: int64 }
displayName:
type: ["string", "null"]
maxLength: 64
AccountEnvelope: AccountEnvelope:
type: object type: object
additionalProperties: false additionalProperties: false
required: [data] required: [data]
properties: properties:
data: { $ref: "#/components/schemas/Account" } data: { $ref: "#/components/schemas/Account" }
UpdateAccountProfileRequest:
type: object
additionalProperties: false
required: [displayName]
properties:
displayName:
type: string
minLength: 1
maxLength: 64
DeleteAccountRequest: DeleteAccountRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -1160,10 +1228,15 @@ components:
CreditAccount: CreditAccount:
type: object type: object
additionalProperties: true additionalProperties: true
required: [userId, balance] required: [userId, balance, lifetimeUsed]
properties: properties:
userId: { type: string, format: uuid } userId: { type: string, format: uuid }
balance: { type: integer, format: int64, minimum: 0 } balance: { type: integer, format: int64, minimum: 0 }
lifetimeUsed:
type: integer
format: int64
minimum: 0
description: Settled usage minus credits returned by refunds
LedgerEntry: LedgerEntry:
type: object type: object
additionalProperties: true additionalProperties: true
@@ -1172,6 +1245,35 @@ components:
id: { type: string, format: uuid } id: { type: string, format: uuid }
amountDelta: { type: integer, format: int64 } amountDelta: { type: integer, format: int64 }
balanceAfter: { type: integer, format: int64, minimum: 0 } 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: AppleAppSiteAssociation:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -28,8 +28,9 @@ import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService 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.AccountReauthenticator
import com.osglab.account.features.account.AccountRepository
import com.osglab.account.features.account.AccountService import com.osglab.account.features.account.AccountService
import com.osglab.account.features.account.AppleAccountReauthenticator import com.osglab.account.features.account.AppleAccountReauthenticator
import com.osglab.account.features.account.AppleRevocationOutboxProcessor 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.InviteWebConfig
import com.osglab.account.features.inviteweb.ReferralLookupPort import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes 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.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService import com.osglab.account.features.referrals.services.ReferralService
import com.osglab.account.features.referrals.services.ReferralRiskIdentity import com.osglab.account.features.referrals.services.ReferralRiskIdentity
import com.osglab.account.features.referrals.services.ReferralRiskProvider import com.osglab.account.features.referrals.services.ReferralRiskProvider
import com.osglab.account.features.referrals.services.UserRegistrationTimeProvider 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.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
@@ -288,6 +295,7 @@ fun Application.module() {
accountRoutes(koin.get()) accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get()) creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get()) referralRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get(), koin.get())
} }
rateLimit(GATEWAY_RATE_LIMIT) { rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes( configureGatewayRoutes(
@@ -431,6 +439,23 @@ fun accountServerModule(config: AppConfig): Module = module {
) )
} }
single<CreditOperations> { get<CreditService>() } single<CreditOperations> { get<CreditService>() }
single<StoreKitTransactionVerifier> {
if (config.storeKit.enabled) {
AppleStoreKitTransactionVerifier(
bundleId = config.storeKit.bundleId,
appAppleId = requireNotNull(config.storeKit.appAppleId),
)
} else {
StoreKitTransactionVerifier { throw StoreKitUnavailable() }
}
}
single {
StoreKitService(
products = if (config.storeKit.enabled) config.storeKit.products else emptyList(),
verifier = get(),
transactions = get(),
)
}
single<TrialCreditGranter> { single<TrialCreditGranter> {
TrialCreditGranter { accountId -> TrialCreditGranter { accountId ->
get<CreditService>().grantSignupTrial( get<CreditService>().grantSignupTrial(
@@ -454,7 +479,7 @@ fun accountServerModule(config: AppConfig): Module = module {
single<GatewayGrantPort> { get<ExposedGatewayRepository>() } single<GatewayGrantPort> { get<ExposedGatewayRepository>() }
single<GatewayUsagePort> { get<ExposedGatewayRepository>() } single<GatewayUsagePort> { get<ExposedGatewayRepository>() }
single<AccountProvisioner> { single<AccountProvisioner> {
AccountProvisioner { accountId, deviceCheckToken -> AccountProvisioner { accountId, deviceCheckToken, displayName ->
val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken) val granted = get<DeviceCheckTrialService>().claimAndGrant(accountId, deviceCheckToken)
if (deviceCheckToken != null && !granted) { if (deviceCheckToken != null && !granted) {
get<AuthRepository>().restrictAccountForAntiAbuse( get<AuthRepository>().restrictAccountForAntiAbuse(
@@ -462,6 +487,14 @@ fun accountServerModule(config: AppConfig): Module = module {
java.time.Instant.now(), java.time.Instant.now(),
) )
} }
get<AccountService>().seedDisplayName(accountId, displayName)
try {
get<ReferralOperations>().getOrCreateCode(accountId)
} catch (exception: CancellationException) {
throw exception
} catch (_: ReferralException) {
// Referral eligibility must not make account sign-in unavailable.
}
} }
} }
single { single {
@@ -494,6 +527,7 @@ fun accountServerModule(config: AppConfig): Module = module {
reauthenticator = get(), reauthenticator = get(),
) )
} }
single<AccountOperations> { get<AccountService>() }
single<UserRegistrationTimeProvider> { single<UserRegistrationTimeProvider> {
UserRegistrationTimeProvider { accountId -> UserRegistrationTimeProvider { accountId ->
@@ -1,5 +1,6 @@
package com.osglab.account.config package com.osglab.account.config
import com.osglab.account.features.storekit.domain.StoreKitProduct
import io.ktor.server.config.ApplicationConfig import io.ktor.server.config.ApplicationConfig
import java.net.URI import java.net.URI
import java.util.Base64 import java.util.Base64
@@ -16,6 +17,7 @@ data class AppConfig(
val antiAbuse: AntiAbuseConfig, val antiAbuse: AntiAbuseConfig,
val apple: AppleConfig, val apple: AppleConfig,
val credits: CreditsConfig, val credits: CreditsConfig,
val storeKit: StoreKitConfig = StoreKitConfig(),
val providers: ProvidersConfig, val providers: ProvidersConfig,
val integrity: IntegrityConfig, val integrity: IntegrityConfig,
val admin: AdminConfig = AdminConfig(), val admin: AdminConfig = AdminConfig(),
@@ -76,10 +78,32 @@ data class AppConfig(
) )
val credits = CreditsConfig( val credits = CreditsConfig(
signupTrial = config.positiveLong("app.credits.signupTrial", 1_000), signupTrial = config.positiveLong("app.credits.signupTrial", 1_000),
referralInviter = config.positiveLong("app.credits.referralInviter", 3_000), referralInviter = config.positiveLong("app.credits.referralInviter", 1_000),
referralInvitee = config.positiveLong("app.credits.referralInvitee", 3_000), referralInvitee = config.positiveLong("app.credits.referralInvitee", 1_000),
referralBindingDays = config.positiveLong("app.credits.referralBindingDays", 7), 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( val providers = ProvidersConfig(
volcengine = VolcengineConfig( volcengine = VolcengineConfig(
endpoint = config.valueOrDefault( endpoint = config.valueOrDefault(
@@ -294,6 +318,7 @@ data class AppConfig(
antiAbuse = antiAbuse, antiAbuse = antiAbuse,
apple = apple, apple = apple,
credits = credits, credits = credits,
storeKit = storeKit,
providers = providers, providers = providers,
integrity = integrity, integrity = integrity,
admin = admin, admin = admin,
@@ -359,6 +384,13 @@ data class CreditsConfig(
val referralBindingDays: Long, val referralBindingDays: Long,
) )
data class StoreKitConfig(
val enabled: Boolean = false,
val bundleId: String = "com.osgkeyboard.ios",
val appAppleId: Long? = null,
val products: List<StoreKitProduct> = emptyList(),
)
data class ProvidersConfig( data class ProvidersConfig(
val volcengine: VolcengineConfig, val volcengine: VolcengineConfig,
val deepSeek: DeepSeekConfig, val deepSeek: DeepSeekConfig,
@@ -492,6 +524,24 @@ private fun ApplicationConfig.positiveLong(path: String, default: Long): Long =
?: throw ConfigValidationException("$path must be a positive integer") ?: throw ConfigValidationException("$path must be a positive integer")
} ?: default } ?: default
private fun ApplicationConfig.storeKitProducts(path: String): List<StoreKitProduct> {
val raw = optionalValue(path) ?: return emptyList()
val products = raw.split(',').map { entry ->
val parts = entry.split(':', limit = 2).map(String::trim)
if (parts.size != 2) {
throw ConfigValidationException("$path must use productId:credits entries")
}
val credits = parts[1].toLongOrNull()?.takeIf { it > 0 }
?: throw ConfigValidationException("$path credits must be positive integers")
runCatching { StoreKitProduct(productId = parts[0], credits = credits) }
.getOrElse { throw ConfigValidationException("$path contains an invalid product", it) }
}
if (products.map(StoreKitProduct::productId).distinct().size != products.size) {
throw ConfigValidationException("$path contains duplicate product IDs")
}
return products
}
private fun ApplicationConfig.boolean(path: String): Boolean = private fun ApplicationConfig.boolean(path: String): Boolean =
required(path).let { required(path).let {
when (it.lowercase()) { when (it.lowercase()) {
@@ -26,11 +26,13 @@ data class AccountRecord(
val identityFingerprint: String, val identityFingerprint: String,
val antiAbuseRestricted: Boolean, val antiAbuseRestricted: Boolean,
val encryptedAppleRefreshToken: String?, val encryptedAppleRefreshToken: String?,
val encryptedDisplayName: String? = null,
val createdAt: Instant, val createdAt: Instant,
) { ) {
override fun toString(): String = override fun toString(): String =
"AccountRecord(id=$id, identityFingerprint=[REDACTED], " + "AccountRecord(id=$id, identityFingerprint=[REDACTED], " +
"antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " + "antiAbuseRestricted=$antiAbuseRestricted, encryptedAppleRefreshToken=[REDACTED], " +
"encryptedDisplayName=[REDACTED], " +
"createdAt=$createdAt)" "createdAt=$createdAt)"
} }
@@ -61,8 +63,18 @@ internal object AppleRevocationOutboxTable : Table("apple_revocation_outbox") {
override val primaryKey = PrimaryKey(id) 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 { interface AccountRepository {
suspend fun findById(accountId: UUID): AccountRecord? 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( suspend fun deleteById(
accountId: UUID, accountId: UUID,
deletedAt: Instant, deletedAt: Instant,
@@ -89,17 +101,58 @@ class ExposedAccountRepository(
.where { AppleCredentialsTable.accountId eq accountId.toString() } .where { AppleCredentialsTable.accountId eq accountId.toString() }
.singleOrNull() .singleOrNull()
?.get(AppleCredentialsTable.encryptedRefreshToken) ?.get(AppleCredentialsTable.encryptedRefreshToken)
val encryptedDisplayName = AccountProfilesTable.selectAll()
.where { AccountProfilesTable.accountId eq accountId.toString() }
.singleOrNull()
?.get(AccountProfilesTable.encryptedDisplayName)
row.let { row.let {
AccountRecord( AccountRecord(
id = UUID.fromString(it[AccountsTable.id]), id = UUID.fromString(it[AccountsTable.id]),
identityFingerprint = fingerprint, identityFingerprint = fingerprint,
antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted], antiAbuseRestricted = it[AccountsTable.antiAbuseRestricted],
encryptedAppleRefreshToken = encryptedRefreshToken, encryptedAppleRefreshToken = encryptedRefreshToken,
encryptedDisplayName = encryptedDisplayName,
createdAt = it[AccountsTable.createdAt], 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( override suspend fun deleteById(
accountId: UUID, accountId: UUID,
deletedAt: Instant, deletedAt: Instant,
@@ -12,6 +12,7 @@ import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.patch
import io.ktor.server.routing.route import io.ktor.server.routing.route
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@@ -19,8 +20,12 @@ import kotlinx.serialization.Serializable
data class AccountResponse( data class AccountResponse(
val id: String, val id: String,
val createdAtEpochSeconds: Long, val createdAtEpochSeconds: Long,
val displayName: String?,
) )
@Serializable
data class UpdateAccountProfileRequest(val displayName: String)
@Serializable @Serializable
data class DeleteAccountRequest( data class DeleteAccountRequest(
val identityToken: String, val identityToken: String,
@@ -39,7 +44,7 @@ data class DeleteAccountRequest(
} }
class AccountRoutes( class AccountRoutes(
private val accountService: AccountService, private val accountService: AccountOperations,
) { ) {
fun register(parent: Route) { fun register(parent: Route) {
with(parent) { with(parent) {
@@ -49,14 +54,17 @@ class AccountRoutes(
val principal = call.principal<AccountPrincipal>() val principal = call.principal<AccountPrincipal>()
?: throw UnauthorizedException() ?: throw UnauthorizedException()
val account = accountService.get(principal.userId) val account = accountService.get(principal.userId)
call.respond( call.respond(ApiResponse(data = account.toResponse()))
ApiResponse( }
data = AccountResponse( patch {
id = account.id.toString(), val principal = call.principal<AccountPrincipal>()
createdAtEpochSeconds = account.createdAt.epochSecond, ?: throw UnauthorizedException()
), val request = call.receive<UpdateAccountProfileRequest>()
), val account = accountService.updateDisplayName(
principal.userId,
request.displayName,
) )
call.respond(ApiResponse(data = account.toResponse()))
} }
delete { delete {
val principal = call.principal<AccountPrincipal>() val principal = call.principal<AccountPrincipal>()
@@ -73,5 +81,11 @@ class AccountRoutes(
} }
} }
fun Route.accountRoutes(accountService: AccountService) = fun Route.accountRoutes(accountService: AccountOperations) =
AccountRoutes(accountService).register(this) AccountRoutes(accountService).register(this)
private fun AccountView.toResponse() = AccountResponse(
id = id.toString(),
createdAtEpochSeconds = createdAt.epochSecond,
displayName = displayName,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.account package com.osglab.account.features.account
import com.osglab.account.common.errors.ExternalServiceUnavailableException 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.errors.UnauthorizedException
import com.osglab.account.common.security.FieldDecryptionException import com.osglab.account.common.security.FieldDecryptionException
import com.osglab.account.common.security.FieldEncryptor import com.osglab.account.common.security.FieldEncryptor
@@ -16,11 +17,13 @@ import kotlinx.coroutines.CancellationException
import java.time.Clock import java.time.Clock
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.text.Normalizer
import java.util.UUID import java.util.UUID
data class AccountView( data class AccountView(
val id: UUID, val id: UUID,
val createdAt: Instant, val createdAt: Instant,
val displayName: String?,
) )
data class AppleReauthenticationProof( data class AppleReauthenticationProof(
@@ -41,6 +44,13 @@ fun interface AccountReauthenticator {
suspend fun verify(account: AccountRecord, proof: AppleReauthenticationProof): String 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( class AppleAccountReauthenticator(
private val identityVerifier: AppleIdentityTokenVerifier, private val identityVerifier: AppleIdentityTokenVerifier,
private val appleTokenClient: AppleTokenClient, private val appleTokenClient: AppleTokenClient,
@@ -94,13 +104,34 @@ class AccountService(
private val revocationProcessor: AppleRevocationOutboxProcessor, private val revocationProcessor: AppleRevocationOutboxProcessor,
private val reauthenticator: AccountReauthenticator, private val reauthenticator: AccountReauthenticator,
private val clock: Clock = Clock.systemUTC(), private val clock: Clock = Clock.systemUTC(),
) { ) : AccountOperations {
suspend fun get(accountId: UUID): AccountView { override suspend fun get(accountId: UUID): AccountView {
val account = repository.findById(accountId) ?: throw UnauthorizedException() 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 account = repository.findById(accountId) ?: return
val now = clock.instant() val now = clock.instant()
val currentRefreshToken = reauthenticator.verify(account, proof) val currentRefreshToken = reauthenticator.verify(account, proof)
@@ -126,6 +157,14 @@ class AccountService(
// Local deletion is final. The durable outbox retry loop handles Apple outages. // 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( class AppleRevocationOutboxProcessor(
@@ -175,3 +214,24 @@ class AppleRevocationOutboxProcessor(
} }
fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id" fun appleRevocationContext(id: UUID): String = "apple-revocation-outbox:$id"
private fun accountProfileContext(id: UUID): String = "account-profile:$id"
private fun normalizedDisplayNameOrNull(candidate: String): String? =
runCatching { normalizedDisplayName(candidate) }.getOrNull()
private fun normalizedDisplayName(candidate: String): String {
val normalized = Normalizer.normalize(candidate.trim(), Normalizer.Form.NFC)
.replace(WHITESPACE_REGEX, " ")
if (
normalized.isBlank() ||
normalized.codePointCount(0, normalized.length) > MAX_DISPLAY_NAME_CODE_POINTS ||
normalized.any { it.isISOControl() }
) {
throw InvalidRequestException("Display name is invalid")
}
return normalized
}
private val WHITESPACE_REGEX = Regex("\\s+")
private const val MAX_DISPLAY_NAME_CODE_POINTS = 64
@@ -21,12 +21,14 @@ data class AppleSignInRequest(
val identityToken: String, val identityToken: String,
val authorizationCode: String, val authorizationCode: String,
val nonce: String, val nonce: String,
val displayName: String? = null,
val deviceCheckToken: String? = null, val deviceCheckToken: String? = null,
val appAttest: AppAttestRequest? = null, val appAttest: AppAttestRequest? = null,
) { ) {
override fun toString(): String = override fun toString(): String =
"AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " + "AppleSignInRequest(identityToken=[REDACTED], authorizationCode=[REDACTED], " +
"nonce=[REDACTED], deviceCheckToken=[REDACTED], appAttest=[REDACTED])" "nonce=[REDACTED], displayName=[REDACTED], deviceCheckToken=[REDACTED], " +
"appAttest=[REDACTED])"
} }
@Serializable @Serializable
@@ -73,6 +75,7 @@ class AuthRoutes(
identityToken = request.identityToken, identityToken = request.identityToken,
authorizationCode = request.authorizationCode, authorizationCode = request.authorizationCode,
nonce = request.nonce, nonce = request.nonce,
displayName = request.displayName,
integrityEvidence = IntegrityEvidence( integrityEvidence = IntegrityEvidence(
deviceCheckToken = request.deviceCheckToken, deviceCheckToken = request.deviceCheckToken,
appAttest = request.appAttest?.let { appAttest = request.appAttest?.let {
@@ -34,7 +34,7 @@ data class SessionTokens(
} }
fun interface AccountProvisioner { fun interface AccountProvisioner {
suspend fun provision(accountId: UUID, deviceCheckToken: String?) suspend fun provision(accountId: UUID, deviceCheckToken: String?, displayName: String?)
} }
class SessionService( class SessionService(
@@ -46,7 +46,7 @@ class SessionService(
private val fieldEncryptor: FieldEncryptor, private val fieldEncryptor: FieldEncryptor,
private val identityFingerprint: IdentityFingerprint, private val identityFingerprint: IdentityFingerprint,
private val sessionConfig: SessionConfig, private val sessionConfig: SessionConfig,
private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _ -> }, private val accountProvisioner: AccountProvisioner = AccountProvisioner { _, _, _ -> },
private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(), private val tokenGenerator: SecureTokenGenerator = Sha256SecureTokenGenerator(),
private val clock: Clock = Clock.systemUTC(), private val clock: Clock = Clock.systemUTC(),
) { ) {
@@ -55,6 +55,7 @@ class SessionService(
authorizationCode: String, authorizationCode: String,
nonce: String, nonce: String,
integrityEvidence: IntegrityEvidence, integrityEvidence: IntegrityEvidence,
displayName: String? = null,
): SessionTokens { ): SessionTokens {
requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH) requireValue(identityToken, "identityToken", MAX_IDENTITY_TOKEN_LENGTH)
requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH) requireValue(authorizationCode, "authorizationCode", MAX_AUTHORIZATION_CODE_LENGTH)
@@ -90,6 +91,7 @@ class SessionService(
accountProvisioner.provision( accountProvisioner.provision(
account.id, account.id,
verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted }, verifiedIntegrity.deviceCheckTokenForTrial.takeUnless { account.antiAbuseRestricted },
displayName,
) )
return createSession(account.id, now) return createSession(account.id, now)
} }
@@ -51,6 +51,11 @@ data class CreditAccount(
val updatedAt: Instant, val updatedAt: Instant,
) )
data class CreditAccountSummary(
val account: CreditAccount,
val lifetimeUsed: Long,
)
data class LedgerEntry( data class LedgerEntry(
val id: UUID, val id: UUID,
val userId: UUID, val userId: UUID,
@@ -1,6 +1,6 @@
package com.osglab.account.features.credits.models 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.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.InvalidCreditRequest
@@ -64,13 +64,15 @@ data class SettleCreditsRequest(
data class CreditAccountDto( data class CreditAccountDto(
val userId: String, val userId: String,
val balance: Long, val balance: Long,
val lifetimeUsed: Long,
val updatedAt: String, val updatedAt: String,
) { ) {
companion object { companion object {
fun fromDomain(account: CreditAccount) = CreditAccountDto( fun fromDomain(summary: CreditAccountSummary) = CreditAccountDto(
userId = account.userId.toString(), userId = summary.account.userId.toString(),
balance = account.balance, balance = summary.account.balance,
updatedAt = account.updatedAt.toString(), lifetimeUsed = summary.lifetimeUsed,
updatedAt = summary.account.updatedAt.toString(),
) )
} }
} }
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.UsageKind import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.referrals.repositories.ReferralsRepository import com.osglab.account.features.referrals.repositories.ReferralsRepository
import com.osglab.account.features.storekit.repositories.StoreKitRepository
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@@ -28,6 +29,8 @@ interface CreditsRepository {
fun insertUsageRecord(record: CreditUsageRecord) fun insertUsageRecord(record: CreditUsageRecord)
fun lifetimeUsedCredits(userId: UUID): Long
fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation? fun findReservationByReserveKey(userId: UUID, idempotencyKey: String): CreditReservation?
fun lockReservation(id: UUID): CreditReservation? fun lockReservation(id: UUID): CreditReservation?
@@ -52,6 +55,7 @@ interface BillingUnitOfWork {
val credits: CreditsRepository val credits: CreditsRepository
val referrals: ReferralsRepository val referrals: ReferralsRepository
val adminCreditGrants: AdminCreditGrantRepository val adminCreditGrants: AdminCreditGrantRepository
val storeKit: StoreKitRepository
} }
interface BillingTransactionRunner { interface BillingTransactionRunner {
@@ -19,6 +19,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
import com.osglab.account.features.referrals.domain.ReferralCode import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.* 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.andWhere
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore 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.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
@@ -184,6 +187,7 @@ private object ExposedBillingUnitOfWork : BillingUnitOfWork {
override val credits: CreditsRepository = ExposedCreditsRepository override val credits: CreditsRepository = ExposedCreditsRepository
override val referrals: ReferralsRepository = ExposedReferralsRepository override val referrals: ReferralsRepository = ExposedReferralsRepository
override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository override val adminCreditGrants: AdminCreditGrantRepository = ExposedAdminCreditGrantRepository
override val storeKit: StoreKitRepository = ExposedStoreKitRepository
} }
private object ExposedCreditsRepository : CreditsRepository { 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( override fun findReservationByReserveKey(
userId: UUID, userId: UUID,
idempotencyKey: String, idempotencyKey: String,
@@ -36,7 +36,7 @@ class CreditRouteInstaller(
parent.route("/v1/credits") { parent.route("/v1/credits") {
get("/balance") { get("/balance") {
call.creditCall(authenticatedUser) { userId -> call.creditCall(authenticatedUser) { userId ->
CreditAccountDto.fromDomain(service.getAccount(userId)) CreditAccountDto.fromDomain(service.getAccountSummary(userId))
} }
} }
get("/ledger") { get("/ledger") {
@@ -4,6 +4,7 @@ import com.osglab.account.features.admin.models.AdminAuditAction
import com.osglab.account.features.admin.models.AdminAuditOutcome import com.osglab.account.features.admin.models.AdminAuditOutcome
import com.osglab.account.features.admin.models.NewAdminAuditEvent import com.osglab.account.features.admin.models.NewAdminAuditEvent
import com.osglab.account.features.credits.domain.CreditAccount 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.CreditConflict
import com.osglab.account.features.credits.domain.CreditCostCalculator import com.osglab.account.features.credits.domain.CreditCostCalculator
import com.osglab.account.features.credits.domain.CreditNotFound import com.osglab.account.features.credits.domain.CreditNotFound
@@ -46,6 +47,8 @@ data class ReferralRewardConfig(
interface CreditOperations { interface CreditOperations {
suspend fun getAccount(userId: UUID): CreditAccount suspend fun getAccount(userId: UUID): CreditAccount
suspend fun getAccountSummary(userId: UUID): CreditAccountSummary
suspend fun listEffectiveRates(): List<CreditRateVersion> suspend fun listEffectiveRates(): List<CreditRateVersion>
suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry> suspend fun listLedger(userId: UUID, limit: Int = 50): List<LedgerEntry>
@@ -107,6 +110,15 @@ class CreditService(
unit.credits.lockAccount(userId) unit.credits.lockAccount(userId)
} }
override suspend fun getAccountSummary(userId: UUID): CreditAccountSummary =
transactions.inTransaction { unit ->
unit.credits.createAccountIfAbsent(userId, clock.instant())
CreditAccountSummary(
account = unit.credits.lockAccount(userId),
lifetimeUsed = unit.credits.lifetimeUsedCredits(userId),
)
}
override suspend fun listEffectiveRates(): List<CreditRateVersion> = override suspend fun listEffectiveRates(): List<CreditRateVersion> =
transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) } transactions.inTransaction { it.credits.listEffectiveRates(clock.instant()) }
@@ -22,6 +22,7 @@ import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.time.Clock import java.time.Clock
@@ -283,10 +284,13 @@ class AppAttestService(
) )
IntegrityVerification.Verified IntegrityVerification.Verified
} catch (exception: AppAttestRejectedException) { } catch (exception: AppAttestRejectedException) {
APP_ATTEST_LOG.warn("App Attest assertion rejected: {}", exception.message)
IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion") IntegrityVerification.Rejected(exception.message ?: "App Attest rejected the assertion")
} catch (exception: InvalidRequestException) { } catch (exception: InvalidRequestException) {
APP_ATTEST_LOG.warn("App Attest assertion request rejected: {}", exception.message)
IntegrityVerification.Rejected(exception.message) IntegrityVerification.Rejected(exception.message)
} catch (exception: AppAttestUnavailableException) { } catch (exception: AppAttestUnavailableException) {
APP_ATTEST_LOG.error("App Attest assertion verification unavailable", exception)
IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable") IntegrityVerification.Unavailable(exception.message ?: "App Attest verification is unavailable")
} catch (exception: CancellationException) { } catch (exception: CancellationException) {
throw exception throw exception
@@ -392,6 +396,7 @@ class AppAttestService(
const val SHA256_BYTES = 32 const val SHA256_BYTES = 32
const val MAX_ATTESTATION_BYTES = 256 * 1024 const val MAX_ATTESTATION_BYTES = 256 * 1024
const val MAX_ASSERTION_BYTES = 64 * 1024 const val MAX_ASSERTION_BYTES = 64 * 1024
val APP_ATTEST_LOG = LoggerFactory.getLogger(AppAttestService::class.java)
} }
} }
@@ -251,11 +251,11 @@ class LibraryAppAttestCrypto(
} catch (exception: Exception) { } catch (exception: Exception) {
throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception) throw AppAttestUnavailableException("Stored App Attest public key is invalid", exception)
} }
val signedBytes = authenticatorDataBytes + clientDataHash val nonce = sha256(authenticatorDataBytes + clientDataHash)
val verified = try { val verified = try {
Signature.getInstance("SHA256withECDSA").run { Signature.getInstance("SHA256withECDSA").run {
initVerify(key) initVerify(key)
update(signedBytes) update(nonce)
verify(signatureBytes) verify(signatureBytes)
} }
} catch (exception: Exception) { } catch (exception: Exception) {
@@ -299,9 +299,10 @@ class LibraryAppAttestCrypto(
val rpHash = ByteArray(SHA256_BYTES).also(buffer::get) val rpHash = ByteArray(SHA256_BYTES).also(buffer::get)
val flags = buffer.get().toInt() and 0xff val flags = buffer.get().toInt() and 0xff
val count = buffer.int.toLong() and UINT32_MASK val count = buffer.int.toLong() and UINT32_MASK
if ((flags and FLAG_ATTESTED_CREDENTIAL_DATA) != 0 || // Production App Attest assertions can set AT while still using
(flags and FLAG_EXTENSION_DATA) != 0 // 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") throw AppAttestRejectedException("App Attest assertion flags are invalid")
} }
return AssertionAuthenticatorData(rpHash, flags, count) return AssertionAuthenticatorData(rpHash, flags, count)
@@ -32,6 +32,7 @@ import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insertIgnore import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import org.slf4j.LoggerFactory
import java.security.KeyFactory import java.security.KeyFactory
import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.PKCS8EncodedKeySpec
@@ -233,7 +234,7 @@ class KtorAppleDeviceCheckClient(
private companion object { private companion object {
const val MAX_DEVICE_TOKEN_LENGTH = 8_192 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" const val BIT_STATE_NOT_FOUND_RESPONSE = "Failed to find bit state"
val JSON = Json { ignoreUnknownKeys = true } val JSON = Json { ignoreUnknownKeys = true }
} }
@@ -263,8 +264,13 @@ class RemoteDeviceCheckVerifier(
} catch (exception: DeviceCheckRejectedException) { } catch (exception: DeviceCheckRejectedException) {
IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token") IntegrityVerification.Rejected(exception.message ?: "DeviceCheck rejected the token")
} catch (exception: DeviceCheckUnavailableException) { } catch (exception: DeviceCheckUnavailableException) {
LOG.warn("DeviceCheck verification unavailable: {}", exception.message)
IntegrityVerification.Unavailable(exception.message ?: "DeviceCheck is unavailable") 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) : open class DeviceCheckException(message: String, cause: Throwable? = null) :
@@ -177,13 +177,15 @@ class ReferralService(
} }
} }
override suspend fun getProfile(userId: UUID): ReferralProfile = override suspend fun getProfile(userId: UUID): ReferralProfile {
transactions.inTransaction { unit -> val activeCode = getOrCreateCode(userId)
return transactions.inTransaction { unit ->
ReferralProfile( ReferralProfile(
code = unit.referrals.findCodeByOwner(userId), code = activeCode,
binding = unit.referrals.findBinding(userId), binding = unit.referrals.findBinding(userId),
) )
} }
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> = override suspend fun listActiveCampaigns(): List<ReferralCampaign> =
transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) } transactions.inTransaction { it.referrals.listActiveCampaigns(clock.instant()) }
@@ -0,0 +1,66 @@
package com.osglab.account.features.storekit.domain
import java.time.Instant
import java.util.UUID
enum class StoreKitEnvironment {
SANDBOX,
PRODUCTION,
}
data class StoreKitProduct(
val productId: String,
val credits: Long,
) {
init {
require(PRODUCT_ID.matches(productId)) { "StoreKit product ID is invalid" }
require(credits > 0) { "StoreKit product credits must be positive" }
}
private companion object {
val PRODUCT_ID = Regex("[A-Za-z0-9._-]{3,128}")
}
}
data class VerifiedStoreKitTransaction(
val transactionId: String,
val originalTransactionId: String,
val appAccountToken: UUID,
val productId: String,
val environment: StoreKitEnvironment,
val purchasedAt: Instant,
val signedAt: Instant,
val revokedAt: Instant?,
)
data class StoreKitCreditPurchase(
val id: UUID,
val transactionId: String,
val originalTransactionId: String,
val userId: UUID,
val appAccountToken: UUID,
val productId: String,
val environment: StoreKitEnvironment,
val creditsGranted: Long,
val ledgerEntryId: UUID,
val signedTransactionSha256: String,
val purchasedAt: Instant,
val signedAt: Instant,
val createdAt: Instant,
)
data class StoreKitPurchaseResult(
val purchase: StoreKitCreditPurchase,
val balanceAfter: Long,
val replayed: Boolean,
)
sealed class StoreKitException(message: String) : RuntimeException(message)
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
class InvalidStoreKitRequest(message: String) : StoreKitException(message)
class StoreKitVerificationFailed : StoreKitException("The App Store transaction could not be verified")
class StoreKitPurchaseConflict : StoreKitException("The App Store transaction conflicts with an existing purchase")
@@ -0,0 +1,42 @@
package com.osglab.account.features.storekit.models
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import kotlinx.serialization.Serializable
@Serializable
data class StoreKitProductDto(
val productId: String,
val credits: Long,
) {
companion object {
fun fromDomain(product: StoreKitProduct): StoreKitProductDto =
StoreKitProductDto(product.productId, product.credits)
}
}
@Serializable
data class StoreKitSubmitRequest(
val signedTransaction: String,
)
@Serializable
data class StoreKitPurchaseResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val replayed: Boolean,
) {
companion object {
fun fromDomain(result: StoreKitPurchaseResult): StoreKitPurchaseResponse =
StoreKitPurchaseResponse(
transactionId = result.purchase.transactionId,
productId = result.purchase.productId,
creditsGranted = result.purchase.creditsGranted,
balanceAfter = result.balanceAfter,
replayed = result.replayed,
)
}
}
@@ -0,0 +1,77 @@
package com.osglab.account.features.storekit.repositories
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import java.util.UUID
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
interface StoreKitRepository {
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
fun insert(purchase: StoreKitCreditPurchase)
}
object ExposedStoreKitRepository : StoreKitRepository {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
StoreKitCreditPurchases
.selectAll()
.where { StoreKitCreditPurchases.transactionId eq transactionId }
.singleOrNull()
?.toStoreKitCreditPurchase()
override fun insert(purchase: StoreKitCreditPurchase) {
StoreKitCreditPurchases.insert {
it[id] = purchase.id.toString()
it[transactionId] = purchase.transactionId
it[originalTransactionId] = purchase.originalTransactionId
it[userId] = purchase.userId.toString()
it[appAccountToken] = purchase.appAccountToken.toString()
it[productId] = purchase.productId
it[environment] = purchase.environment
it[creditsGranted] = purchase.creditsGranted
it[ledgerEntryId] = purchase.ledgerEntryId.toString()
it[signedTransactionSha256] = purchase.signedTransactionSha256
it[purchasedAt] = purchase.purchasedAt
it[signedAt] = purchase.signedAt
it[createdAt] = purchase.createdAt
}
}
}
private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
val id = varchar("id", 36)
val transactionId = varchar("transaction_id", 64)
val originalTransactionId = varchar("original_transaction_id", 64)
val userId = varchar("user_id", 36)
val appAccountToken = varchar("app_account_token", 36)
val productId = varchar("product_id", 128)
val environment = enumerationByName<StoreKitEnvironment>("environment", 16)
val creditsGranted = long("credits_granted")
val ledgerEntryId = varchar("ledger_entry_id", 36)
val signedTransactionSha256 = char("signed_transaction_sha256", 64)
val purchasedAt = timestamp("purchased_at")
val signedAt = timestamp("signed_at")
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(id)
}
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
StoreKitCreditPurchase(
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
transactionId = this[StoreKitCreditPurchases.transactionId],
originalTransactionId = this[StoreKitCreditPurchases.originalTransactionId],
userId = UUID.fromString(this[StoreKitCreditPurchases.userId]),
appAccountToken = UUID.fromString(this[StoreKitCreditPurchases.appAccountToken]),
productId = this[StoreKitCreditPurchases.productId],
environment = this[StoreKitCreditPurchases.environment],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
ledgerEntryId = UUID.fromString(this[StoreKitCreditPurchases.ledgerEntryId]),
signedTransactionSha256 = this[StoreKitCreditPurchases.signedTransactionSha256],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
signedAt = this[StoreKitCreditPurchases.signedAt],
createdAt = this[StoreKitCreditPurchases.createdAt],
)
@@ -0,0 +1,89 @@
package com.osglab.account.features.storekit.routes
import com.osglab.account.common.api.ApiError
import com.osglab.account.common.api.ApiErrorResponse
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.models.StoreKitProductDto
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
import com.osglab.account.features.storekit.services.StoreKitService
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
fun Route.storeKitRoutes(
service: StoreKitService,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
}
}
}
}
@@ -0,0 +1,160 @@
package com.osglab.account.features.storekit.services
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.LedgerEntry
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.UUID
class StoreKitService(
products: List<StoreKitProduct>,
private val verifier: StoreKitTransactionVerifier,
private val transactions: BillingTransactionRunner,
private val clock: Clock = Clock.systemUTC(),
private val newId: () -> UUID = UUID::randomUUID,
) {
private val productsById = products.associateBy(StoreKitProduct::productId)
init {
require(productsById.size == products.size) { "StoreKit product IDs must be unique" }
}
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
suspend fun submit(
userId: UUID,
signedTransaction: String,
): StoreKitPurchaseResult {
if (productsById.isEmpty()) {
throw StoreKitUnavailable()
}
if (
signedTransaction.length !in MIN_SIGNED_TRANSACTION_LENGTH..MAX_SIGNED_TRANSACTION_LENGTH ||
signedTransaction != signedTransaction.trim()
) {
throw InvalidStoreKitRequest("signedTransaction is invalid")
}
val verified = verifier.verify(signedTransaction)
val product = productsById[verified.productId] ?: throw StoreKitPurchaseConflict()
validateTransaction(userId, verified)
val digest = signedTransaction.sha256Hex()
val idempotencyKey = "storekit:${verified.transactionId}"
return transactions.inTransaction { unit ->
val now = clock.instant()
unit.credits.createAccountIfAbsent(userId, now)
val account = unit.credits.lockAccount(userId)
unit.storeKit.findByTransactionId(verified.transactionId)?.let { existing ->
requireReplayMatches(existing, verified, product)
val ledger = unit.credits.findLedgerEntry(userId, idempotencyKey)
?: throw StoreKitPurchaseConflict()
if (
ledger.id != existing.ledgerEntryId ||
ledger.type != LedgerEntryType.STOREKIT_PURCHASE ||
ledger.amountDelta != existing.creditsGranted ||
ledger.referenceId != existing.id
) {
throw StoreKitPurchaseConflict()
}
return@inTransaction StoreKitPurchaseResult(existing, ledger.balanceAfter, replayed = true)
}
if (unit.credits.findLedgerEntry(userId, idempotencyKey) != null) {
throw StoreKitPurchaseConflict()
}
val balanceAfter = try {
Math.addExact(account.balance, product.credits)
} catch (_: ArithmeticException) {
throw CreditConflict("StoreKit credit balance overflow")
}
val purchaseId = newId()
val ledgerEntryId = newId()
val purchase = StoreKitCreditPurchase(
id = purchaseId,
transactionId = verified.transactionId,
originalTransactionId = verified.originalTransactionId,
userId = userId,
appAccountToken = verified.appAccountToken,
productId = product.productId,
environment = verified.environment,
creditsGranted = product.credits,
ledgerEntryId = ledgerEntryId,
signedTransactionSha256 = digest,
purchasedAt = verified.purchasedAt,
signedAt = verified.signedAt,
createdAt = now,
)
unit.credits.updateAccountBalance(userId, balanceAfter, now)
unit.credits.insertLedgerEntry(
LedgerEntry(
id = ledgerEntryId,
userId = userId,
type = LedgerEntryType.STOREKIT_PURCHASE,
amountDelta = product.credits,
balanceAfter = balanceAfter,
idempotencyKey = idempotencyKey,
referenceId = purchaseId,
createdAt = now,
)
)
unit.storeKit.insert(purchase)
StoreKitPurchaseResult(purchase, balanceAfter, replayed = false)
}
}
private fun validateTransaction(
userId: UUID,
transaction: VerifiedStoreKitTransaction,
) {
if (transaction.appAccountToken != userId || transaction.revokedAt != null) {
throw StoreKitPurchaseConflict()
}
val now = clock.instant()
if (
transaction.purchasedAt > transaction.signedAt ||
transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
) {
throw StoreKitPurchaseConflict()
}
}
private fun requireReplayMatches(
existing: StoreKitCreditPurchase,
verified: VerifiedStoreKitTransaction,
product: StoreKitProduct,
) {
if (
existing.userId != verified.appAccountToken ||
existing.originalTransactionId != verified.originalTransactionId ||
existing.productId != verified.productId ||
existing.environment != verified.environment ||
existing.creditsGranted != product.credits ||
existing.purchasedAt != verified.purchasedAt
) {
throw StoreKitPurchaseConflict()
}
}
private fun String.sha256Hex(): String =
MessageDigest.getInstance("SHA-256")
.digest(toByteArray(Charsets.UTF_8))
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
private companion object {
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
}
}
@@ -0,0 +1,119 @@
package com.osglab.account.features.storekit.verification
import com.apple.itunes.storekit.model.Environment
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload
import com.apple.itunes.storekit.model.Type
import com.apple.itunes.storekit.verification.SignedDataVerifier
import com.apple.itunes.storekit.verification.VerificationException
import com.apple.itunes.storekit.verification.VerificationStatus
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import java.io.InputStream
import java.time.Instant
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
fun interface StoreKitTransactionVerifier {
suspend fun verify(signedTransaction: String): VerifiedStoreKitTransaction
}
class AppleStoreKitTransactionVerifier(
bundleId: String,
appAppleId: Long,
rootCertificateLoader: (String) -> InputStream? = {
AppleStoreKitTransactionVerifier::class.java.getResourceAsStream(it)
},
enableOnlineChecks: Boolean = true,
) : StoreKitTransactionVerifier {
private val production: SignedDataVerifier
private val sandbox: SignedDataVerifier
init {
val certificateBytes = ROOT_CERTIFICATES.map { path ->
rootCertificateLoader(path)?.use(InputStream::readAllBytes)
?: error("Missing Apple root certificate: $path")
}
fun verifier(environment: Environment): SignedDataVerifier {
val streams = certificateBytes.map(ByteArray::inputStream).toSet()
return SignedDataVerifier(
streams,
bundleId,
if (environment == Environment.PRODUCTION) appAppleId else null,
environment,
enableOnlineChecks,
).also { streams.forEach(InputStream::close) }
}
production = verifier(Environment.PRODUCTION)
sandbox = verifier(Environment.SANDBOX)
}
override suspend fun verify(
signedTransaction: String
): VerifiedStoreKitTransaction = withContext(Dispatchers.IO) {
if (signedTransaction.length !in MIN_JWS_LENGTH..MAX_JWS_LENGTH) {
throw StoreKitVerificationFailed()
}
var retryableFailure = false
val payload = try {
production.verifyAndDecodeTransaction(signedTransaction)
} catch (exception: VerificationException) {
retryableFailure = exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
null
} ?: try {
sandbox.verifyAndDecodeTransaction(signedTransaction)
} catch (exception: VerificationException) {
retryableFailure = retryableFailure ||
exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
null
}
if (payload == null && retryableFailure) {
throw StoreKitUnavailable()
}
if (payload == null) {
throw StoreKitVerificationFailed()
}
payload.toVerifiedTransaction()
}
private fun JWSTransactionDecodedPayload.toVerifiedTransaction(): VerifiedStoreKitTransaction {
val transaction = transactionId?.takeIf(TRANSACTION_ID::matches)
?: throw StoreKitVerificationFailed()
val original = originalTransactionId?.takeIf(TRANSACTION_ID::matches)
?: throw StoreKitVerificationFailed()
val accountToken = appAccountToken ?: throw StoreKitVerificationFailed()
val product = productId?.takeIf { it.length in 3..128 }
?: throw StoreKitVerificationFailed()
val purchaseMillis = purchaseDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
val signedMillis = signedDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
if (type != Type.CONSUMABLE || quantity != 1) {
throw StoreKitVerificationFailed()
}
val verifiedEnvironment = when (environment) {
Environment.PRODUCTION -> StoreKitEnvironment.PRODUCTION
Environment.SANDBOX -> StoreKitEnvironment.SANDBOX
else -> throw StoreKitVerificationFailed()
}
return VerifiedStoreKitTransaction(
transactionId = transaction,
originalTransactionId = original,
appAccountToken = accountToken,
productId = product,
environment = verifiedEnvironment,
purchasedAt = Instant.ofEpochMilli(purchaseMillis),
signedAt = Instant.ofEpochMilli(signedMillis),
revokedAt = revocationDate?.let(Instant::ofEpochMilli),
)
}
private companion object {
const val MIN_JWS_LENGTH = 100
const val MAX_JWS_LENGTH = 32_768
val TRANSACTION_ID = Regex("[0-9]{1,64}")
val ROOT_CERTIFICATES = listOf(
"/apple-pki/AppleRootCA-G2.cer",
"/apple-pki/AppleRootCA-G3.cer",
)
}
}
Binary file not shown.
Binary file not shown.
+7 -2
View File
@@ -49,9 +49,14 @@ app:
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke" revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
credits: credits:
signupTrial: "$SIGNUP_TRIAL_CREDITS:1000" signupTrial: "$SIGNUP_TRIAL_CREDITS:1000"
referralInviter: "$REFERRAL_INVITER_CREDITS:3000" referralInviter: "$REFERRAL_INVITER_CREDITS:1000"
referralInvitee: "$REFERRAL_INVITEE_CREDITS:3000" referralInvitee: "$REFERRAL_INVITEE_CREDITS:1000"
referralBindingDays: "$REFERRAL_BINDING_DAYS:7" 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: providers:
volcengine: volcengine:
endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel" endpoint: "$VOLCENGINE_ASR_ENDPOINT:wss://openspeech.bytedance.com/api/v3/sauc/bigmodel"
@@ -0,0 +1,60 @@
-- Close the initial immutable rates and activate the smaller credit unit.
-- ASR bills one credit per started three-second interval.
-- DeepSeek bills input/output dimensions independently and rounds each upward.
SET @new_credit_rate_effective_from = UTC_TIMESTAMP(6);
UPDATE credit_rate_versions
SET effective_until = @new_credit_rate_effective_from
WHERE id IN (
'10000000-0000-0000-0000-000000000001',
'10000000-0000-0000-0000-000000000002'
)
AND effective_until IS NULL;
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
asr_credits_numerator,
asr_millis_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000003',
'ASR',
'volcengine-sauc-v3',
'volc.seedasr.sauc.duration',
@new_credit_rate_effective_from,
NULL,
1,
3000,
@new_credit_rate_effective_from
);
INSERT INTO credit_rate_versions (
id,
kind,
provider,
model,
effective_from,
effective_until,
input_credits_numerator,
input_tokens_denominator,
output_credits_numerator,
output_tokens_denominator,
created_at
) VALUES (
'10000000-0000-0000-0000-000000000004',
'LLM',
'deepseek',
'deepseek-v4-flash',
@new_credit_rate_effective_from,
NULL,
1,
1000,
1,
400,
@new_credit_rate_effective_from
);
@@ -0,0 +1,9 @@
CREATE TABLE account_profiles (
account_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
encrypted_display_name MEDIUMTEXT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (account_id),
CONSTRAINT fk_account_profiles_account
FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE
) ENGINE = InnoDB;
@@ -0,0 +1,4 @@
UPDATE referral_campaigns
SET inviter_reward_credits = 1000,
invitee_reward_credits = 1000
WHERE id = '00000000-0000-0000-0000-000000000001';
@@ -0,0 +1,24 @@
CREATE TABLE storekit_credit_purchases (
id CHAR(36) NOT NULL,
transaction_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
original_transaction_id VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
app_account_token CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
product_id VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
environment VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
credits_granted BIGINT NOT NULL,
ledger_entry_id CHAR(36) NOT NULL,
signed_transaction_sha256 CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
purchased_at DATETIME(6) NOT NULL,
signed_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
UNIQUE KEY uk_storekit_purchase_transaction (transaction_id),
UNIQUE KEY uk_storekit_purchase_ledger (ledger_entry_id),
INDEX idx_storekit_purchase_user_created (user_id, created_at, id),
CONSTRAINT chk_storekit_purchase_credits CHECK (credits_granted > 0),
CONSTRAINT chk_storekit_purchase_environment
CHECK (environment IN ('SANDBOX', 'PRODUCTION')),
CONSTRAINT fk_storekit_purchase_ledger
FOREIGN KEY (ledger_entry_id) REFERENCES credit_ledger(id)
) ENGINE = InnoDB;
@@ -14,6 +14,9 @@ class AppConfigTest : FunSpec({
config.environment shouldBe Environment.TEST config.environment shouldBe Environment.TEST
config.apple.clientCredentialsAvailable shouldBe false config.apple.clientCredentialsAvailable shouldBe false
config.encryption.key.size shouldBe 32 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") { test("production rejects placeholder secrets") {
@@ -76,6 +79,43 @@ class AppConfigTest : FunSpec({
}.message.orEmpty() shouldContain "bootstrapEnabled requires" }.message.orEmpty() shouldContain "bootstrapEnabled requires"
} }
test("StoreKit requires an app identifier and dedicated credit product when enabled") {
val missingAppId = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
}
shouldThrow<IllegalArgumentException> {
AppConfig.from(missingAppId)
}.message.orEmpty() shouldContain "appAppleId is required"
val enabled = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.appAppleId", "6781553267")
put("app.storeKit.products", "500tks:500,1500tks:1500,3000tks:3000")
}
val storeKit = AppConfig.from(enabled).storeKit
storeKit.enabled shouldBe true
storeKit.appAppleId shouldBe 6_781_553_267
storeKit.products.map { it.productId to it.credits } shouldBe listOf(
"500tks" to 500,
"1500tks" to 1_500,
"3000tks" to 3_000,
)
}
test("StoreKit rejects duplicate product mappings") {
val config = validProductionConfig().apply {
put("app.storeKit.enabled", "true")
put("app.storeKit.appAppleId", "6781553267")
put("app.storeKit.products", "500tks:500,500tks:3000")
}
shouldThrow<ConfigValidationException> {
AppConfig.from(config)
}.message.orEmpty() shouldContain "duplicate product IDs"
}
test("production fails fast when Apple signing credentials are missing") { test("production fails fast when Apple signing credentials are missing") {
val config = validProductionConfig().apply { val config = validProductionConfig().apply {
put("app.apple.keyId", "") put("app.apple.keyId", "")
@@ -61,6 +61,44 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "unpadded Base64URL" 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") { test("production Compose reuses private MySQL and hardens the application container") {
val compose = root.read("compose.yaml") 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") { test("admin bootstrap is one-time and runtime database grants stay explicit") {
val compose = root.read("compose.yaml") val compose = root.read("compose.yaml")
val privileges = root.read("docs/mysql-minimum-privileges.sql") 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}" compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators" 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 INSERT ON osg_account.gateway_grant_scopes"
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens" privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
privileges shouldContain "GRANT INSERT, UPDATE 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_audit_log"
privileges shouldContain "GRANT INSERT ON osg_account.admin_credit_grants" 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 "UPDATE ON osg_account.admin_audit_log"
privileges shouldNotContain "DELETE ON osg_account.admin_credit_grants" 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") { 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/balance",
"/v1/credits/ledger", "/v1/credits/ledger",
"/v1/credits/rates", "/v1/credits/rates",
"/v1/storekit/products",
"/v1/storekit/transactions",
"/v1/referrals", "/v1/referrals",
"/v1/referrals/me", "/v1/referrals/me",
"/v1/referrals/code", "/v1/referrals/code",
@@ -37,9 +37,13 @@ class SmokeDeploymentTest : FunSpec({
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/" runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://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 "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 "first ledger page omitted nextCursor"
runner shouldContain "DELETE FROM admin_sessions WHERE expires_at < UTC_TIMESTAMP()" 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 "appleid.apple.com"
runner shouldNotContain "api.deepseek.com" runner shouldNotContain "api.deepseek.com"
runner shouldNotContain "openspeech.bytedance.com" runner shouldNotContain "openspeech.bytedance.com"
@@ -0,0 +1,94 @@
package com.osglab.account.features.account
import com.osglab.account.common.security.AccountPrincipal
import com.osglab.account.common.security.SESSION_AUTH_NAME
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.patch
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.bearer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import java.time.Instant
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlin.test.Test
class AccountRoutesTest {
private val accountId = UUID.fromString("10000000-0000-0000-0000-000000000010")
private val sessionId = UUID.fromString("20000000-0000-0000-0000-000000000010")
@Test
fun `profile read update and destructive deletion require the authenticated account`() =
testApplication {
val operations = RecordingAccountOperations(accountId)
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
install(Authentication) {
bearer(SESSION_AUTH_NAME) {
authenticate { AccountPrincipal(accountId, sessionId) }
}
}
routing { accountRoutes(operations) }
}
val profile = client.get("/v1/account") {
header(HttpHeaders.Authorization, "Bearer test")
}
val updated = client.patch("/v1/account") {
header(HttpHeaders.Authorization, "Bearer test")
contentType(ContentType.Application.Json)
setBody("""{"displayName":"OSG 用户"}""")
}
val deleted = client.delete("/v1/account") {
header(HttpHeaders.Authorization, "Bearer test")
contentType(ContentType.Application.Json)
setBody(
"""{"identityToken":"identity","authorizationCode":"code","nonce":"nonce"}""",
)
}
profile.status shouldBe HttpStatusCode.OK
profile.bodyAsText() shouldContain """"displayName":"Rocky""""
updated.status shouldBe HttpStatusCode.OK
updated.bodyAsText() shouldContain """"displayName":"OSG 用户""""
deleted.status shouldBe HttpStatusCode.NoContent
operations.deletedAccountId shouldBe accountId
operations.deletionProof shouldBe AppleReauthenticationProof("identity", "code", "nonce")
}
}
private class RecordingAccountOperations(
private val accountId: UUID,
) : AccountOperations {
private var displayName = "Rocky"
var deletedAccountId: UUID? = null
var deletionProof: AppleReauthenticationProof? = null
override suspend fun get(accountId: UUID): AccountView =
AccountView(accountId, Instant.EPOCH, displayName)
override suspend fun seedDisplayName(accountId: UUID, candidate: String?) = Unit
override suspend fun updateDisplayName(accountId: UUID, candidate: String): AccountView {
displayName = candidate
return get(accountId)
}
override suspend fun delete(accountId: UUID, proof: AppleReauthenticationProof) {
deletedAccountId = accountId
deletionProof = proof
}
}
@@ -14,6 +14,40 @@ import java.time.Instant
import java.util.UUID import java.util.UUID
class AccountServiceTest : FunSpec({ 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") { test("account deletion commits locally before reliably revoking the Apple token") {
val accountId = UUID.randomUUID() val accountId = UUID.randomUUID()
val encryptor = FieldEncryptor(ByteArray(32) { 5 }) val encryptor = FieldEncryptor(ByteArray(32) { 5 })
@@ -117,15 +151,34 @@ private val REAUTH_PROOF = AppleReauthenticationProof(
) )
private class RecordingAccountRepository( private class RecordingAccountRepository(
private val account: AccountRecord, account: AccountRecord,
private val events: MutableList<String>, private val events: MutableList<String>,
) : AccountRepository { ) : AccountRepository {
private var account = account
var deleted = false var deleted = false
private var pending: AppleRevocationOutboxRecord? = null private var pending: AppleRevocationOutboxRecord? = null
val pendingCount: Int get() = if (pending == null) 0 else 1 val pendingCount: Int get() = if (pending == null) 0 else 1
override suspend fun findById(accountId: UUID): AccountRecord? = account 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( override suspend fun deleteById(
accountId: UUID, accountId: UUID,
deletedAt: Instant, deletedAt: Instant,
@@ -48,6 +48,33 @@ class CreditServiceTest : FunSpec({
) shouldBeExactly 4 ) 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") { test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") {
CreditCostCalculator.calculate( CreditCostCalculator.calculate(
asrRate(now).copy( asrRate(now).copy(
@@ -602,6 +629,43 @@ class CreditServiceTest : FunSpec({
} shouldBe true } 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") { test("release and refund restore only the corresponding debit") {
val store = storeWithRates(now) val store = storeWithRates(now)
val service = service(store, now) val service = service(store, now)
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditRateVersion
import com.osglab.account.features.credits.domain.CreditReservation import com.osglab.account.features.credits.domain.CreditReservation
import com.osglab.account.features.credits.domain.CreditUsageRecord import com.osglab.account.features.credits.domain.CreditUsageRecord
import com.osglab.account.features.credits.domain.LedgerEntry 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.ManualCreditGrant
import com.osglab.account.features.credits.domain.UsageKind import com.osglab.account.features.credits.domain.UsageKind
import com.osglab.account.features.credits.repositories.BillingTransactionRunner 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.ReferralCode
import com.osglab.account.features.referrals.domain.ReferralRewardStatus import com.osglab.account.features.referrals.domain.ReferralRewardStatus
import com.osglab.account.features.referrals.repositories.ReferralsRepository 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.time.Instant
import java.util.UUID import java.util.UUID
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
@@ -37,6 +40,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val rates = mutableMapOf<UUID, CreditRateVersion>() val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>() val codes = mutableMapOf<UUID, ReferralCode>()
val bindings = mutableMapOf<UUID, ReferralBinding>() val bindings = mutableMapOf<UUID, ReferralBinding>()
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
val campaigns = mutableMapOf( val campaigns = mutableMapOf(
DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign( DEFAULT_REFERRAL_CAMPAIGN_ID to ReferralCampaign(
id = DEFAULT_REFERRAL_CAMPAIGN_ID, id = DEFAULT_REFERRAL_CAMPAIGN_ID,
@@ -63,6 +67,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
override val credits: CreditsRepository = Credits() override val credits: CreditsRepository = Credits()
override val referrals: ReferralsRepository = Referrals() override val referrals: ReferralsRepository = Referrals()
override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants() override val adminCreditGrants: AdminCreditGrantRepository = AdminCreditGrants()
override val storeKit: StoreKitRepository = StoreKitPurchases()
var failNextManualGrantInsert = false var failNextManualGrantInsert = false
@@ -78,6 +83,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val codeSnapshot = codes.toMap() val codeSnapshot = codes.toMap()
val bindingSnapshot = bindings.toMap() val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap() val budgetSnapshot = campaignBudgets.toMap()
val storeKitSnapshot = storeKitPurchases.toMap()
try { try {
block(this) block(this)
} catch (failure: Throwable) { } catch (failure: Throwable) {
@@ -91,6 +97,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
codes.replaceWith(codeSnapshot) codes.replaceWith(codeSnapshot)
bindings.replaceWith(bindingSnapshot) bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot) campaignBudgets.replaceWith(budgetSnapshot)
storeKitPurchases.replaceWith(storeKitSnapshot)
throw failure throw failure
} }
} }
@@ -134,6 +141,15 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
usageRecords += record 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( override fun findReservationByReserveKey(
userId: UUID, userId: UUID,
idempotencyKey: String, idempotencyKey: String,
@@ -271,6 +287,15 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET)) entry.setValue(entry.value.copy(rewardStatus = ReferralRewardStatus.INELIGIBLE_BUDGET))
} }
} }
private inner class StoreKitPurchases : StoreKitRepository {
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
storeKitPurchases[transactionId]
override fun insert(purchase: StoreKitCreditPurchase) {
check(storeKitPurchases.putIfAbsent(purchase.transactionId, purchase) == null)
}
}
} }
private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) { private fun <K, V> MutableMap<K, V>.replaceWith(snapshot: Map<K, V>) {
@@ -93,6 +93,40 @@ class AppAttestCryptoTest : FunSpec({
) )
} }
} }
test("assertion accepts the production fixed profile with AT set") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("production-request".toByteArray())
fixture.crypto().validateAssertion(
assertionObject = fixture.assertionObject(
counter = 1,
clientDataHash = hash,
flags = 0x40,
),
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 0,
) shouldBe 1L
}
test("assertion rejects the extension flag without extension data") {
val fixture = AppAttestFixture()
val hash = sha256ForTest("extension-request".toByteArray())
shouldThrow<AppAttestRejectedException> {
fixture.crypto().validateAssertion(
assertionObject = fixture.assertionObject(
counter = 1,
clientDataHash = hash,
flags = 0x80,
),
clientDataHash = hash,
publicKey = fixture.keyPair.public.encoded,
lastCounter = 0,
)
}
}
}) })
private class AppAttestFixture { private class AppAttestFixture {
@@ -134,15 +168,19 @@ private class AppAttestFixture {
.EncodeToBytes() .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) val authData = ByteBuffer.allocate(37).order(ByteOrder.BIG_ENDIAN)
.put(rpIdHash) .put(rpIdHash)
.put(0) .put(flags.toByte())
.putInt(counter) .putInt(counter)
.array() .array()
val signature = Signature.getInstance("SHA256withECDSA").run { val signature = Signature.getInstance("SHA256withECDSA").run {
initSign(keyPair.private) initSign(keyPair.private)
update(authData + clientDataHash) update(sha256ForTest(authData + clientDataHash))
sign() sign()
} }
return CBORObject.NewMap() return CBORObject.NewMap()
@@ -38,6 +38,19 @@ class ReferralServiceTest : FunSpec({
store.codes.size shouldBe 1 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") { test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore() val store = TestBillingStore()
val inviter = UUID.randomUUID() val inviter = UUID.randomUUID()
@@ -0,0 +1,20 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
class AppleStoreKitTransactionVerifierTest : FunSpec({
test("bundled Apple roots load and malformed JWS is rejected") {
val verifier = AppleStoreKitTransactionVerifier(
bundleId = "com.osgkeyboard.ios",
appAppleId = 6_781_553_267,
enableOnlineChecks = false,
)
shouldThrow<StoreKitVerificationFailed> {
verifier.verify("not-a-jws".repeat(20))
}
}
})
@@ -0,0 +1,107 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.routes.storeKitRoutes
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlinx.serialization.json.Json
import kotlin.test.Test
class StoreKitRoutesTest {
private val now = Instant.parse("2026-08-18T08:00:00Z")
private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
private val signedTransaction = "s".repeat(100)
@Test
fun `authenticated transaction submission grants once and replays safely`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
}
}
val catalog = client.get("/v1/storekit/products")
val first = client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
val replay = client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}
catalog.status shouldBe HttpStatusCode.OK
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
catalog.bodyAsText() shouldContain """"productId":"1500tks","credits":1500"""
catalog.bodyAsText() shouldContain """"productId":"3000tks","credits":3000"""
first.status shouldBe HttpStatusCode.OK
first.bodyAsText() shouldContain """"creditsGranted":3000"""
first.bodyAsText() shouldContain """"replayed":false"""
replay.status shouldBe HttpStatusCode.OK
replay.bodyAsText() shouldContain """"replayed":true"""
}
@Test
fun `product catalog and transaction submission require authentication`() = testApplication {
val service = service()
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
storeKitRoutes(service, AuthenticatedUserExtractor { null })
}
}
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
client.post("/v1/storekit/transactions") {
contentType(ContentType.Application.Json)
setBody("""{"signedTransaction":"$signedTransaction"}""")
}.status shouldBe HttpStatusCode.Unauthorized
}
private fun service(): StoreKitService {
val verified = VerifiedStoreKitTransaction(
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
appAccountToken = userId,
productId = "3000tks",
environment = StoreKitEnvironment.SANDBOX,
purchasedAt = now.minusSeconds(10),
signedAt = now.minusSeconds(5),
revokedAt = null,
)
return StoreKitService(
products = listOf(
StoreKitProduct("500tks", 500),
StoreKitProduct("1500tks", 1_500),
StoreKitProduct(verified.productId, 3_000),
),
verifier = StoreKitTransactionVerifier { verified },
transactions = TestBillingStore(),
clock = Clock.fixed(now, ZoneOffset.UTC),
)
}
}
@@ -0,0 +1,133 @@
package com.osglab.account.features.storekit
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.services.StoreKitService
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.util.UUID
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
class StoreKitServiceTest : FunSpec({
val now = Instant.parse("2026-08-18T08:00:00Z")
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
val product = StoreKitProduct("3000tks", 3_000)
val signedTransaction = "s".repeat(100)
fun transaction(
transactionId: String = "2000000000001",
accountToken: UUID = userId,
productId: String = product.productId,
revokedAt: Instant? = null,
) = VerifiedStoreKitTransaction(
transactionId = transactionId,
originalTransactionId = transactionId,
appAccountToken = accountToken,
productId = productId,
environment = StoreKitEnvironment.SANDBOX,
purchasedAt = now.minusSeconds(10),
signedAt = now.minusSeconds(5),
revokedAt = revokedAt,
)
fun service(
store: TestBillingStore,
verified: VerifiedStoreKitTransaction = transaction(),
) = StoreKitService(
products = listOf(product),
verifier = StoreKitTransactionVerifier { verified },
transactions = store,
clock = Clock.fixed(now, ZoneOffset.UTC),
)
test("verified consumable grants integer credits and appends immutable records") {
val store = TestBillingStore()
val result = service(store).submit(userId, signedTransaction)
result.balanceAfter shouldBeExactly 3_000
result.replayed shouldBe false
store.storeKitPurchases.values shouldHaveSize 1
store.ledger.single().type shouldBe LedgerEntryType.STOREKIT_PURCHASE
store.ledger.single().referenceId shouldBe result.purchase.id
}
test("same transaction replay returns the original grant without double crediting") {
val store = TestBillingStore()
val service = service(store)
service.submit(userId, signedTransaction)
val replay = service.submit(userId, signedTransaction)
replay.replayed shouldBe true
replay.balanceAfter shouldBeExactly 3_000
store.ledger shouldHaveSize 1
}
test("concurrent transaction replay grants credits exactly once") {
val store = TestBillingStore()
val service = service(store)
val results = coroutineScope {
(1..20).map {
async { service.submit(userId, signedTransaction) }
}.awaitAll()
}
results.count { !it.replayed } shouldBe 1
store.balance(userId) shouldBeExactly 3_000
store.ledger shouldHaveSize 1
}
test("transaction must be bound to the authenticated account") {
val store = TestBillingStore()
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
shouldThrow<StoreKitPurchaseConflict> {
service(store, transaction(accountToken = otherUser))
.submit(userId, signedTransaction)
}
store.ledger shouldHaveSize 0
}
test("malformed signed transaction is rejected before verification or persistence") {
val store = TestBillingStore()
shouldThrow<InvalidStoreKitRequest> {
service(store).submit(userId, "too-short")
}
store.ledger shouldHaveSize 0
}
test("unknown or revoked products never grant credits") {
val unknownStore = TestBillingStore()
shouldThrow<StoreKitPurchaseConflict> {
service(unknownStore, transaction(productId = "com.osgkeyboard.credits.unknown"))
.submit(userId, signedTransaction)
}
unknownStore.ledger shouldHaveSize 0
val revokedStore = TestBillingStore()
shouldThrow<StoreKitPurchaseConflict> {
service(revokedStore, transaction(revokedAt = now))
.submit(userId, signedTransaction)
}
revokedStore.ledger shouldHaveSize 0
}
})