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.
Files changed (76) hide show
  1. package/api/src/crons/index.ts +22 -0
  2. package/api/src/crons/retry-pending-events.ts +58 -0
  3. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  4. package/api/src/integrations/app-store/client.ts +369 -0
  5. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  6. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  7. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  8. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  9. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  10. package/api/src/integrations/google-play/client.ts +276 -0
  11. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  12. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  13. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  14. package/api/src/integrations/google-play/setup.ts +43 -0
  15. package/api/src/integrations/google-play/verify.ts +251 -0
  16. package/api/src/integrations/iap-reconcile.ts +415 -0
  17. package/api/src/libs/audit.ts +38 -8
  18. package/api/src/libs/entitlement.ts +399 -0
  19. package/api/src/libs/env.ts +2 -0
  20. package/api/src/libs/security.ts +51 -0
  21. package/api/src/libs/subscription.ts +13 -1
  22. package/api/src/libs/util.ts +13 -0
  23. package/api/src/queues/event.ts +25 -19
  24. package/api/src/queues/webhook.ts +12 -2
  25. package/api/src/routes/entitlements.ts +105 -0
  26. package/api/src/routes/events.ts +2 -2
  27. package/api/src/routes/index.ts +12 -2
  28. package/api/src/routes/integrations/app-store.ts +267 -0
  29. package/api/src/routes/integrations/google-play.ts +324 -0
  30. package/api/src/routes/payment-methods.ts +130 -0
  31. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  32. package/api/src/store/models/customer.ts +14 -0
  33. package/api/src/store/models/entitlement-grant.ts +118 -0
  34. package/api/src/store/models/entitlement-product.ts +48 -0
  35. package/api/src/store/models/entitlement.ts +86 -0
  36. package/api/src/store/models/index.ts +9 -0
  37. package/api/src/store/models/invoice.ts +20 -0
  38. package/api/src/store/models/payment-method.ts +62 -1
  39. package/api/src/store/models/refund.ts +10 -0
  40. package/api/src/store/models/subscription.ts +14 -0
  41. package/api/src/store/models/types.ts +32 -0
  42. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  43. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  44. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  45. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  46. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  47. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  48. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  49. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  50. package/api/tests/libs/entitlement.spec.ts +347 -0
  51. package/blocklet.yml +1 -1
  52. package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
  53. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  54. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  55. package/cloudflare/run-build.js +23 -1
  56. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  57. package/cloudflare/shims/node-fetch.ts +35 -0
  58. package/cloudflare/shims/queue.ts +28 -2
  59. package/cloudflare/shims/sequelize-d1/model.ts +19 -0
  60. package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
  61. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  62. package/cloudflare/worker.ts +59 -4
  63. package/cloudflare/wrangler.jsonc +7 -1
  64. package/cloudflare/wrangler.staging.json +2 -1
  65. package/package.json +10 -6
  66. package/scripts/seed-google-play.ts +79 -0
  67. package/src/components/payment-method/app-store.tsx +103 -0
  68. package/src/components/payment-method/form.tsx +7 -1
  69. package/src/components/payment-method/google-play.tsx +85 -0
  70. package/src/components/subscription/list.tsx +20 -0
  71. package/src/locales/en.tsx +63 -0
  72. package/src/locales/zh.tsx +63 -0
  73. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  74. package/src/pages/admin/customers/customers/detail.tsx +6 -0
  75. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  76. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
@@ -0,0 +1,43 @@
1
+ // Google Play integration startup checks.
2
+ //
3
+ // Unlike Stripe (we register webhook URL with Stripe at startup), Google Play's
4
+ // Real-Time Developer Notifications is configured **server-side in Google Cloud
5
+ // Pub/Sub + Play Console**, not via API call. This file just sanity-checks the
6
+ // PaymentMethod settings on app start and surfaces clear errors early.
7
+
8
+ import logger from '../../libs/logger';
9
+ import { PaymentMethod } from '../../store/models';
10
+
11
+ export async function ensureGooglePlayConfigured(): Promise<void> {
12
+ const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
13
+ if (methods.length === 0) {
14
+ return;
15
+ }
16
+
17
+ for (const method of methods) {
18
+ const settings = PaymentMethod.decryptSettings(method.settings);
19
+ const cfg = settings.google_play;
20
+ if (!cfg) {
21
+ logger.warn('google_play PaymentMethod missing settings', { id: method.id });
22
+ // eslint-disable-next-line no-continue
23
+ continue;
24
+ }
25
+ if (!cfg.package_name) {
26
+ logger.warn('google_play PaymentMethod missing package_name', { id: method.id });
27
+ }
28
+ if (!cfg.service_account_json) {
29
+ logger.warn('google_play PaymentMethod missing service_account_json', { id: method.id });
30
+ } else {
31
+ try {
32
+ JSON.parse(cfg.service_account_json);
33
+ } catch (err) {
34
+ logger.error('google_play service_account_json is not valid JSON', { id: method.id });
35
+ }
36
+ }
37
+ if (!cfg.pubsub_topic_name) {
38
+ logger.warn('google_play PaymentMethod missing pubsub_topic_name', { id: method.id });
39
+ }
40
+ }
41
+
42
+ logger.info('google_play PaymentMethod config checked', { count: methods.length });
43
+ }
@@ -0,0 +1,251 @@
1
+ // Google Cloud Pub/Sub Push 验签。
2
+ //
3
+ // Pub/Sub Push 推送的每个请求都会带 `Authorization: Bearer <JWT>` 头,JWT 由 Google
4
+ // 用其内部服务账号私钥签发(RS256),通过 https://www.googleapis.com/oauth2/v3/certs
5
+ // 拉到的 JWK Set 可以做真实签名校验。
6
+ //
7
+ // 此前 A2 mock 阶段仅做 claim 检查不验签,等同于 iOS JWS 漏洞的 Android 镜像版
8
+ // (CWE-347)。本文件接入 Web Crypto 完成 RS256 验签,运行时同时兼容 Node 22+ 与
9
+ // Cloudflare Workers(payment-kit 是统一代码)。
10
+
11
+ import logger from '../../libs/logger';
12
+
13
+ export type PubSubJwtClaims = {
14
+ iss: string;
15
+ aud: string;
16
+ iat: number;
17
+ exp: number;
18
+ email?: string;
19
+ email_verified?: boolean;
20
+ };
21
+
22
+ type JwtHeader = {
23
+ alg: string;
24
+ kid?: string;
25
+ typ?: string;
26
+ };
27
+
28
+ const GOOGLE_PUBSUB_ISSUERS = new Set(['https://accounts.google.com', 'accounts.google.com']);
29
+ const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
30
+
31
+ // Cache parsed JWKS keys by `kid`. Google rotates keys ~daily; 1h TTL keeps us
32
+ // fresh enough without hammering the JWKS endpoint on every webhook.
33
+ const JWKS_CACHE_TTL_MS = 60 * 60 * 1000;
34
+ let jwksCache: { fetchedAt: number; keys: Map<string, CryptoKey> } | null = null;
35
+
36
+ function decodeSegment(segment: string): Buffer {
37
+ return Buffer.from(segment, 'base64url');
38
+ }
39
+
40
+ function parseJsonSegment<T>(segment: string): T {
41
+ return JSON.parse(decodeSegment(segment).toString('utf8')) as T;
42
+ }
43
+
44
+ /** Decode JWT claims (payload segment only). Does NOT verify signature. */
45
+ export function decodePubSubJwt(token: string): PubSubJwtClaims {
46
+ const parts = token.split('.');
47
+ if (parts.length !== 3) {
48
+ throw new Error('Pub/Sub JWT format invalid: expected 3 segments');
49
+ }
50
+ return parseJsonSegment<PubSubJwtClaims>(parts[1]!);
51
+ }
52
+
53
+ export type FetchJwks = () => Promise<Map<string, CryptoKey>>;
54
+
55
+ export type VerifyOptions = {
56
+ /** 当前 Worker 的公网 URL,必须匹配 JWT 的 `aud` claim */
57
+ expectedAudience: string;
58
+ /**
59
+ * The Pub/Sub push subscription's OIDC service-account email. When set, the
60
+ * JWT MUST carry `email_verified=true` and a matching `email` — otherwise any
61
+ * Google-issued token for this audience could forge RTDN payloads (PR #1381
62
+ * review P1). Leave undefined to skip (the call site logs a warning).
63
+ */
64
+ expectedEmail?: string;
65
+ /** 容忍的时钟偏移(秒),默认 60 */
66
+ clockTolerance?: number;
67
+ /** 当前 unix 时间(秒),默认 Date.now() / 1000;用于测试注入 */
68
+ now?: number;
69
+ /** 覆盖 JWKS fetcher(测试用)。默认拉 Google `oauth2/v3/certs` 并缓存 1h */
70
+ fetchJwks?: FetchJwks;
71
+ /**
72
+ * Bypass RS256 signature verification.
73
+ *
74
+ * Only safe values:
75
+ * - true in unit tests that use synthetic JWTs (no real signing key)
76
+ * - true via `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY=true` for local sandbox
77
+ * debugging — NEVER in production.
78
+ */
79
+ skipSignature?: boolean;
80
+ };
81
+
82
+ function defaultSkipSignature(): boolean {
83
+ return process.env.GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY === 'true';
84
+ }
85
+
86
+ /**
87
+ * Production fail-closed wrapper around the skipSignature flag — whether it
88
+ * came from `options.skipSignature` or `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY`.
89
+ * In production we refuse to honor the bypass (logs loudly so the
90
+ * misconfiguration is visible). The flag is meant for tests / local sandbox
91
+ * debugging only; in production it would silently let any caller forge an
92
+ * RTDN with arbitrary JWT claims (CWE-347).
93
+ */
94
+ function effectiveSkipSignature(requested: boolean): boolean {
95
+ if (!requested) return false;
96
+ if (process.env.BLOCKLET_MODE === 'production') {
97
+ logger.error(
98
+ 'google_play: signature verification skip refused in production — Pub/Sub JWT signature verification stays enabled'
99
+ );
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ async function fetchGoogleJwks(): Promise<Map<string, CryptoKey>> {
106
+ if (jwksCache && Date.now() - jwksCache.fetchedAt < JWKS_CACHE_TTL_MS) {
107
+ return jwksCache.keys;
108
+ }
109
+
110
+ const res = await fetch(GOOGLE_JWKS_URL);
111
+ if (!res.ok) {
112
+ throw new Error(`failed to fetch Google JWKS: HTTP ${res.status}`);
113
+ }
114
+ // Google JWKS entries always carry `kid` and `alg`; the lib `JsonWebKey` type
115
+ // doesn't, so widen here. Other fields (`kty`, `n`, `e`, `use`) are validated
116
+ // implicitly by `crypto.subtle.importKey`.
117
+ const body = (await res.json()) as { keys: Array<JsonWebKey & { kid?: string; alg?: string }> };
118
+ if (!Array.isArray(body?.keys)) {
119
+ throw new Error('Google JWKS response missing `keys` array');
120
+ }
121
+
122
+ const keys = new Map<string, CryptoKey>();
123
+ for (const jwk of body.keys) {
124
+ // eslint-disable-next-line no-continue -- guard-style skip for JWK entries we won't use
125
+ if (!jwk.kid) continue;
126
+ // eslint-disable-next-line no-continue -- guard-style skip for JWK entries we won't use
127
+ if (jwk.alg && jwk.alg !== 'RS256') continue;
128
+ // eslint-disable-next-line no-await-in-loop -- sequential key import keeps the call deterministic + small N
129
+ const key = await globalThis.crypto.subtle.importKey(
130
+ 'jwk',
131
+ jwk,
132
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
133
+ false,
134
+ ['verify']
135
+ );
136
+ keys.set(jwk.kid, key);
137
+ }
138
+ jwksCache = { fetchedAt: Date.now(), keys };
139
+ return keys;
140
+ }
141
+
142
+ /**
143
+ * Full Pub/Sub JWT verification: RS256 signature + issuer + audience + expiry.
144
+ *
145
+ * Async because both the JWKS fetch and `crypto.subtle.verify` are async on
146
+ * Web Crypto. Use `skipSignature: true` (or set
147
+ * `GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY=true`) ONLY for unit tests / local
148
+ * sandbox debugging — never in production.
149
+ */
150
+ export async function verifyPubSubJwt(token: string, options: VerifyOptions): Promise<PubSubJwtClaims> {
151
+ const parts = token.split('.');
152
+ if (parts.length !== 3) {
153
+ throw new Error('Pub/Sub JWT format invalid: expected 3 segments');
154
+ }
155
+ const [headerSeg, payloadSeg, signatureSeg] = parts as [string, string, string];
156
+
157
+ const header = parseJsonSegment<JwtHeader>(headerSeg);
158
+ const claims = parseJsonSegment<PubSubJwtClaims>(payloadSeg);
159
+
160
+ if (!GOOGLE_PUBSUB_ISSUERS.has(claims.iss)) {
161
+ throw new Error(`Pub/Sub JWT issuer not recognized: ${claims.iss}`);
162
+ }
163
+
164
+ if (claims.aud !== options.expectedAudience) {
165
+ throw new Error(`Pub/Sub JWT audience mismatch: got ${claims.aud}, expected ${options.expectedAudience}`);
166
+ }
167
+
168
+ const now = options.now ?? Math.floor(Date.now() / 1000);
169
+ const tolerance = options.clockTolerance ?? 60;
170
+ if (claims.exp + tolerance < now) {
171
+ throw new Error(`Pub/Sub JWT expired (exp=${claims.exp}, now=${now})`);
172
+ }
173
+ if (claims.iat - tolerance > now) {
174
+ throw new Error(`Pub/Sub JWT issued in the future (iat=${claims.iat}, now=${now})`);
175
+ }
176
+
177
+ // Sender-identity check: a valid Google-issued token for our audience is not
178
+ // enough — require the token to belong to the configured Pub/Sub push service
179
+ // account, with a verified email. Without this, any Google identity able to
180
+ // mint a token for this audience could forge RTDN payloads (PR #1381 P1).
181
+ if (options.expectedEmail) {
182
+ if (claims.email_verified !== true) {
183
+ throw new Error('Pub/Sub JWT email not verified');
184
+ }
185
+ if (claims.email !== options.expectedEmail) {
186
+ throw new Error(`Pub/Sub JWT email mismatch: got ${claims.email ?? '(none)'}, expected ${options.expectedEmail}`);
187
+ }
188
+ }
189
+
190
+ const skipSignature = effectiveSkipSignature(options.skipSignature ?? defaultSkipSignature());
191
+ if (skipSignature) {
192
+ logger.warn('Pub/Sub JWT signature verification skipped — set only in tests / local sandbox', {
193
+ iss: claims.iss,
194
+ aud: claims.aud,
195
+ });
196
+ return claims;
197
+ }
198
+
199
+ if (header.alg !== 'RS256') {
200
+ throw new Error(`Pub/Sub JWT alg not supported: ${header.alg} (expected RS256)`);
201
+ }
202
+ if (!header.kid) {
203
+ throw new Error('Pub/Sub JWT header missing `kid`');
204
+ }
205
+
206
+ const fetchKeys = options.fetchJwks ?? fetchGoogleJwks;
207
+ const keys = await fetchKeys();
208
+ const key = keys.get(header.kid);
209
+ if (!key) {
210
+ throw new Error(`Pub/Sub JWT kid not found in JWKS: ${header.kid}`);
211
+ }
212
+
213
+ const signingInput = new TextEncoder().encode(`${headerSeg}.${payloadSeg}`);
214
+ const signature = decodeSegment(signatureSeg);
215
+ const ok = await globalThis.crypto.subtle.verify(
216
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
217
+ key,
218
+ signature,
219
+ signingInput
220
+ );
221
+ if (!ok) {
222
+ throw new Error('Pub/Sub JWT signature verification failed');
223
+ }
224
+
225
+ return claims;
226
+ }
227
+
228
+ /** Test-only: reset the in-memory JWKS cache between cases. */
229
+ // eslint-disable-next-line @typescript-eslint/naming-convention
230
+ export function __resetJwksCacheForTests(): void {
231
+ jwksCache = null;
232
+ }
233
+
234
+ export type PubSubMessage = {
235
+ message: {
236
+ data: string; // base64-encoded JSON
237
+ messageId: string;
238
+ publishTime: string;
239
+ attributes?: Record<string, string>;
240
+ };
241
+ subscription: string;
242
+ };
243
+
244
+ /** Decode the base64 `data` field inside a Pub/Sub push envelope. */
245
+ export function decodePubSubMessage<T = unknown>(envelope: PubSubMessage): T {
246
+ if (!envelope?.message?.data) {
247
+ throw new Error('Pub/Sub envelope has no message.data');
248
+ }
249
+ const decoded = Buffer.from(envelope.message.data, 'base64').toString('utf8');
250
+ return JSON.parse(decoded) as T;
251
+ }