payment-kit 1.28.0 → 1.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/crons/index.ts +22 -0
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/libs/audit.ts +38 -8
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/util.ts +13 -0
- package/api/src/queues/event.ts +25 -19
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/payment-methods.ts +130 -0
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +14 -0
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +62 -1
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/blocklet.yml +1 -1
- package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +23 -1
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/node-fetch.ts +35 -0
- package/cloudflare/shims/queue.ts +28 -2
- package/cloudflare/shims/sequelize-d1/model.ts +19 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/worker.ts +59 -4
- package/cloudflare/wrangler.jsonc +7 -1
- package/cloudflare/wrangler.staging.json +2 -1
- package/package.json +10 -6
- package/scripts/seed-google-play.ts +79 -0
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +6 -0
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Real signature verification for App Store JWS payloads.
|
|
2
|
+
//
|
|
3
|
+
// Wraps Apple's official `@apple/app-store-server-library` SignedDataVerifier:
|
|
4
|
+
// 1. Loads bundled Apple Root CAs (vendored as base64 constants in
|
|
5
|
+
// apple-root-certs.ts so tsc compiles to dist without copying assets)
|
|
6
|
+
// 2. Caches a per-(bundleId, environment) verifier instance — Apple SDK keeps
|
|
7
|
+
// a public-key cache inside each verifier, so reusing is much faster than
|
|
8
|
+
// constructing per-call
|
|
9
|
+
// 3. Provides verifyTransaction / verifyNotification that throw on bad signatures
|
|
10
|
+
//
|
|
11
|
+
// Env var `APP_STORE_SKIP_SIGNATURE_VERIFY=true` bypasses the SDK entirely
|
|
12
|
+
// (decode-only). Use for unit tests and sandbox debugging — never in production.
|
|
13
|
+
|
|
14
|
+
// NOTE: @apple/app-store-server-library has top-level side-effects (jsrsasign
|
|
15
|
+
// initializes RNG in global scope, see https://github.com/apple/app-store-server-library-node).
|
|
16
|
+
// Cloudflare Workers forbid I/O / random / setTimeout in global scope, so we
|
|
17
|
+
// **lazy-load** the SDK inside the first call to getVerifier. This keeps the
|
|
18
|
+
// worker startup clean and lets non-Apple endpoints (Google Play, entitlements,
|
|
19
|
+
// etc.) mount even when Apple SDK is present in the deps.
|
|
20
|
+
import type {
|
|
21
|
+
AppStoreServerAPIClient,
|
|
22
|
+
JWSTransactionDecodedPayload,
|
|
23
|
+
ResponseBodyV2DecodedPayload,
|
|
24
|
+
SignedDataVerifier,
|
|
25
|
+
StatusResponse,
|
|
26
|
+
} from '@apple/app-store-server-library';
|
|
27
|
+
|
|
28
|
+
import logger from '../../libs/logger';
|
|
29
|
+
import { APPLE_ROOT_CERTS } from './apple-root-certs';
|
|
30
|
+
|
|
31
|
+
const verifierCache = new Map<string, SignedDataVerifier>();
|
|
32
|
+
|
|
33
|
+
async function getVerifier(bundleId: string, environment: 'production' | 'sandbox'): Promise<SignedDataVerifier> {
|
|
34
|
+
const key = `${bundleId}:${environment}`;
|
|
35
|
+
const cached = verifierCache.get(key);
|
|
36
|
+
if (cached) return cached;
|
|
37
|
+
// Dynamic import — keeps the SDK out of the worker bundle's global scope.
|
|
38
|
+
const mod = await import('@apple/app-store-server-library');
|
|
39
|
+
const env = environment === 'production' ? mod.Environment.PRODUCTION : mod.Environment.SANDBOX;
|
|
40
|
+
// enableOnlineChecks=false — turning this on does OCSP revocation lookups on
|
|
41
|
+
// every verify, which adds latency + network dependency. Apple's published
|
|
42
|
+
// recommendation is to leave it off unless you specifically need it.
|
|
43
|
+
const verifier = new mod.SignedDataVerifier(APPLE_ROOT_CERTS, false, env, bundleId);
|
|
44
|
+
verifierCache.set(key, verifier);
|
|
45
|
+
return verifier;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isSignatureVerificationSkipped(): boolean {
|
|
49
|
+
if (process.env.APP_STORE_SKIP_SIGNATURE_VERIFY !== 'true') return false;
|
|
50
|
+
// Production fail-closed: even when the bypass flag is set we refuse to
|
|
51
|
+
// honor it in production, and log loudly so the misconfiguration is
|
|
52
|
+
// visible. The flag exists for unit tests / local sandbox debugging where
|
|
53
|
+
// the synthetic JWS isn't signed by Apple; in production it would silently
|
|
54
|
+
// downgrade a critical trust boundary to decode-only (CWE-347).
|
|
55
|
+
if (process.env.BLOCKLET_MODE === 'production') {
|
|
56
|
+
logger.error(
|
|
57
|
+
'app_store: APP_STORE_SKIP_SIGNATURE_VERIFY=true ignored in production — JWS signature verification stays enabled'
|
|
58
|
+
);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function verifySignedTransaction(
|
|
65
|
+
signedTransaction: string,
|
|
66
|
+
bundleId: string,
|
|
67
|
+
environment: 'production' | 'sandbox'
|
|
68
|
+
): Promise<JWSTransactionDecodedPayload> {
|
|
69
|
+
if (isSignatureVerificationSkipped()) {
|
|
70
|
+
logger.warn('app_store: signature verification skipped via APP_STORE_SKIP_SIGNATURE_VERIFY');
|
|
71
|
+
return decodeUnsafe<JWSTransactionDecodedPayload>(signedTransaction);
|
|
72
|
+
}
|
|
73
|
+
const verifier = await getVerifier(bundleId, environment);
|
|
74
|
+
return verifier.verifyAndDecodeTransaction(signedTransaction);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function verifySignedNotification(
|
|
78
|
+
signedPayload: string,
|
|
79
|
+
bundleId: string,
|
|
80
|
+
environment: 'production' | 'sandbox'
|
|
81
|
+
): Promise<ResponseBodyV2DecodedPayload> {
|
|
82
|
+
if (isSignatureVerificationSkipped()) {
|
|
83
|
+
logger.warn('app_store: signature verification skipped via APP_STORE_SKIP_SIGNATURE_VERIFY');
|
|
84
|
+
return decodeUnsafe<ResponseBodyV2DecodedPayload>(signedPayload);
|
|
85
|
+
}
|
|
86
|
+
const verifier = await getVerifier(bundleId, environment);
|
|
87
|
+
return verifier.verifyAndDecodeNotification(signedPayload);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Decode-only fallback for when signature verification is intentionally bypassed. */
|
|
91
|
+
function decodeUnsafe<T>(jws: string): T {
|
|
92
|
+
const parts = jws.split('.');
|
|
93
|
+
if (parts.length !== 3) {
|
|
94
|
+
throw new Error('AppStore JWS format invalid (expected 3 segments)');
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8'));
|
|
98
|
+
} catch (err) {
|
|
99
|
+
throw new Error(`AppStore JWS payload not valid JSON: ${(err as Error).message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Exposed for tests — reset module-level caches between tests.
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
105
|
+
export function __resetVerifierCachesForTests(): void {
|
|
106
|
+
verifierCache.clear();
|
|
107
|
+
apiClientCache.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ============================================================================
|
|
111
|
+
// App Store Server API client — for pulling fresh state (subscription status,
|
|
112
|
+
// transaction history) when our local cache might be stale (e.g. at renewal,
|
|
113
|
+
// or when reconciling after a missed webhook). Requires issuer_id/key_id/.p8
|
|
114
|
+
// credentials in PaymentMethod settings; throws clearly when not configured.
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
export type AppStoreApiCredentials = {
|
|
118
|
+
issuerId: string;
|
|
119
|
+
keyId: string;
|
|
120
|
+
privateKeyPem: string;
|
|
121
|
+
bundleId: string;
|
|
122
|
+
environment: 'production' | 'sandbox';
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const apiClientCache = new Map<string, AppStoreServerAPIClient>();
|
|
126
|
+
async function getApiClient(creds: AppStoreApiCredentials): Promise<AppStoreServerAPIClient> {
|
|
127
|
+
const key = `${creds.bundleId}:${creds.environment}:${creds.keyId}`;
|
|
128
|
+
const cached = apiClientCache.get(key);
|
|
129
|
+
if (cached) return cached;
|
|
130
|
+
// Lazy import — same reason as getVerifier (global-scope I/O ban in CF Workers).
|
|
131
|
+
const mod = await import('@apple/app-store-server-library');
|
|
132
|
+
const env = creds.environment === 'production' ? mod.Environment.PRODUCTION : mod.Environment.SANDBOX;
|
|
133
|
+
const client = new mod.AppStoreServerAPIClient(creds.privateKeyPem, creds.keyId, creds.issuerId, creds.bundleId, env);
|
|
134
|
+
apiClientCache.set(key, client);
|
|
135
|
+
return client;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Fetch all subscription statuses for a given originalTransactionId.
|
|
140
|
+
* Returns Apple's StatusResponse which contains `signedTransactionInfo` /
|
|
141
|
+
* `signedRenewalInfo` JWS strings — verify those with the SignedDataVerifier
|
|
142
|
+
* if you want decoded payloads.
|
|
143
|
+
*/
|
|
144
|
+
export async function getAllSubscriptionStatuses(
|
|
145
|
+
originalTransactionId: string,
|
|
146
|
+
creds: AppStoreApiCredentials
|
|
147
|
+
): Promise<StatusResponse> {
|
|
148
|
+
const client = await getApiClient(creds);
|
|
149
|
+
return client.getAllSubscriptionStatuses(originalTransactionId);
|
|
150
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Google Play Developer API client.
|
|
2
|
+
//
|
|
3
|
+
// Original implementation used `google-play-billing-validator` (a Node-only
|
|
4
|
+
// wrapper around `request`). That doesn't run on CF Workers — the `request`
|
|
5
|
+
// HTTP library predates streams as we know them and trips even on nodejs_compat.
|
|
6
|
+
// This rewrite uses fetch + Web Crypto, which work identically in Node 18+
|
|
7
|
+
// and in CF Workers, so /verify works in both targets.
|
|
8
|
+
//
|
|
9
|
+
// Read methods (verifySub / verifyINAPP) → GET androidpublisher.googleapis.com
|
|
10
|
+
// Write methods (acknowledge) → POST androidpublisher.googleapis.com
|
|
11
|
+
//
|
|
12
|
+
// Intentionally absent: cancel / refund. Google Play subscription cancellation
|
|
13
|
+
// and refund are the user's prerogative via Play Store «管理订阅» / Play Console
|
|
14
|
+
// (for developer-initiated refunds). Exposing server-side cancel/refund from
|
|
15
|
+
// Payment Kit would diverge our local mirror from Google's source of truth.
|
|
16
|
+
// State changes flow IN via RTDN webhooks → handlers/, never OUT from us.
|
|
17
|
+
|
|
18
|
+
import logger from '../../libs/logger';
|
|
19
|
+
|
|
20
|
+
export type GooglePlaySubscriptionPurchase = {
|
|
21
|
+
/** Subscription Status of the order: 0 = pending, 1 = active, 2 = canceled */
|
|
22
|
+
paymentState?: 0 | 1 | 2;
|
|
23
|
+
/** RFC3339 UTC timestamp of expiry */
|
|
24
|
+
expiryTimeMillis?: string;
|
|
25
|
+
/** RFC3339 UTC timestamp of auto-resume */
|
|
26
|
+
autoResumeTimeMillis?: string;
|
|
27
|
+
/** Subscription auto-renewing flag */
|
|
28
|
+
autoRenewing?: boolean;
|
|
29
|
+
/** Price paid by user in micro-units of price_currency_code */
|
|
30
|
+
priceAmountMicros?: string;
|
|
31
|
+
priceCurrencyCode?: string;
|
|
32
|
+
/** 0 = active, 1 = canceled by user, 2 = canceled by system, 3 = replaced by another subscription, 4 = canceled by developer */
|
|
33
|
+
cancelReason?: 0 | 1 | 2 | 3 | 4;
|
|
34
|
+
/** Acknowledge state: 0 = yet to be, 1 = acknowledged */
|
|
35
|
+
acknowledgementState?: 0 | 1;
|
|
36
|
+
/** UUID-like identifier of the user account, passed when calling launchBillingFlow as obfuscatedAccountId */
|
|
37
|
+
obfuscatedExternalAccountId?: string;
|
|
38
|
+
obfuscatedExternalProfileId?: string;
|
|
39
|
+
orderId?: string;
|
|
40
|
+
startTimeMillis?: string;
|
|
41
|
+
countryCode?: string;
|
|
42
|
+
/** PURCHASE / SUBSCRIPTION */
|
|
43
|
+
kind?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type GooglePlayProductPurchase = {
|
|
47
|
+
kind: string;
|
|
48
|
+
purchaseTimeMillis?: string;
|
|
49
|
+
purchaseState?: 0 | 1 | 2; // 0 = purchased, 1 = canceled, 2 = pending
|
|
50
|
+
consumptionState?: 0 | 1;
|
|
51
|
+
developerPayload?: string;
|
|
52
|
+
orderId?: string;
|
|
53
|
+
purchaseType?: 0 | 1 | 2;
|
|
54
|
+
acknowledgementState?: 0 | 1;
|
|
55
|
+
obfuscatedExternalAccountId?: string;
|
|
56
|
+
obfuscatedExternalProfileId?: string;
|
|
57
|
+
regionCode?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type GooglePlaySettings = {
|
|
61
|
+
package_name: string;
|
|
62
|
+
/** Stringified service account credentials JSON downloaded from GCP Console. */
|
|
63
|
+
service_account_json: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const ANDROID_PUBLISHER_BASE = 'https://androidpublisher.googleapis.com/androidpublisher/v3';
|
|
67
|
+
const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
|
68
|
+
const ANDROID_PUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
|
69
|
+
const ACCESS_TOKEN_REFRESH_LEEWAY_SEC = 60;
|
|
70
|
+
|
|
71
|
+
type AccessTokenCache = { token: string; expiresAt: number };
|
|
72
|
+
|
|
73
|
+
export class GooglePlayClient {
|
|
74
|
+
declare readonly packageName: string;
|
|
75
|
+
|
|
76
|
+
private readonly clientEmail: string;
|
|
77
|
+
|
|
78
|
+
private readonly privateKeyPem: string;
|
|
79
|
+
|
|
80
|
+
/** In-memory access-token cache; sized 1 because each client maps to one SA. */
|
|
81
|
+
private accessToken: AccessTokenCache | null = null;
|
|
82
|
+
|
|
83
|
+
/** Lazily-imported CryptoKey — Web Crypto can only import once per key. */
|
|
84
|
+
private signingKey: Promise<CryptoKey> | null = null;
|
|
85
|
+
|
|
86
|
+
private constructor(packageName: string, credentials: { client_email: string; private_key: string }) {
|
|
87
|
+
this.packageName = packageName;
|
|
88
|
+
this.clientEmail = credentials.client_email;
|
|
89
|
+
this.privateKeyPem = credentials.private_key;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public static fromSettings(settings: GooglePlaySettings): GooglePlayClient {
|
|
93
|
+
let credentials: { client_email: string; private_key: string };
|
|
94
|
+
try {
|
|
95
|
+
credentials = JSON.parse(settings.service_account_json);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
throw new Error('GooglePlayClient: service_account_json is not valid JSON');
|
|
98
|
+
}
|
|
99
|
+
if (!credentials.client_email || !credentials.private_key) {
|
|
100
|
+
throw new Error('GooglePlayClient: service account JSON missing client_email or private_key');
|
|
101
|
+
}
|
|
102
|
+
return new GooglePlayClient(settings.package_name, credentials);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// eslint-disable-next-line require-await -- `async` kept for API uniformity with the rest of GooglePlayClient
|
|
106
|
+
public async getSubscription(subscriptionId: string, purchaseToken: string): Promise<GooglePlaySubscriptionPurchase> {
|
|
107
|
+
const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
|
|
108
|
+
this.packageName
|
|
109
|
+
)}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent(purchaseToken)}`;
|
|
110
|
+
return this.apiGet<GooglePlaySubscriptionPurchase>(url, 'verifySub');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// eslint-disable-next-line require-await -- `async` kept for API uniformity with the rest of GooglePlayClient
|
|
114
|
+
public async getProductPurchase(productId: string, purchaseToken: string): Promise<GooglePlayProductPurchase> {
|
|
115
|
+
const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
|
|
116
|
+
this.packageName
|
|
117
|
+
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}`;
|
|
118
|
+
return this.apiGet<GooglePlayProductPurchase>(url, 'verifyINAPP');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Acknowledge a subscription purchase. Google requires this within 3 days of
|
|
123
|
+
* purchase, otherwise the order auto-refunds. POST returns 204 on success;
|
|
124
|
+
* 400 with "alreadyAcknowledged" if already acked (safe to swallow).
|
|
125
|
+
*/
|
|
126
|
+
public async acknowledgeSubscription(subscriptionId: string, purchaseToken: string): Promise<void> {
|
|
127
|
+
const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
|
|
128
|
+
this.packageName
|
|
129
|
+
)}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent(
|
|
130
|
+
purchaseToken
|
|
131
|
+
)}:acknowledge`;
|
|
132
|
+
await this.apiAcknowledge(url, 'acknowledgeSubscription');
|
|
133
|
+
logger.info('google_play subscription acknowledged', { subscriptionId, purchaseToken });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public async acknowledgeProduct(productId: string, purchaseToken: string): Promise<void> {
|
|
137
|
+
const url = `${ANDROID_PUBLISHER_BASE}/applications/${encodeURIComponent(
|
|
138
|
+
this.packageName
|
|
139
|
+
)}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`;
|
|
140
|
+
await this.apiAcknowledge(url, 'acknowledgeProduct');
|
|
141
|
+
logger.info('google_play product acknowledged', { productId, purchaseToken });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// -- internal: OAuth2 + HTTP -------------------------------------------------
|
|
145
|
+
|
|
146
|
+
private async apiGet<T>(url: string, opName: string): Promise<T> {
|
|
147
|
+
const token = await this.getAccessToken();
|
|
148
|
+
const resp = await fetch(url, {
|
|
149
|
+
method: 'GET',
|
|
150
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
151
|
+
});
|
|
152
|
+
if (resp.status === 401) {
|
|
153
|
+
// Token may have just expired between cache hit and request; refresh once.
|
|
154
|
+
this.accessToken = null;
|
|
155
|
+
const retryToken = await this.getAccessToken();
|
|
156
|
+
const retry = await fetch(url, {
|
|
157
|
+
method: 'GET',
|
|
158
|
+
headers: { Authorization: `Bearer ${retryToken}`, Accept: 'application/json' },
|
|
159
|
+
});
|
|
160
|
+
return parseOrThrow<T>(retry, opName);
|
|
161
|
+
}
|
|
162
|
+
return parseOrThrow<T>(resp, opName);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async apiAcknowledge(url: string, opName: string): Promise<void> {
|
|
166
|
+
const token = await this.getAccessToken();
|
|
167
|
+
const resp = await fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: {
|
|
170
|
+
Authorization: `Bearer ${token}`,
|
|
171
|
+
'Content-Type': 'application/json',
|
|
172
|
+
Accept: 'application/json',
|
|
173
|
+
},
|
|
174
|
+
// Google accepts an empty JSON body; developerPayload is optional and
|
|
175
|
+
// not used in our flow (we link purchases via obfuscatedExternalAccountId).
|
|
176
|
+
body: '{}',
|
|
177
|
+
});
|
|
178
|
+
// 204 No Content on success. 400 with reason=alreadyAcknowledged is fine.
|
|
179
|
+
if (resp.status === 204 || resp.ok) return;
|
|
180
|
+
const body = await resp.text();
|
|
181
|
+
if (resp.status === 400 && body.includes('alreadyAcknowledged')) return;
|
|
182
|
+
throw new Error(`Google Play ${opName} failed: HTTP ${resp.status} ${body.slice(0, 300)}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private async getAccessToken(): Promise<string> {
|
|
186
|
+
const now = Math.floor(Date.now() / 1000);
|
|
187
|
+
if (this.accessToken && this.accessToken.expiresAt - ACCESS_TOKEN_REFRESH_LEEWAY_SEC > now) {
|
|
188
|
+
return this.accessToken.token;
|
|
189
|
+
}
|
|
190
|
+
const fresh = await this.fetchAccessToken();
|
|
191
|
+
this.accessToken = fresh;
|
|
192
|
+
return fresh.token;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async fetchAccessToken(): Promise<AccessTokenCache> {
|
|
196
|
+
const now = Math.floor(Date.now() / 1000);
|
|
197
|
+
const claim = {
|
|
198
|
+
iss: this.clientEmail,
|
|
199
|
+
scope: ANDROID_PUBLISHER_SCOPE,
|
|
200
|
+
aud: OAUTH_TOKEN_URL,
|
|
201
|
+
exp: now + 3600,
|
|
202
|
+
iat: now,
|
|
203
|
+
};
|
|
204
|
+
const jwt = await this.signJwt(claim);
|
|
205
|
+
const resp = await fetch(OAUTH_TOKEN_URL, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
208
|
+
body: new URLSearchParams({
|
|
209
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
210
|
+
assertion: jwt,
|
|
211
|
+
}).toString(),
|
|
212
|
+
});
|
|
213
|
+
const text = await resp.text();
|
|
214
|
+
if (!resp.ok) {
|
|
215
|
+
throw new Error(`Google OAuth token exchange failed: HTTP ${resp.status} ${text.slice(0, 300)}`);
|
|
216
|
+
}
|
|
217
|
+
const data = JSON.parse(text) as { access_token: string; expires_in: number };
|
|
218
|
+
if (!data.access_token) {
|
|
219
|
+
throw new Error(`Google OAuth token exchange missing access_token: ${text.slice(0, 300)}`);
|
|
220
|
+
}
|
|
221
|
+
return { token: data.access_token, expiresAt: now + (data.expires_in ?? 3600) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private async signJwt(claim: Record<string, unknown>): Promise<string> {
|
|
225
|
+
const header = { alg: 'RS256', typ: 'JWT' };
|
|
226
|
+
const headerB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(header)));
|
|
227
|
+
const claimB64 = base64UrlEncode(new TextEncoder().encode(JSON.stringify(claim)));
|
|
228
|
+
const signingInput = `${headerB64}.${claimB64}`;
|
|
229
|
+
const key = await this.getSigningKey();
|
|
230
|
+
const sig = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, key, new TextEncoder().encode(signingInput));
|
|
231
|
+
return `${signingInput}.${base64UrlEncode(new Uint8Array(sig))}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private getSigningKey(): Promise<CryptoKey> {
|
|
235
|
+
if (!this.signingKey) {
|
|
236
|
+
this.signingKey = importPkcs8(this.privateKeyPem);
|
|
237
|
+
}
|
|
238
|
+
return this.signingKey;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// -- module helpers ----------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
async function parseOrThrow<T>(resp: Response, opName: string): Promise<T> {
|
|
245
|
+
if (!resp.ok) {
|
|
246
|
+
const body = await resp.text();
|
|
247
|
+
throw new Error(`Google Play ${opName} failed: HTTP ${resp.status} ${body.slice(0, 500)}`);
|
|
248
|
+
}
|
|
249
|
+
return (await resp.json()) as T;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function importPkcs8(pem: string): Promise<CryptoKey> {
|
|
253
|
+
// Service-account private_key is PKCS#8 PEM with literal \n sequences when
|
|
254
|
+
// it came out of JSON-stringification.
|
|
255
|
+
const normalized = pem.replace(/\\n/g, '\n');
|
|
256
|
+
const body = normalized
|
|
257
|
+
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
|
258
|
+
.replace(/-----END PRIVATE KEY-----/, '')
|
|
259
|
+
.replace(/\s+/g, '');
|
|
260
|
+
const der = base64DecodeToBytes(body);
|
|
261
|
+
return crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function base64DecodeToBytes(b64: string): Uint8Array {
|
|
265
|
+
// atob exists in CF Workers and Node 18+.
|
|
266
|
+
const bin = atob(b64);
|
|
267
|
+
const out = new Uint8Array(bin.length);
|
|
268
|
+
for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
273
|
+
let bin = '';
|
|
274
|
+
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]!);
|
|
275
|
+
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
276
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Top-level dispatch for a Google Play Real-Time Developer Notification.
|
|
2
|
+
//
|
|
3
|
+
// RTDN payload (already base64-decoded by routes/integrations/google-play.ts):
|
|
4
|
+
// {
|
|
5
|
+
// version, packageName, eventTimeMillis,
|
|
6
|
+
// subscriptionNotification?: { version, notificationType, purchaseToken, subscriptionId },
|
|
7
|
+
// oneTimeProductNotification?: { ... },
|
|
8
|
+
// voidedPurchaseNotification?: { purchaseToken, orderId, productType, refundType },
|
|
9
|
+
// testNotification?: { version }
|
|
10
|
+
// }
|
|
11
|
+
|
|
12
|
+
import logger from '../../../libs/logger';
|
|
13
|
+
import { GooglePlayClient } from '../client';
|
|
14
|
+
import { GooglePlaySubscriptionNotification, handleGooglePlaySubscriptionEvent } from './subscription';
|
|
15
|
+
import { GooglePlayVoidedPurchaseNotification, handleGooglePlayVoidedPurchase } from './voided';
|
|
16
|
+
|
|
17
|
+
export type GooglePlayRtdnPayload = {
|
|
18
|
+
version: string;
|
|
19
|
+
packageName: string;
|
|
20
|
+
eventTimeMillis?: string;
|
|
21
|
+
subscriptionNotification?: GooglePlaySubscriptionNotification;
|
|
22
|
+
oneTimeProductNotification?: {
|
|
23
|
+
version: string;
|
|
24
|
+
notificationType: number;
|
|
25
|
+
purchaseToken: string;
|
|
26
|
+
sku: string;
|
|
27
|
+
};
|
|
28
|
+
voidedPurchaseNotification?: GooglePlayVoidedPurchaseNotification;
|
|
29
|
+
testNotification?: {
|
|
30
|
+
version: string;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default async function handleGooglePlayEvent(
|
|
35
|
+
payload: GooglePlayRtdnPayload,
|
|
36
|
+
client: GooglePlayClient
|
|
37
|
+
): Promise<void> {
|
|
38
|
+
if (payload.testNotification) {
|
|
39
|
+
logger.info('google_play test notification received', { packageName: payload.packageName });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (payload.subscriptionNotification) {
|
|
44
|
+
await handleGooglePlaySubscriptionEvent({
|
|
45
|
+
packageName: payload.packageName,
|
|
46
|
+
client,
|
|
47
|
+
notification: payload.subscriptionNotification,
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (payload.voidedPurchaseNotification) {
|
|
53
|
+
await handleGooglePlayVoidedPurchase({
|
|
54
|
+
packageName: payload.packageName,
|
|
55
|
+
notification: payload.voidedPurchaseNotification,
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (payload.oneTimeProductNotification) {
|
|
61
|
+
logger.info('google_play one-time-product notification — not handled in A2 (subscriptions only)', {
|
|
62
|
+
packageName: payload.packageName,
|
|
63
|
+
sku: payload.oneTimeProductNotification.sku,
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
logger.warn('google_play notification with no recognized inner payload', { payload });
|
|
69
|
+
}
|