checkpoint before checking out feature/account-managed-gateway
This commit is contained in:
+9
-2
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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'@'%';
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -8,6 +8,7 @@ import com.osglab.account.features.credits.domain.CreditUsageRecord
|
|||||||
import com.osglab.account.features.credits.domain.LedgerEntry
|
import com.osglab.account.features.credits.domain.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 {
|
||||||
|
|||||||
+23
@@ -19,6 +19,8 @@ import com.osglab.account.features.referrals.domain.ReferralCampaignBudget
|
|||||||
import com.osglab.account.features.referrals.domain.ReferralCode
|
import com.osglab.account.features.referrals.domain.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package com.osglab.account.features.storekit.repositories
|
||||||
|
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jetbrains.exposed.v1.core.*
|
||||||
|
import org.jetbrains.exposed.v1.javatime.timestamp
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
|
||||||
|
interface StoreKitRepository {
|
||||||
|
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
|
||||||
|
|
||||||
|
fun insert(purchase: StoreKitCreditPurchase)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ExposedStoreKitRepository : StoreKitRepository {
|
||||||
|
override fun findByTransactionId(transactionId: String): StoreKitCreditPurchase? =
|
||||||
|
StoreKitCreditPurchases
|
||||||
|
.selectAll()
|
||||||
|
.where { StoreKitCreditPurchases.transactionId eq transactionId }
|
||||||
|
.singleOrNull()
|
||||||
|
?.toStoreKitCreditPurchase()
|
||||||
|
|
||||||
|
override fun insert(purchase: StoreKitCreditPurchase) {
|
||||||
|
StoreKitCreditPurchases.insert {
|
||||||
|
it[id] = purchase.id.toString()
|
||||||
|
it[transactionId] = purchase.transactionId
|
||||||
|
it[originalTransactionId] = purchase.originalTransactionId
|
||||||
|
it[userId] = purchase.userId.toString()
|
||||||
|
it[appAccountToken] = purchase.appAccountToken.toString()
|
||||||
|
it[productId] = purchase.productId
|
||||||
|
it[environment] = purchase.environment
|
||||||
|
it[creditsGranted] = purchase.creditsGranted
|
||||||
|
it[ledgerEntryId] = purchase.ledgerEntryId.toString()
|
||||||
|
it[signedTransactionSha256] = purchase.signedTransactionSha256
|
||||||
|
it[purchasedAt] = purchase.purchasedAt
|
||||||
|
it[signedAt] = purchase.signedAt
|
||||||
|
it[createdAt] = purchase.createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
|
||||||
|
val id = varchar("id", 36)
|
||||||
|
val transactionId = varchar("transaction_id", 64)
|
||||||
|
val originalTransactionId = varchar("original_transaction_id", 64)
|
||||||
|
val userId = varchar("user_id", 36)
|
||||||
|
val appAccountToken = varchar("app_account_token", 36)
|
||||||
|
val productId = varchar("product_id", 128)
|
||||||
|
val environment = enumerationByName<StoreKitEnvironment>("environment", 16)
|
||||||
|
val creditsGranted = long("credits_granted")
|
||||||
|
val ledgerEntryId = varchar("ledger_entry_id", 36)
|
||||||
|
val signedTransactionSha256 = char("signed_transaction_sha256", 64)
|
||||||
|
val purchasedAt = timestamp("purchased_at")
|
||||||
|
val signedAt = timestamp("signed_at")
|
||||||
|
val createdAt = timestamp("created_at")
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
|
||||||
|
StoreKitCreditPurchase(
|
||||||
|
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
|
||||||
|
transactionId = this[StoreKitCreditPurchases.transactionId],
|
||||||
|
originalTransactionId = this[StoreKitCreditPurchases.originalTransactionId],
|
||||||
|
userId = UUID.fromString(this[StoreKitCreditPurchases.userId]),
|
||||||
|
appAccountToken = UUID.fromString(this[StoreKitCreditPurchases.appAccountToken]),
|
||||||
|
productId = this[StoreKitCreditPurchases.productId],
|
||||||
|
environment = this[StoreKitCreditPurchases.environment],
|
||||||
|
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
|
||||||
|
ledgerEntryId = UUID.fromString(this[StoreKitCreditPurchases.ledgerEntryId]),
|
||||||
|
signedTransactionSha256 = this[StoreKitCreditPurchases.signedTransactionSha256],
|
||||||
|
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
|
||||||
|
signedAt = this[StoreKitCreditPurchases.signedAt],
|
||||||
|
createdAt = this[StoreKitCreditPurchases.createdAt],
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.osglab.account.features.storekit.routes
|
||||||
|
|
||||||
|
import com.osglab.account.common.api.ApiError
|
||||||
|
import com.osglab.account.common.api.ApiErrorResponse
|
||||||
|
import com.osglab.account.features.credits.domain.CreditConflict
|
||||||
|
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||||
|
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
|
||||||
|
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||||
|
import com.osglab.account.features.storekit.models.StoreKitProductDto
|
||||||
|
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
|
||||||
|
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
|
||||||
|
import com.osglab.account.features.storekit.services.StoreKitService
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.request.receive
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import io.ktor.server.routing.post
|
||||||
|
import io.ktor.server.routing.route
|
||||||
|
|
||||||
|
fun Route.storeKitRoutes(
|
||||||
|
service: StoreKitService,
|
||||||
|
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
|
||||||
|
) {
|
||||||
|
route("/v1/storekit") {
|
||||||
|
get("/products") {
|
||||||
|
val userId = authenticatedUser.extract(call)
|
||||||
|
if (userId == null) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||||
|
)
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
call.respond(service.products().map(StoreKitProductDto::fromDomain))
|
||||||
|
}
|
||||||
|
post("/transactions") {
|
||||||
|
val userId = authenticatedUser.extract(call)
|
||||||
|
if (userId == null) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Unauthorized,
|
||||||
|
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
|
||||||
|
)
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
val request = call.receive<StoreKitSubmitRequest>()
|
||||||
|
try {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.OK,
|
||||||
|
StoreKitPurchaseResponse.fromDomain(
|
||||||
|
service.submit(userId, request.signedTransaction)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: StoreKitUnavailable) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.ServiceUnavailable,
|
||||||
|
ApiErrorResponse(
|
||||||
|
ApiError("external_service_unavailable", "Credit purchases are unavailable")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: InvalidStoreKitRequest) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.BadRequest,
|
||||||
|
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
|
||||||
|
)
|
||||||
|
} catch (_: StoreKitVerificationFailed) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.UnprocessableEntity,
|
||||||
|
ApiErrorResponse(
|
||||||
|
ApiError("transaction_invalid", "The App Store transaction is invalid")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (_: StoreKitPurchaseConflict) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Conflict,
|
||||||
|
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||||
|
)
|
||||||
|
} catch (_: CreditConflict) {
|
||||||
|
call.respond(
|
||||||
|
HttpStatusCode.Conflict,
|
||||||
|
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package com.osglab.account.features.storekit.services
|
||||||
|
|
||||||
|
import com.osglab.account.features.credits.domain.CreditConflict
|
||||||
|
import com.osglab.account.features.credits.domain.LedgerEntry
|
||||||
|
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||||
|
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
|
||||||
|
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||||
|
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||||
|
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class StoreKitService(
|
||||||
|
products: List<StoreKitProduct>,
|
||||||
|
private val verifier: StoreKitTransactionVerifier,
|
||||||
|
private val transactions: BillingTransactionRunner,
|
||||||
|
private val clock: Clock = Clock.systemUTC(),
|
||||||
|
private val newId: () -> UUID = UUID::randomUUID,
|
||||||
|
) {
|
||||||
|
private val productsById = products.associateBy(StoreKitProduct::productId)
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(productsById.size == products.size) { "StoreKit product IDs must be unique" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
|
||||||
|
|
||||||
|
suspend fun submit(
|
||||||
|
userId: UUID,
|
||||||
|
signedTransaction: String,
|
||||||
|
): StoreKitPurchaseResult {
|
||||||
|
if (productsById.isEmpty()) {
|
||||||
|
throw StoreKitUnavailable()
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
signedTransaction.length !in MIN_SIGNED_TRANSACTION_LENGTH..MAX_SIGNED_TRANSACTION_LENGTH ||
|
||||||
|
signedTransaction != signedTransaction.trim()
|
||||||
|
) {
|
||||||
|
throw InvalidStoreKitRequest("signedTransaction is invalid")
|
||||||
|
}
|
||||||
|
val verified = verifier.verify(signedTransaction)
|
||||||
|
val product = productsById[verified.productId] ?: throw StoreKitPurchaseConflict()
|
||||||
|
validateTransaction(userId, verified)
|
||||||
|
val digest = signedTransaction.sha256Hex()
|
||||||
|
val idempotencyKey = "storekit:${verified.transactionId}"
|
||||||
|
|
||||||
|
return transactions.inTransaction { unit ->
|
||||||
|
val now = clock.instant()
|
||||||
|
unit.credits.createAccountIfAbsent(userId, now)
|
||||||
|
val account = unit.credits.lockAccount(userId)
|
||||||
|
unit.storeKit.findByTransactionId(verified.transactionId)?.let { existing ->
|
||||||
|
requireReplayMatches(existing, verified, product)
|
||||||
|
val ledger = unit.credits.findLedgerEntry(userId, idempotencyKey)
|
||||||
|
?: throw StoreKitPurchaseConflict()
|
||||||
|
if (
|
||||||
|
ledger.id != existing.ledgerEntryId ||
|
||||||
|
ledger.type != LedgerEntryType.STOREKIT_PURCHASE ||
|
||||||
|
ledger.amountDelta != existing.creditsGranted ||
|
||||||
|
ledger.referenceId != existing.id
|
||||||
|
) {
|
||||||
|
throw StoreKitPurchaseConflict()
|
||||||
|
}
|
||||||
|
return@inTransaction StoreKitPurchaseResult(existing, ledger.balanceAfter, replayed = true)
|
||||||
|
}
|
||||||
|
if (unit.credits.findLedgerEntry(userId, idempotencyKey) != null) {
|
||||||
|
throw StoreKitPurchaseConflict()
|
||||||
|
}
|
||||||
|
|
||||||
|
val balanceAfter = try {
|
||||||
|
Math.addExact(account.balance, product.credits)
|
||||||
|
} catch (_: ArithmeticException) {
|
||||||
|
throw CreditConflict("StoreKit credit balance overflow")
|
||||||
|
}
|
||||||
|
val purchaseId = newId()
|
||||||
|
val ledgerEntryId = newId()
|
||||||
|
val purchase = StoreKitCreditPurchase(
|
||||||
|
id = purchaseId,
|
||||||
|
transactionId = verified.transactionId,
|
||||||
|
originalTransactionId = verified.originalTransactionId,
|
||||||
|
userId = userId,
|
||||||
|
appAccountToken = verified.appAccountToken,
|
||||||
|
productId = product.productId,
|
||||||
|
environment = verified.environment,
|
||||||
|
creditsGranted = product.credits,
|
||||||
|
ledgerEntryId = ledgerEntryId,
|
||||||
|
signedTransactionSha256 = digest,
|
||||||
|
purchasedAt = verified.purchasedAt,
|
||||||
|
signedAt = verified.signedAt,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
unit.credits.updateAccountBalance(userId, balanceAfter, now)
|
||||||
|
unit.credits.insertLedgerEntry(
|
||||||
|
LedgerEntry(
|
||||||
|
id = ledgerEntryId,
|
||||||
|
userId = userId,
|
||||||
|
type = LedgerEntryType.STOREKIT_PURCHASE,
|
||||||
|
amountDelta = product.credits,
|
||||||
|
balanceAfter = balanceAfter,
|
||||||
|
idempotencyKey = idempotencyKey,
|
||||||
|
referenceId = purchaseId,
|
||||||
|
createdAt = now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
unit.storeKit.insert(purchase)
|
||||||
|
StoreKitPurchaseResult(purchase, balanceAfter, replayed = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateTransaction(
|
||||||
|
userId: UUID,
|
||||||
|
transaction: VerifiedStoreKitTransaction,
|
||||||
|
) {
|
||||||
|
if (transaction.appAccountToken != userId || transaction.revokedAt != null) {
|
||||||
|
throw StoreKitPurchaseConflict()
|
||||||
|
}
|
||||||
|
val now = clock.instant()
|
||||||
|
if (
|
||||||
|
transaction.purchasedAt > transaction.signedAt ||
|
||||||
|
transaction.signedAt > now.plus(MAX_CLOCK_SKEW)
|
||||||
|
) {
|
||||||
|
throw StoreKitPurchaseConflict()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireReplayMatches(
|
||||||
|
existing: StoreKitCreditPurchase,
|
||||||
|
verified: VerifiedStoreKitTransaction,
|
||||||
|
product: StoreKitProduct,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
existing.userId != verified.appAccountToken ||
|
||||||
|
existing.originalTransactionId != verified.originalTransactionId ||
|
||||||
|
existing.productId != verified.productId ||
|
||||||
|
existing.environment != verified.environment ||
|
||||||
|
existing.creditsGranted != product.credits ||
|
||||||
|
existing.purchasedAt != verified.purchasedAt
|
||||||
|
) {
|
||||||
|
throw StoreKitPurchaseConflict()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.sha256Hex(): String =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(toByteArray(Charsets.UTF_8))
|
||||||
|
.joinToString(separator = "") { byte -> "%02x".format(byte.toInt() and 0xff) }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
|
||||||
|
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
|
||||||
|
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package com.osglab.account.features.storekit.verification
|
||||||
|
|
||||||
|
import com.apple.itunes.storekit.model.Environment
|
||||||
|
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload
|
||||||
|
import com.apple.itunes.storekit.model.Type
|
||||||
|
import com.apple.itunes.storekit.verification.SignedDataVerifier
|
||||||
|
import com.apple.itunes.storekit.verification.VerificationException
|
||||||
|
import com.apple.itunes.storekit.verification.VerificationStatus
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||||
|
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
fun interface StoreKitTransactionVerifier {
|
||||||
|
suspend fun verify(signedTransaction: String): VerifiedStoreKitTransaction
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppleStoreKitTransactionVerifier(
|
||||||
|
bundleId: String,
|
||||||
|
appAppleId: Long,
|
||||||
|
rootCertificateLoader: (String) -> InputStream? = {
|
||||||
|
AppleStoreKitTransactionVerifier::class.java.getResourceAsStream(it)
|
||||||
|
},
|
||||||
|
enableOnlineChecks: Boolean = true,
|
||||||
|
) : StoreKitTransactionVerifier {
|
||||||
|
private val production: SignedDataVerifier
|
||||||
|
private val sandbox: SignedDataVerifier
|
||||||
|
|
||||||
|
init {
|
||||||
|
val certificateBytes = ROOT_CERTIFICATES.map { path ->
|
||||||
|
rootCertificateLoader(path)?.use(InputStream::readAllBytes)
|
||||||
|
?: error("Missing Apple root certificate: $path")
|
||||||
|
}
|
||||||
|
fun verifier(environment: Environment): SignedDataVerifier {
|
||||||
|
val streams = certificateBytes.map(ByteArray::inputStream).toSet()
|
||||||
|
return SignedDataVerifier(
|
||||||
|
streams,
|
||||||
|
bundleId,
|
||||||
|
if (environment == Environment.PRODUCTION) appAppleId else null,
|
||||||
|
environment,
|
||||||
|
enableOnlineChecks,
|
||||||
|
).also { streams.forEach(InputStream::close) }
|
||||||
|
}
|
||||||
|
production = verifier(Environment.PRODUCTION)
|
||||||
|
sandbox = verifier(Environment.SANDBOX)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun verify(
|
||||||
|
signedTransaction: String
|
||||||
|
): VerifiedStoreKitTransaction = withContext(Dispatchers.IO) {
|
||||||
|
if (signedTransaction.length !in MIN_JWS_LENGTH..MAX_JWS_LENGTH) {
|
||||||
|
throw StoreKitVerificationFailed()
|
||||||
|
}
|
||||||
|
var retryableFailure = false
|
||||||
|
val payload = try {
|
||||||
|
production.verifyAndDecodeTransaction(signedTransaction)
|
||||||
|
} catch (exception: VerificationException) {
|
||||||
|
retryableFailure = exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
|
||||||
|
null
|
||||||
|
} ?: try {
|
||||||
|
sandbox.verifyAndDecodeTransaction(signedTransaction)
|
||||||
|
} catch (exception: VerificationException) {
|
||||||
|
retryableFailure = retryableFailure ||
|
||||||
|
exception.status == VerificationStatus.RETRYABLE_VERIFICATION_FAILURE
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (payload == null && retryableFailure) {
|
||||||
|
throw StoreKitUnavailable()
|
||||||
|
}
|
||||||
|
if (payload == null) {
|
||||||
|
throw StoreKitVerificationFailed()
|
||||||
|
}
|
||||||
|
payload.toVerifiedTransaction()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JWSTransactionDecodedPayload.toVerifiedTransaction(): VerifiedStoreKitTransaction {
|
||||||
|
val transaction = transactionId?.takeIf(TRANSACTION_ID::matches)
|
||||||
|
?: throw StoreKitVerificationFailed()
|
||||||
|
val original = originalTransactionId?.takeIf(TRANSACTION_ID::matches)
|
||||||
|
?: throw StoreKitVerificationFailed()
|
||||||
|
val accountToken = appAccountToken ?: throw StoreKitVerificationFailed()
|
||||||
|
val product = productId?.takeIf { it.length in 3..128 }
|
||||||
|
?: throw StoreKitVerificationFailed()
|
||||||
|
val purchaseMillis = purchaseDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
|
||||||
|
val signedMillis = signedDate?.takeIf { it > 0 } ?: throw StoreKitVerificationFailed()
|
||||||
|
if (type != Type.CONSUMABLE || quantity != 1) {
|
||||||
|
throw StoreKitVerificationFailed()
|
||||||
|
}
|
||||||
|
val verifiedEnvironment = when (environment) {
|
||||||
|
Environment.PRODUCTION -> StoreKitEnvironment.PRODUCTION
|
||||||
|
Environment.SANDBOX -> StoreKitEnvironment.SANDBOX
|
||||||
|
else -> throw StoreKitVerificationFailed()
|
||||||
|
}
|
||||||
|
return VerifiedStoreKitTransaction(
|
||||||
|
transactionId = transaction,
|
||||||
|
originalTransactionId = original,
|
||||||
|
appAccountToken = accountToken,
|
||||||
|
productId = product,
|
||||||
|
environment = verifiedEnvironment,
|
||||||
|
purchasedAt = Instant.ofEpochMilli(purchaseMillis),
|
||||||
|
signedAt = Instant.ofEpochMilli(signedMillis),
|
||||||
|
revokedAt = revocationDate?.let(Instant::ofEpochMilli),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MIN_JWS_LENGTH = 100
|
||||||
|
const val MAX_JWS_LENGTH = 32_768
|
||||||
|
val TRANSACTION_ID = Regex("[0-9]{1,64}")
|
||||||
|
val ROOT_CERTIFICATES = listOf(
|
||||||
|
"/apple-pki/AppleRootCA-G2.cer",
|
||||||
|
"/apple-pki/AppleRootCA-G3.cer",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -49,9 +49,14 @@ app:
|
|||||||
revokeUrl: "$APPLE_REVOKE_URL:https://appleid.apple.com/auth/revoke"
|
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()
|
||||||
|
|||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.osglab.account.features.storekit
|
||||||
|
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
|
||||||
|
import com.osglab.account.features.storekit.verification.AppleStoreKitTransactionVerifier
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
|
||||||
|
class AppleStoreKitTransactionVerifierTest : FunSpec({
|
||||||
|
test("bundled Apple roots load and malformed JWS is rejected") {
|
||||||
|
val verifier = AppleStoreKitTransactionVerifier(
|
||||||
|
bundleId = "com.osgkeyboard.ios",
|
||||||
|
appAppleId = 6_781_553_267,
|
||||||
|
enableOnlineChecks = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
shouldThrow<StoreKitVerificationFailed> {
|
||||||
|
verifier.verify("not-a-jws".repeat(20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.osglab.account.features.storekit
|
||||||
|
|
||||||
|
import com.osglab.account.features.credits.TestBillingStore
|
||||||
|
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||||
|
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||||
|
import com.osglab.account.features.storekit.routes.storeKitRoutes
|
||||||
|
import com.osglab.account.features.storekit.services.StoreKitService
|
||||||
|
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.testing.testApplication
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
|
||||||
|
class StoreKitRoutesTest {
|
||||||
|
private val now = Instant.parse("2026-08-18T08:00:00Z")
|
||||||
|
private val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||||
|
private val signedTransaction = "s".repeat(100)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `authenticated transaction submission grants once and replays safely`() = testApplication {
|
||||||
|
val service = service()
|
||||||
|
application {
|
||||||
|
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||||
|
routing {
|
||||||
|
storeKitRoutes(service, AuthenticatedUserExtractor { userId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val catalog = client.get("/v1/storekit/products")
|
||||||
|
val first = client.post("/v1/storekit/transactions") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||||
|
}
|
||||||
|
val replay = client.post("/v1/storekit/transactions") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog.status shouldBe HttpStatusCode.OK
|
||||||
|
catalog.bodyAsText() shouldContain """"productId":"500tks","credits":500"""
|
||||||
|
catalog.bodyAsText() shouldContain """"productId":"1500tks","credits":1500"""
|
||||||
|
catalog.bodyAsText() shouldContain """"productId":"3000tks","credits":3000"""
|
||||||
|
first.status shouldBe HttpStatusCode.OK
|
||||||
|
first.bodyAsText() shouldContain """"creditsGranted":3000"""
|
||||||
|
first.bodyAsText() shouldContain """"replayed":false"""
|
||||||
|
replay.status shouldBe HttpStatusCode.OK
|
||||||
|
replay.bodyAsText() shouldContain """"replayed":true"""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `product catalog and transaction submission require authentication`() = testApplication {
|
||||||
|
val service = service()
|
||||||
|
application {
|
||||||
|
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
|
||||||
|
routing {
|
||||||
|
storeKitRoutes(service, AuthenticatedUserExtractor { null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.get("/v1/storekit/products").status shouldBe HttpStatusCode.Unauthorized
|
||||||
|
client.post("/v1/storekit/transactions") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody("""{"signedTransaction":"$signedTransaction"}""")
|
||||||
|
}.status shouldBe HttpStatusCode.Unauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun service(): StoreKitService {
|
||||||
|
val verified = VerifiedStoreKitTransaction(
|
||||||
|
transactionId = "2000000000001",
|
||||||
|
originalTransactionId = "2000000000001",
|
||||||
|
appAccountToken = userId,
|
||||||
|
productId = "3000tks",
|
||||||
|
environment = StoreKitEnvironment.SANDBOX,
|
||||||
|
purchasedAt = now.minusSeconds(10),
|
||||||
|
signedAt = now.minusSeconds(5),
|
||||||
|
revokedAt = null,
|
||||||
|
)
|
||||||
|
return StoreKitService(
|
||||||
|
products = listOf(
|
||||||
|
StoreKitProduct("500tks", 500),
|
||||||
|
StoreKitProduct("1500tks", 1_500),
|
||||||
|
StoreKitProduct(verified.productId, 3_000),
|
||||||
|
),
|
||||||
|
verifier = StoreKitTransactionVerifier { verified },
|
||||||
|
transactions = TestBillingStore(),
|
||||||
|
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package com.osglab.account.features.storekit
|
||||||
|
|
||||||
|
import com.osglab.account.features.credits.TestBillingStore
|
||||||
|
import com.osglab.account.features.credits.domain.LedgerEntryType
|
||||||
|
import com.osglab.account.features.storekit.domain.InvalidStoreKitRequest
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitProduct
|
||||||
|
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
|
||||||
|
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
|
||||||
|
import com.osglab.account.features.storekit.services.StoreKitService
|
||||||
|
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
|
||||||
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.collections.shouldHaveSize
|
||||||
|
import io.kotest.matchers.longs.shouldBeExactly
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import java.time.Clock
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
|
||||||
|
class StoreKitServiceTest : FunSpec({
|
||||||
|
val now = Instant.parse("2026-08-18T08:00:00Z")
|
||||||
|
val userId = UUID.fromString("10000000-0000-0000-0000-000000000010")
|
||||||
|
val product = StoreKitProduct("3000tks", 3_000)
|
||||||
|
val signedTransaction = "s".repeat(100)
|
||||||
|
|
||||||
|
fun transaction(
|
||||||
|
transactionId: String = "2000000000001",
|
||||||
|
accountToken: UUID = userId,
|
||||||
|
productId: String = product.productId,
|
||||||
|
revokedAt: Instant? = null,
|
||||||
|
) = VerifiedStoreKitTransaction(
|
||||||
|
transactionId = transactionId,
|
||||||
|
originalTransactionId = transactionId,
|
||||||
|
appAccountToken = accountToken,
|
||||||
|
productId = productId,
|
||||||
|
environment = StoreKitEnvironment.SANDBOX,
|
||||||
|
purchasedAt = now.minusSeconds(10),
|
||||||
|
signedAt = now.minusSeconds(5),
|
||||||
|
revokedAt = revokedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun service(
|
||||||
|
store: TestBillingStore,
|
||||||
|
verified: VerifiedStoreKitTransaction = transaction(),
|
||||||
|
) = StoreKitService(
|
||||||
|
products = listOf(product),
|
||||||
|
verifier = StoreKitTransactionVerifier { verified },
|
||||||
|
transactions = store,
|
||||||
|
clock = Clock.fixed(now, ZoneOffset.UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
test("verified consumable grants integer credits and appends immutable records") {
|
||||||
|
val store = TestBillingStore()
|
||||||
|
|
||||||
|
val result = service(store).submit(userId, signedTransaction)
|
||||||
|
|
||||||
|
result.balanceAfter shouldBeExactly 3_000
|
||||||
|
result.replayed shouldBe false
|
||||||
|
store.storeKitPurchases.values shouldHaveSize 1
|
||||||
|
store.ledger.single().type shouldBe LedgerEntryType.STOREKIT_PURCHASE
|
||||||
|
store.ledger.single().referenceId shouldBe result.purchase.id
|
||||||
|
}
|
||||||
|
|
||||||
|
test("same transaction replay returns the original grant without double crediting") {
|
||||||
|
val store = TestBillingStore()
|
||||||
|
val service = service(store)
|
||||||
|
|
||||||
|
service.submit(userId, signedTransaction)
|
||||||
|
val replay = service.submit(userId, signedTransaction)
|
||||||
|
|
||||||
|
replay.replayed shouldBe true
|
||||||
|
replay.balanceAfter shouldBeExactly 3_000
|
||||||
|
store.ledger shouldHaveSize 1
|
||||||
|
}
|
||||||
|
|
||||||
|
test("concurrent transaction replay grants credits exactly once") {
|
||||||
|
val store = TestBillingStore()
|
||||||
|
val service = service(store)
|
||||||
|
|
||||||
|
val results = coroutineScope {
|
||||||
|
(1..20).map {
|
||||||
|
async { service.submit(userId, signedTransaction) }
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
results.count { !it.replayed } shouldBe 1
|
||||||
|
store.balance(userId) shouldBeExactly 3_000
|
||||||
|
store.ledger shouldHaveSize 1
|
||||||
|
}
|
||||||
|
|
||||||
|
test("transaction must be bound to the authenticated account") {
|
||||||
|
val store = TestBillingStore()
|
||||||
|
val otherUser = UUID.fromString("10000000-0000-0000-0000-000000000011")
|
||||||
|
|
||||||
|
shouldThrow<StoreKitPurchaseConflict> {
|
||||||
|
service(store, transaction(accountToken = otherUser))
|
||||||
|
.submit(userId, signedTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
store.ledger shouldHaveSize 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test("malformed signed transaction is rejected before verification or persistence") {
|
||||||
|
val store = TestBillingStore()
|
||||||
|
|
||||||
|
shouldThrow<InvalidStoreKitRequest> {
|
||||||
|
service(store).submit(userId, "too-short")
|
||||||
|
}
|
||||||
|
|
||||||
|
store.ledger shouldHaveSize 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test("unknown or revoked products never grant credits") {
|
||||||
|
val unknownStore = TestBillingStore()
|
||||||
|
shouldThrow<StoreKitPurchaseConflict> {
|
||||||
|
service(unknownStore, transaction(productId = "com.osgkeyboard.credits.unknown"))
|
||||||
|
.submit(userId, signedTransaction)
|
||||||
|
}
|
||||||
|
unknownStore.ledger shouldHaveSize 0
|
||||||
|
|
||||||
|
val revokedStore = TestBillingStore()
|
||||||
|
shouldThrow<StoreKitPurchaseConflict> {
|
||||||
|
service(revokedStore, transaction(revokedAt = now))
|
||||||
|
.submit(userId, signedTransaction)
|
||||||
|
}
|
||||||
|
revokedStore.ledger shouldHaveSize 0
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user