payment-kit 1.29.6 → 1.29.8
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/host-glue.ts +79 -0
- package/api/src/integrations/app-store/client.ts +7 -13
- package/api/src/integrations/app-store/native-api.ts +102 -0
- package/api/src/integrations/app-store/native-asn1.ts +49 -0
- package/api/src/integrations/app-store/native-jws.ts +222 -0
- package/api/src/integrations/app-store/native-receipt.ts +105 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +40 -79
- package/api/src/libs/env.ts +64 -22
- package/api/src/routes/hono/credit-grants.ts +1 -1
- package/api/tests/integrations/app-store/client.spec.ts +83 -57
- package/api/tests/integrations/app-store/fixtures/README.md +54 -0
- package/api/tests/integrations/app-store/fixtures/keys/int.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/keys/leaf.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/keys/root.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/keys/wrongint.key.pem +5 -0
- package/api/tests/integrations/app-store/fixtures/make-chain.spec.ts +152 -0
- package/api/tests/integrations/app-store/fixtures/make-chain.ts +326 -0
- package/api/tests/integrations/app-store/fixtures/real-sandbox.json +43 -0
- package/api/tests/integrations/app-store/fixtures/real-sandbox.jws +1 -0
- package/api/tests/integrations/app-store/native-api.spec.ts +172 -0
- package/api/tests/integrations/app-store/native-integration.spec.ts +78 -0
- package/api/tests/integrations/app-store/native-jws.spec.ts +219 -0
- package/api/tests/integrations/app-store/native-receipt.spec.ts +161 -0
- package/api/tests/libs/notification-guard.spec.ts +25 -8
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +11 -46
- package/cloudflare/tests/cf-adapter.spec.ts +2 -1
- package/cloudflare/tests/x509-probe.mjs +260 -0
- package/package.json +7 -10
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +0 -17
|
@@ -1,49 +1,30 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Signature verification for App Store JWS payloads — native (node:crypto +
|
|
2
|
+
// crypto.subtle), no external dependency. The Apple JWS chain/signature logic
|
|
3
|
+
// lives in native-jws.ts (verifyAppleJws); the App Store Server API client lives
|
|
4
|
+
// in native-api.ts (nativeGetAllSubscriptionStatuses). The legacy
|
|
5
|
+
// `@apple/app-store-server-library` path was removed once native became the only
|
|
6
|
+
// path (design §10 Phase 3) — that evicts jsrsasign + the whole apple cluster
|
|
7
|
+
// from both payment-core bundles.
|
|
2
8
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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';
|
|
9
|
+
// Env var `APP_STORE_SKIP_SIGNATURE_VERIFY=true` bypasses verification
|
|
10
|
+
// (decode-only) — for unit tests / sandbox debugging, fail-closed in production.
|
|
27
11
|
|
|
28
12
|
import logger from '../../libs/logger';
|
|
29
13
|
import { appStoreSkipSignatureVerify, isProduction } from '../../libs/env';
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
const verifierCache = new Map<string, SignedDataVerifier>();
|
|
14
|
+
import { JwsNotificationPayload, JwsTransactionPayload, verifyAppleJws } from './native-jws';
|
|
15
|
+
import { nativeGetAllSubscriptionStatuses } from './native-api';
|
|
33
16
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
verifierCache.set(key, verifier);
|
|
46
|
-
return verifier;
|
|
17
|
+
/**
|
|
18
|
+
* Minimal local shape of Apple's App Store Server API StatusResponse — only the
|
|
19
|
+
* fields `AppStoreClient.getSubscriptionStatus` reads (client.ts:337-339).
|
|
20
|
+
*/
|
|
21
|
+
export interface StatusResponse {
|
|
22
|
+
data?: Array<{
|
|
23
|
+
lastTransactions?: Array<{
|
|
24
|
+
originalTransactionId?: string;
|
|
25
|
+
signedTransactionInfo?: string;
|
|
26
|
+
}>;
|
|
27
|
+
}>;
|
|
47
28
|
}
|
|
48
29
|
|
|
49
30
|
export function isSignatureVerificationSkipped(): boolean {
|
|
@@ -64,28 +45,29 @@ export function isSignatureVerificationSkipped(): boolean {
|
|
|
64
45
|
|
|
65
46
|
export async function verifySignedTransaction(
|
|
66
47
|
signedTransaction: string,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
): Promise<
|
|
48
|
+
_bundleId: string,
|
|
49
|
+
_environment: 'production' | 'sandbox'
|
|
50
|
+
): Promise<JwsTransactionPayload> {
|
|
70
51
|
if (isSignatureVerificationSkipped()) {
|
|
71
52
|
logger.warn('app_store: signature verification skipped via APP_STORE_SKIP_SIGNATURE_VERIFY');
|
|
72
|
-
return decodeUnsafe<
|
|
53
|
+
return decodeUnsafe<JwsTransactionPayload>(signedTransaction);
|
|
73
54
|
}
|
|
74
|
-
|
|
75
|
-
|
|
55
|
+
// Stateless native verify — anchors to vendored Apple roots, validates against
|
|
56
|
+
// the payload's signedDate. bundleId/environment stay in the signature for
|
|
57
|
+
// caller-layer enforcement (client.ts) + API parity.
|
|
58
|
+
return verifyAppleJws<JwsTransactionPayload>(signedTransaction);
|
|
76
59
|
}
|
|
77
60
|
|
|
78
61
|
export async function verifySignedNotification(
|
|
79
62
|
signedPayload: string,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
): Promise<
|
|
63
|
+
_bundleId: string,
|
|
64
|
+
_environment: 'production' | 'sandbox'
|
|
65
|
+
): Promise<JwsNotificationPayload> {
|
|
83
66
|
if (isSignatureVerificationSkipped()) {
|
|
84
67
|
logger.warn('app_store: signature verification skipped via APP_STORE_SKIP_SIGNATURE_VERIFY');
|
|
85
|
-
return decodeUnsafe<
|
|
68
|
+
return decodeUnsafe<JwsNotificationPayload>(signedPayload);
|
|
86
69
|
}
|
|
87
|
-
|
|
88
|
-
return verifier.verifyAndDecodeNotification(signedPayload);
|
|
70
|
+
return verifyAppleJws<JwsNotificationPayload>(signedPayload);
|
|
89
71
|
}
|
|
90
72
|
|
|
91
73
|
/** Decode-only fallback for when signature verification is intentionally bypassed. */
|
|
@@ -101,13 +83,6 @@ function decodeUnsafe<T>(jws: string): T {
|
|
|
101
83
|
}
|
|
102
84
|
}
|
|
103
85
|
|
|
104
|
-
// Exposed for tests — reset module-level caches between tests.
|
|
105
|
-
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
106
|
-
export function __resetVerifierCachesForTests(): void {
|
|
107
|
-
verifierCache.clear();
|
|
108
|
-
apiClientCache.clear();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
86
|
// ============================================================================
|
|
112
87
|
// App Store Server API client — for pulling fresh state (subscription status,
|
|
113
88
|
// transaction history) when our local cache might be stale (e.g. at renewal,
|
|
@@ -123,29 +98,15 @@ export type AppStoreApiCredentials = {
|
|
|
123
98
|
environment: 'production' | 'sandbox';
|
|
124
99
|
};
|
|
125
100
|
|
|
126
|
-
const apiClientCache = new Map<string, AppStoreServerAPIClient>();
|
|
127
|
-
async function getApiClient(creds: AppStoreApiCredentials): Promise<AppStoreServerAPIClient> {
|
|
128
|
-
const key = `${creds.bundleId}:${creds.environment}:${creds.keyId}`;
|
|
129
|
-
const cached = apiClientCache.get(key);
|
|
130
|
-
if (cached) return cached;
|
|
131
|
-
// Lazy import — same reason as getVerifier (global-scope I/O ban in CF Workers).
|
|
132
|
-
const mod = await import('@apple/app-store-server-library');
|
|
133
|
-
const env = creds.environment === 'production' ? mod.Environment.PRODUCTION : mod.Environment.SANDBOX;
|
|
134
|
-
const client = new mod.AppStoreServerAPIClient(creds.privateKeyPem, creds.keyId, creds.issuerId, creds.bundleId, env);
|
|
135
|
-
apiClientCache.set(key, client);
|
|
136
|
-
return client;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
101
|
/**
|
|
140
|
-
* Fetch all subscription statuses for a given originalTransactionId
|
|
141
|
-
*
|
|
142
|
-
* `
|
|
143
|
-
*
|
|
102
|
+
* Fetch all subscription statuses for a given originalTransactionId via the
|
|
103
|
+
* native App Store Server API client (ES256 JWT bearer + fetch). Returns Apple's
|
|
104
|
+
* StatusResponse, whose `signedTransactionInfo` JWS strings are verified by the
|
|
105
|
+
* caller (client.ts) with verifyAppleJws.
|
|
144
106
|
*/
|
|
145
107
|
export async function getAllSubscriptionStatuses(
|
|
146
108
|
originalTransactionId: string,
|
|
147
109
|
creds: AppStoreApiCredentials
|
|
148
110
|
): Promise<StatusResponse> {
|
|
149
|
-
|
|
150
|
-
return client.getAllSubscriptionStatuses(originalTransactionId);
|
|
111
|
+
return nativeGetAllSubscriptionStatuses(originalTransactionId, creds);
|
|
151
112
|
}
|
package/api/src/libs/env.ts
CHANGED
|
@@ -57,6 +57,31 @@ const numConfig = (key: string, fallback: number): number => {
|
|
|
57
57
|
return v ? +v : fallback;
|
|
58
58
|
};
|
|
59
59
|
|
|
60
|
+
// Unified boolean env parsing. Accepts 1/true/yes/on (truthy) and 0/false/no/off
|
|
61
|
+
// (falsy), case-insensitive; unset / empty / unrecognized → `fallback`. Replaces
|
|
62
|
+
// the ad-hoc `=== '1'` / `=== 'true'` / `!== 'false'` conventions that used to be
|
|
63
|
+
// scattered across these accessors, so operators no longer have to guess whether a
|
|
64
|
+
// flag wants `1` or `true`.
|
|
65
|
+
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
66
|
+
const FALSY = new Set(['0', 'false', 'no', 'off']);
|
|
67
|
+
const parseBool = (value: string | undefined, fallback = false): boolean => {
|
|
68
|
+
if (value === undefined || value === '') return fallback;
|
|
69
|
+
const v = value.trim().toLowerCase();
|
|
70
|
+
if (TRUTHY.has(v)) return true;
|
|
71
|
+
if (FALSY.has(v)) return false;
|
|
72
|
+
return fallback;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// First non-empty value among keys — used to honor a renamed key while still
|
|
76
|
+
// reading the deprecated alias (new name wins, old name kept as fallback).
|
|
77
|
+
const readConfigAny = (...keys: string[]): string | undefined => {
|
|
78
|
+
for (const key of keys) {
|
|
79
|
+
const v = readConfig(key);
|
|
80
|
+
if (v !== undefined && v !== '') return v;
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
60
85
|
export const paymentStatCronTime = (): string => '0 1 0 * * *'; // 默认每天一次,计算前一天的
|
|
61
86
|
export const subscriptionCronTime = (): string => readConfig('SUBSCRIPTION_CRON_TIME') || '0 */30 * * * *';
|
|
62
87
|
export const notificationCronTime = (): string => readConfig('NOTIFICATION_CRON_TIME') || '0 5 */6 * * *';
|
|
@@ -67,7 +92,6 @@ export const stripeInvoiceCronTime = (): string => readConfig('STRIPE_INVOICE_CR
|
|
|
67
92
|
export const stripePaymentCronTime = (): string => readConfig('STRIPE_PAYMENT_CRON_TIME') || '0 */20 * * * *';
|
|
68
93
|
export const stripeSubscriptionCronTime = (): string => readConfig('STRIPE_SUBSCRIPTION_CRON_TIME') || '0 10 */8 * * *';
|
|
69
94
|
export const revokeStakeCronTime = (): string => readConfig('REVOKE_STAKE_CRON_TIME') || '0 */5 * * * *';
|
|
70
|
-
export const daysUntilCancel = (): string | undefined => readConfig('DAYS_UNTIL_CANCEL');
|
|
71
95
|
export const meteringSubscriptionDetectionCronTime = (): string =>
|
|
72
96
|
readConfig('METERING_SUBSCRIPTION_DETECTION_CRON_TIME') || '0 0 10 * * *';
|
|
73
97
|
export const overdueDetectionCronTime = (): string => readConfig('OVERDUE_DETECTION_CRON_TIME') || '0 0 10 * * *';
|
|
@@ -93,20 +117,26 @@ export const sequelizeOptionsPoolIdle = (): number => numConfig('SEQUELIZE_OPTIO
|
|
|
93
117
|
|
|
94
118
|
export const updateDataConcurrency = (): number => numConfig('UPDATE_DATA_CONCURRENCY', 5);
|
|
95
119
|
|
|
96
|
-
// When
|
|
97
|
-
//
|
|
120
|
+
// When truthy, the system stops accepting new orders. Existing checkout sessions
|
|
121
|
+
// can still be viewed but new submissions will be rejected.
|
|
122
|
+
// Renamed PAYMENT_KIT_STOP_ACCEPTING_ORDERS → PAYMENT_STOP_ACCEPTING_ORDERS; the
|
|
123
|
+
// old name is still honored as a deprecated alias.
|
|
98
124
|
export const stopAcceptingOrders = (): boolean =>
|
|
99
|
-
|
|
125
|
+
parseBool(readConfigAny('PAYMENT_STOP_ACCEPTING_ORDERS', 'PAYMENT_KIT_STOP_ACCEPTING_ORDERS'));
|
|
100
126
|
|
|
101
127
|
export const exchangeRateCacheTTLSeconds = (): number => numConfig('EXCHANGE_RATE_CACHE_TTL_SECONDS', 10 * 60);
|
|
102
128
|
|
|
103
|
-
// System-level maximum pending amount limit (in token format, e.g., "10")
|
|
104
|
-
//
|
|
105
|
-
|
|
129
|
+
// System-level maximum pending amount limit (in token format, e.g., "10"). Default 5.
|
|
130
|
+
// Renamed PAYMENT_KIT_MAX_PENDING_AMOUNT → PAYMENT_MAX_PENDING_AMOUNT; the old name
|
|
131
|
+
// is still honored as a deprecated alias.
|
|
132
|
+
export const systemMaxPendingAmount = (): number => {
|
|
133
|
+
const v = readConfigAny('PAYMENT_MAX_PENDING_AMOUNT', 'PAYMENT_KIT_MAX_PENDING_AMOUNT');
|
|
134
|
+
return v ? +v : 5;
|
|
135
|
+
};
|
|
106
136
|
|
|
107
137
|
// Whether a locked price may still be edited. Lazy (reads injected config via
|
|
108
138
|
// the boundary at call time) like every other Phase 8 accessor.
|
|
109
|
-
export const allowChangeLockedPrice = (): boolean => readConfig('PAYMENT_CHANGE_LOCKED_PRICE')
|
|
139
|
+
export const allowChangeLockedPrice = (): boolean => parseBool(readConfig('PAYMENT_CHANGE_LOCKED_PRICE'));
|
|
110
140
|
|
|
111
141
|
// ──────────────────────────────────────────────────────────────────────────
|
|
112
142
|
// Phase 8 (W2′): converged accessors. Every read below used to live inline in
|
|
@@ -122,7 +152,7 @@ export const isProduction = (): boolean => blockletMode() === 'production';
|
|
|
122
152
|
export const nodeEnv = (): string | undefined => readConfig('NODE_ENV');
|
|
123
153
|
export const isTestEnv = (): boolean => nodeEnv() === 'test';
|
|
124
154
|
export const isDevelopmentEnv = (): boolean => nodeEnv() === 'development';
|
|
125
|
-
export const enableDevFakeAuth = (): boolean => readConfig('ENABLE_DEV_FAKE_AUTH')
|
|
155
|
+
export const enableDevFakeAuth = (): boolean => parseBool(readConfig('ENABLE_DEV_FAKE_AUTH'));
|
|
126
156
|
|
|
127
157
|
// -- tenant mode-source (getTenantMode / getDefaultInstanceDid read these) --
|
|
128
158
|
export const tenantModeRaw = (): string | undefined => readConfig('PAYMENT_TENANT_MODE');
|
|
@@ -138,14 +168,14 @@ export const blockletPort = (): string | undefined => readConfig('BLOCKLET_PORT'
|
|
|
138
168
|
export const blockletMountPoints = (): string | undefined => readConfig('BLOCKLET_MOUNT_POINTS');
|
|
139
169
|
|
|
140
170
|
// -- integrations --
|
|
141
|
-
export const appStoreWriteEnabled = (): boolean => readConfig('APP_STORE_WRITE_ENABLED')
|
|
142
|
-
export const appStoreSkipSignatureVerify = (): boolean => readConfig('APP_STORE_SKIP_SIGNATURE_VERIFY')
|
|
171
|
+
export const appStoreWriteEnabled = (): boolean => parseBool(readConfig('APP_STORE_WRITE_ENABLED'));
|
|
172
|
+
export const appStoreSkipSignatureVerify = (): boolean => parseBool(readConfig('APP_STORE_SKIP_SIGNATURE_VERIFY'));
|
|
143
173
|
export const googlePubsubSkipSignatureVerify = (): boolean =>
|
|
144
|
-
readConfig('GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY')
|
|
174
|
+
parseBool(readConfig('GOOGLE_PUBSUB_SKIP_SIGNATURE_VERIFY'));
|
|
145
175
|
export const googlePubsubPushServiceAccount = (): string | undefined =>
|
|
146
176
|
readConfig('GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT');
|
|
147
177
|
export const googlePubsubAllowUnverifiedSender = (): boolean =>
|
|
148
|
-
readConfig('GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER')
|
|
178
|
+
parseBool(readConfig('GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER'));
|
|
149
179
|
export const googlePlayWebhookUrl = (): string | undefined => readConfig('GOOGLE_PLAY_WEBHOOK_URL');
|
|
150
180
|
export const stripeWebhookSecret = (): string | undefined => readConfig('STRIPE_WEBHOOK_SECRET');
|
|
151
181
|
export const iapReconcileBatchSize = (): number => Number(readConfig('IAP_RECONCILE_BATCH_SIZE') ?? '100');
|
|
@@ -155,9 +185,12 @@ export const paymentBillingThreshold = (): number => +(readConfig('PAYMENT_BILLI
|
|
|
155
185
|
export const paymentMinStakeAmount = (): number => +(readConfig('PAYMENT_MIN_STAKE_AMOUNT') as string);
|
|
156
186
|
export const paymentDaysUntilDue = (): string | undefined => readConfig('PAYMENT_DAYS_UNTIL_DUE');
|
|
157
187
|
export const paymentDaysUntilCancel = (): string | undefined => readConfig('PAYMENT_DAYS_UNTIL_CANCEL');
|
|
158
|
-
export const paymentReloadSubscriptionJobs = (): boolean => readConfig('PAYMENT_RELOAD_SUBSCRIPTION_JOBS')
|
|
188
|
+
export const paymentReloadSubscriptionJobs = (): boolean => parseBool(readConfig('PAYMENT_RELOAD_SUBSCRIPTION_JOBS'));
|
|
159
189
|
export const paymentRateVolatilityThreshold = (): string | undefined => readConfig('PAYMENT_RATE_VOLATILITY_THRESHOLD');
|
|
160
|
-
|
|
190
|
+
// Defaults to livemode (true) when unset. NOTE: with the unified parser, an
|
|
191
|
+
// explicit falsy value (false/0/no/off) now means testmode — previously ONLY the
|
|
192
|
+
// literal string 'false' did, so PAYMENT_LIVEMODE=0 used to (mis)read as livemode.
|
|
193
|
+
export const paymentLivemode = (): boolean => parseBool(readConfig('PAYMENT_LIVEMODE'), true);
|
|
161
194
|
|
|
162
195
|
// -- credit queue --
|
|
163
196
|
export const creditLowBalanceThresholdPercentage = (): number =>
|
|
@@ -172,8 +205,8 @@ export const creditQueueConcurrency = (): number =>
|
|
|
172
205
|
export const exchangeRateCacheTTLFromEnv = (): boolean => hasConfig('EXCHANGE_RATE_CACHE_TTL_SECONDS');
|
|
173
206
|
|
|
174
207
|
// -- store / sequelize logging --
|
|
175
|
-
export const sqlLog = (): boolean => readConfig('SQL_LOG')
|
|
176
|
-
export const sqlBenchmark = (): boolean => readConfig('SQL_BENCHMARK')
|
|
208
|
+
export const sqlLog = (): boolean => parseBool(readConfig('SQL_LOG'));
|
|
209
|
+
export const sqlBenchmark = (): boolean => parseBool(readConfig('SQL_BENCHMARK'));
|
|
177
210
|
|
|
178
211
|
// -- CF worker runtime detection + env mirror (set by cloudflare/worker.ts on
|
|
179
212
|
// globalThis; the boundary so core never reads the global directly) --
|
|
@@ -184,17 +217,26 @@ export const isCfWorker = (): boolean => !!cfEnv();
|
|
|
184
217
|
* THE single, reusable gate for Blocklet-Server-ONLY features.
|
|
185
218
|
*
|
|
186
219
|
* True only under Blocklet Server, where the full @blocklet/sdk host runtime is
|
|
187
|
-
* present —
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
220
|
+
* present — the env the SDK's `checkBlockletEnvironment` requires (BLOCKLET_APP_ID /
|
|
221
|
+
* BLOCKLET_DID / BLOCKLET_APP_EK / ABT_NODE_*).
|
|
222
|
+
*
|
|
223
|
+
* Keyed on BLOCKLET_DID, NOT BLOCKLET_APP_ID: the CF worker injects
|
|
224
|
+
* BLOCKLET_APP_ID = APP_PID into the config slot (cloudflare/worker.ts
|
|
225
|
+
* envToPaymentCoreConfig — blockletAppId() legitimately needs it for app identity
|
|
226
|
+
* in auth.ts / util.ts), which made the old BLOCKLET_APP_ID check spuriously true
|
|
227
|
+
* on CF and defeated the transport guard below. BLOCKLET_DID is supplied only by
|
|
228
|
+
* the real Blocklet Server runtime — the CF worker uses APP_PID for the node DID
|
|
229
|
+
* and never sets BLOCKLET_DID in config, and arc-node never injects it — so it
|
|
230
|
+
* cleanly distinguishes the three hosts. (BLOCKLET_APP_EK would also be BS-only in
|
|
231
|
+
* production, but the test harness sets it for the crypto shim, so DID is the
|
|
232
|
+
* unambiguous discriminator.)
|
|
191
233
|
*
|
|
192
234
|
* Any integration that only works on Blocklet Server (the @blocklet/sdk
|
|
193
235
|
* notification / eventbus / relay transports, and any future host-service feature)
|
|
194
236
|
* MUST gate on this and skip itself off Blocklet Server — never branch on
|
|
195
237
|
* isCfWorker()/hasDynamicIdentity() ad hoc, so "what is BS-only" stays in one place.
|
|
196
238
|
*/
|
|
197
|
-
export const isBlockletServer = (): boolean => hasConfig('
|
|
239
|
+
export const isBlockletServer = (): boolean => hasConfig('BLOCKLET_DID');
|
|
198
240
|
|
|
199
241
|
export default {
|
|
200
242
|
...env,
|
|
@@ -394,7 +394,7 @@ app.get('/verify-availability', authMine, async (c) => {
|
|
|
394
394
|
const pendingAmountBN = new BN(pendingAmount);
|
|
395
395
|
|
|
396
396
|
// 5. Check system-level maximum pending amount limit (highest priority, cannot be bypassed)
|
|
397
|
-
// systemMaxPendingAmount() is configured via
|
|
397
|
+
// systemMaxPendingAmount() is configured via PAYMENT_MAX_PENDING_AMOUNT (in token format)
|
|
398
398
|
if (systemMaxPendingAmount() > 0 && pendingAmountBN.gt(new BN(0))) {
|
|
399
399
|
const systemMaxPendingAmountBN = fromTokenToUnit(
|
|
400
400
|
trimDecimals(String(systemMaxPendingAmount()), currency.decimal),
|
|
@@ -8,18 +8,35 @@ process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
|
|
|
8
8
|
|
|
9
9
|
import { AppStoreClient, AppStoreSettings } from '../../../src/integrations/app-store/client';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
// Path C (verifyLegacyReceipt) is now native fetch — mock the verifyReceipt HTTP
|
|
12
|
+
// call instead of the evicted node-apple-receipt-verify package.
|
|
13
|
+
type LegacyCall = { url: string; body: any };
|
|
14
|
+
let legacyCalls: LegacyCall[] = [];
|
|
15
|
+
|
|
16
|
+
/** Mock Apple's verifyReceipt with one JSON response per call (production→sandbox). */
|
|
17
|
+
function mockVerifyReceipt(responses: Array<{ status: number; latest_receipt_info?: any[]; receipt?: any }>): void {
|
|
18
|
+
let i = 0;
|
|
19
|
+
legacyCalls = [];
|
|
20
|
+
global.fetch = jest.fn(async (url: unknown, init?: unknown) => {
|
|
21
|
+
legacyCalls.push({ url: String(url), body: JSON.parse(String((init as RequestInit).body)) });
|
|
22
|
+
const r = responses[Math.min(i, responses.length - 1)]!;
|
|
23
|
+
i += 1;
|
|
24
|
+
return { ok: true, status: 200, json: async () => r } as unknown as Response;
|
|
25
|
+
}) as unknown as typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Apple verifyReceipt success body with snake_case item(s). */
|
|
29
|
+
const legacyItem = (over: Record<string, unknown> = {}) => ({
|
|
30
|
+
product_id: 'sub_06',
|
|
31
|
+
transaction_id: 'txn_1',
|
|
32
|
+
original_transaction_id: 'orig_1',
|
|
33
|
+
purchase_date_ms: '1700000000000',
|
|
34
|
+
expires_date_ms: '1800000000000',
|
|
35
|
+
...over,
|
|
21
36
|
});
|
|
22
37
|
|
|
38
|
+
afterEach(() => jest.restoreAllMocks());
|
|
39
|
+
|
|
23
40
|
const baseSettings: AppStoreSettings = {
|
|
24
41
|
bundle_id: 'com.example.app',
|
|
25
42
|
environment: 'sandbox',
|
|
@@ -232,38 +249,20 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
|
|
|
232
249
|
await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/shared_secret is required/);
|
|
233
250
|
});
|
|
234
251
|
|
|
235
|
-
it('
|
|
236
|
-
|
|
237
|
-
{
|
|
238
|
-
bundleId: 'com.example.app',
|
|
239
|
-
productId: 'sub_06',
|
|
240
|
-
transactionId: 'txn_1',
|
|
241
|
-
originalTransactionId: 'orig_1',
|
|
242
|
-
purchaseDate: 1700000000000,
|
|
243
|
-
expirationDate: 1800000000000,
|
|
244
|
-
},
|
|
245
|
-
]);
|
|
252
|
+
it('POSTs receipt-data + secret + exclude-old-transactions to the production Apple host', async () => {
|
|
253
|
+
mockVerifyReceipt([{ status: 0, receipt: { bundle_id: 'com.example.app' }, latest_receipt_info: [legacyItem()] }]);
|
|
246
254
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
247
255
|
await c.verifyLegacyReceipt('base64-receipt');
|
|
248
|
-
expect(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
);
|
|
256
|
+
expect(legacyCalls[0]!.url).toBe('https://buy.itunes.apple.com/verifyReceipt');
|
|
257
|
+
expect(legacyCalls[0]!.body).toEqual({
|
|
258
|
+
'receipt-data': 'base64-receipt',
|
|
259
|
+
password: 'sk_test_secret',
|
|
260
|
+
'exclude-old-transactions': true,
|
|
261
|
+
});
|
|
254
262
|
});
|
|
255
263
|
|
|
256
264
|
it('normalizes one-item receipt to AppStoreTransactionPayload', async () => {
|
|
257
|
-
|
|
258
|
-
{
|
|
259
|
-
bundleId: 'com.example.app',
|
|
260
|
-
productId: 'sub_06',
|
|
261
|
-
transactionId: 'txn_1',
|
|
262
|
-
originalTransactionId: 'orig_1',
|
|
263
|
-
purchaseDate: 1700000000000,
|
|
264
|
-
expirationDate: 1800000000000,
|
|
265
|
-
},
|
|
266
|
-
]);
|
|
265
|
+
mockVerifyReceipt([{ status: 0, receipt: { bundle_id: 'com.example.app' }, latest_receipt_info: [legacyItem()] }]);
|
|
267
266
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
268
267
|
const result = await c.verifyLegacyReceipt('base64-receipt');
|
|
269
268
|
expect(result).toEqual({
|
|
@@ -280,10 +279,16 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
|
|
|
280
279
|
});
|
|
281
280
|
|
|
282
281
|
it('picks the item with the latest expirationDate when receipt contains multiple', async () => {
|
|
283
|
-
|
|
284
|
-
{
|
|
285
|
-
|
|
286
|
-
|
|
282
|
+
mockVerifyReceipt([
|
|
283
|
+
{
|
|
284
|
+
status: 0,
|
|
285
|
+
receipt: { bundle_id: 'com.example.app' },
|
|
286
|
+
latest_receipt_info: [
|
|
287
|
+
legacyItem({ transaction_id: 'old', expires_date_ms: '1500000000000' }),
|
|
288
|
+
legacyItem({ transaction_id: 'new', expires_date_ms: '1800000000000' }),
|
|
289
|
+
legacyItem({ transaction_id: 'mid', expires_date_ms: '1600000000000' }),
|
|
290
|
+
],
|
|
291
|
+
},
|
|
287
292
|
]);
|
|
288
293
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
289
294
|
const result = await c.verifyLegacyReceipt('base64-receipt');
|
|
@@ -291,9 +296,15 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
|
|
|
291
296
|
});
|
|
292
297
|
|
|
293
298
|
it('filters by expectedProductIds when provided', async () => {
|
|
294
|
-
|
|
295
|
-
{
|
|
296
|
-
|
|
299
|
+
mockVerifyReceipt([
|
|
300
|
+
{
|
|
301
|
+
status: 0,
|
|
302
|
+
receipt: { bundle_id: 'com.example.app' },
|
|
303
|
+
latest_receipt_info: [
|
|
304
|
+
legacyItem({ product_id: 'other_sku', transaction_id: 'wrong', expires_date_ms: '1900000000000' }),
|
|
305
|
+
legacyItem({ product_id: 'sub_06', transaction_id: 'right', expires_date_ms: '1800000000000' }),
|
|
306
|
+
],
|
|
307
|
+
},
|
|
297
308
|
]);
|
|
298
309
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
299
310
|
const result = await c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] });
|
|
@@ -301,8 +312,12 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
|
|
|
301
312
|
});
|
|
302
313
|
|
|
303
314
|
it('throws when no item matches expectedProductIds', async () => {
|
|
304
|
-
|
|
305
|
-
{
|
|
315
|
+
mockVerifyReceipt([
|
|
316
|
+
{
|
|
317
|
+
status: 0,
|
|
318
|
+
receipt: { bundle_id: 'com.example.app' },
|
|
319
|
+
latest_receipt_info: [legacyItem({ product_id: 'unknown_sku', transaction_id: 'x' })],
|
|
320
|
+
},
|
|
306
321
|
]);
|
|
307
322
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
308
323
|
await expect(c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] })).rejects.toThrow(
|
|
@@ -311,25 +326,36 @@ describe('AppStoreClient.verifyLegacyReceipt', () => {
|
|
|
311
326
|
});
|
|
312
327
|
|
|
313
328
|
it('rejects when receipt bundleId disagrees with configured bundle_id', async () => {
|
|
314
|
-
|
|
315
|
-
{
|
|
316
|
-
bundleId: 'com.evil.app',
|
|
317
|
-
productId: 'sub_06',
|
|
318
|
-
transactionId: 'txn_1',
|
|
319
|
-
originalTransactionId: 'orig_1',
|
|
320
|
-
expirationDate: 1800000000000,
|
|
321
|
-
},
|
|
322
|
-
]);
|
|
329
|
+
mockVerifyReceipt([{ status: 0, receipt: { bundle_id: 'com.evil.app' }, latest_receipt_info: [legacyItem()] }]);
|
|
323
330
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
324
331
|
await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/bundleId mismatch/);
|
|
325
332
|
});
|
|
326
333
|
|
|
327
334
|
it('falls back to transactionId when originalTransactionId is absent', async () => {
|
|
328
|
-
|
|
329
|
-
{
|
|
335
|
+
mockVerifyReceipt([
|
|
336
|
+
{
|
|
337
|
+
status: 0,
|
|
338
|
+
receipt: { bundle_id: 'com.example.app' },
|
|
339
|
+
latest_receipt_info: [legacyItem({ transaction_id: 'txn_only', original_transaction_id: undefined })],
|
|
340
|
+
},
|
|
330
341
|
]);
|
|
331
342
|
const c = AppStoreClient.fromSettings(withSecret);
|
|
332
343
|
const result = await c.verifyLegacyReceipt('base64-receipt');
|
|
333
344
|
expect(result.originalTransactionId).toBe('txn_only');
|
|
334
345
|
});
|
|
346
|
+
|
|
347
|
+
it('retries the sandbox host when production returns 21007', async () => {
|
|
348
|
+
mockVerifyReceipt([
|
|
349
|
+
{ status: 21007 },
|
|
350
|
+
{
|
|
351
|
+
status: 0,
|
|
352
|
+
receipt: { bundle_id: 'com.example.app' },
|
|
353
|
+
latest_receipt_info: [legacyItem({ transaction_id: 'sb' })],
|
|
354
|
+
},
|
|
355
|
+
]);
|
|
356
|
+
const c = AppStoreClient.fromSettings(withSecret);
|
|
357
|
+
const result = await c.verifyLegacyReceipt('base64-receipt');
|
|
358
|
+
expect(legacyCalls.map((x) => new URL(x.url).host)).toEqual(['buy.itunes.apple.com', 'sandbox.itunes.apple.com']);
|
|
359
|
+
expect(result.transactionId).toBe('sb');
|
|
360
|
+
});
|
|
335
361
|
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# App Store native-verify test fixtures
|
|
2
|
+
|
|
3
|
+
## `make-chain.ts` — synthetic chain generator (0.1)
|
|
4
|
+
|
|
5
|
+
Generates a self-signed Apple-style 3-cert chain (root → CA intermediate w/ Apple
|
|
6
|
+
OID `1.2.840.113635.100.6.2.1` → leaf w/ Apple OID `1.2.840.113635.100.6.11.1`)
|
|
7
|
+
plus an ES256 `header.payload.p1363sig` JWS signed by the leaf key, and a
|
|
8
|
+
`crossSignedLeaf` (same subject DN, different key) for chain-failure negatives.
|
|
9
|
+
Cert validity windows are pinned via `certValidFrom`/`certValidTo` so skew
|
|
10
|
+
boundary tests can position `signedDate` precisely. See the file header for the
|
|
11
|
+
full contract — chiefly: **`signedDate` is the `effectiveDate`, never `Date.now()`.**
|
|
12
|
+
|
|
13
|
+
**Determinism:** the EC P-256 signing keys are committed under `keys/`
|
|
14
|
+
(`{root,int,leaf,wrongint}.key.pem`, synthetic + test-only) and certs are derived
|
|
15
|
+
from them at runtime with pinned windows — so value assertions are stable and the
|
|
16
|
+
certs never expire. Pass `freshKeys: true` for unique per-call keys. These keys
|
|
17
|
+
are test-only and **must never be imported by `src/`** — `make-chain.spec.ts`
|
|
18
|
+
asserts that structurally (a data-leak regression fails CI).
|
|
19
|
+
|
|
20
|
+
## `real-sandbox.jws` / `real-sandbox.json` — real Apple fixture (0.4)
|
|
21
|
+
|
|
22
|
+
A genuine Apple-signed (ES256) sandbox `signedTransactionInfo`, fetched via the
|
|
23
|
+
App Store Server API `getTransactionInfo()` (`bundle_id: io.arcblock.ai.stro`,
|
|
24
|
+
`transaction_id: 2000001186283296`). The real x5c chain is:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
leaf: Prod ECC Mac App Store and iTunes Store Receipt Signing (expires 2027-10-13)
|
|
28
|
+
int: Apple Worldwide Developer Relations Certification Authority G6
|
|
29
|
+
root: Apple Root CA - G3 (expires 2039-04-30)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This is **public, Apple-signed data — it contains no credentials** (a JWS is the
|
|
33
|
+
signed transaction itself; the signing keys stay at Apple). Committing it is safe
|
|
34
|
+
and it is the golden vector that exercises Apple's **real** OID + 3-cert chain
|
|
35
|
+
end-to-end — the gap synthetic self-signed chains cannot close (design §11).
|
|
36
|
+
|
|
37
|
+
`real-sandbox.json` carries the decoded header/payload + provenance `_meta` for
|
|
38
|
+
reference; tests assert against `real-sandbox.jws`.
|
|
39
|
+
|
|
40
|
+
**Maintenance:** the leaf cert expires 2027-10-13. After that, `verifyAppleJws`
|
|
41
|
+
will reject this fixture on the validity check unless tests pin `signedDate` to
|
|
42
|
+
the original `2026-06-15T11:02:14.379Z` (`payload.signedDate = 1781521334379`),
|
|
43
|
+
which is what the native-jws spec does — so the fixture stays valid indefinitely
|
|
44
|
+
for the algorithm assertions.
|
|
45
|
+
|
|
46
|
+
## 0.3 — OID-encoding contract (production vs probe)
|
|
47
|
+
|
|
48
|
+
Production code (`native-asn1.ts`, Phase 1) matches the **FULL DER OID tag**
|
|
49
|
+
`[0x06, len, ...content]` against `cert.raw`. This is intentionally STRICTER than
|
|
50
|
+
the workerd probe's content-only `oidContentHex` scan (`x509-probe.mjs`), which
|
|
51
|
+
stays looser because a probe only needs a presence signal. Defense-in-depth: the
|
|
52
|
+
OID marker is **not** the trust anchor — the chain-to-pinned-Apple-root is
|
|
53
|
+
(design §4/§5.5). Both certs in `make-chain.ts` carry the OIDs as `DER:0500`
|
|
54
|
+
extensions so the full-tag scan finds them.
|