payment-kit 1.29.5 → 1.29.7

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 (25) hide show
  1. package/api/src/integrations/app-store/client.ts +7 -13
  2. package/api/src/integrations/app-store/native-api.ts +102 -0
  3. package/api/src/integrations/app-store/native-asn1.ts +49 -0
  4. package/api/src/integrations/app-store/native-jws.ts +222 -0
  5. package/api/src/integrations/app-store/native-receipt.ts +105 -0
  6. package/api/src/integrations/app-store/signed-data-verifier.ts +40 -79
  7. package/api/tests/integrations/app-store/client.spec.ts +83 -57
  8. package/api/tests/integrations/app-store/fixtures/README.md +54 -0
  9. package/api/tests/integrations/app-store/fixtures/keys/int.key.pem +5 -0
  10. package/api/tests/integrations/app-store/fixtures/keys/leaf.key.pem +5 -0
  11. package/api/tests/integrations/app-store/fixtures/keys/root.key.pem +5 -0
  12. package/api/tests/integrations/app-store/fixtures/keys/wrongint.key.pem +5 -0
  13. package/api/tests/integrations/app-store/fixtures/make-chain.spec.ts +152 -0
  14. package/api/tests/integrations/app-store/fixtures/make-chain.ts +326 -0
  15. package/api/tests/integrations/app-store/fixtures/real-sandbox.json +43 -0
  16. package/api/tests/integrations/app-store/fixtures/real-sandbox.jws +1 -0
  17. package/api/tests/integrations/app-store/native-api.spec.ts +172 -0
  18. package/api/tests/integrations/app-store/native-integration.spec.ts +78 -0
  19. package/api/tests/integrations/app-store/native-jws.spec.ts +219 -0
  20. package/api/tests/integrations/app-store/native-receipt.spec.ts +161 -0
  21. package/blocklet.yml +1 -1
  22. package/cloudflare/cf-adapter.ts +14 -1
  23. package/cloudflare/tests/x509-probe.mjs +260 -0
  24. package/package.json +7 -10
  25. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +0 -17
@@ -0,0 +1,219 @@
1
+ // Unit tests for the native Apple JWS verifier (Phase 1, Path A).
2
+ //
3
+ // Synthetic chains (make-chain.ts) exercise the algorithm with an injected test
4
+ // root; the committed REAL Apple sandbox JWS (real-sandbox.jws) proves the real
5
+ // OID + 3-cert chain end-to-end (design §11). No env flag needed — verifyAppleJws
6
+ // is pure (the flag only selects native vs legacy in signed-data-verifier).
7
+
8
+ import { readFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { buildJws, makeChain } from './fixtures/make-chain';
12
+ import { AppleJwsVerifyError, verifyAppleJws } from '../../../src/integrations/app-store/native-jws';
13
+ import type { JwsTransactionPayload } from '../../../src/integrations/app-store/native-jws';
14
+
15
+ const FIXED_SIGNED_DATE = 1_700_000_000_000;
16
+
17
+ /** A valid chain + the trustedRoots list that anchors it (the chain's own root). */
18
+ function freshChainCtx(opts: Parameters<typeof makeChain>[0] = {}) {
19
+ const chain = makeChain({ signedDate: FIXED_SIGNED_DATE, ...opts });
20
+ const trustedRoots = [Buffer.from(chain.x5c[2]!, 'base64')];
21
+ return { chain, trustedRoots };
22
+ }
23
+
24
+ async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
25
+ await expect(promise).rejects.toMatchObject({ code });
26
+ }
27
+
28
+ describe('verifyAppleJws — happy path', () => {
29
+ it('decodes a synthetic chain when its root is trusted; fields match the signed payload', async () => {
30
+ const { chain, trustedRoots } = freshChainCtx({
31
+ payload: { productId: 'sub_happy', expiresDate: 1_900_000_000_000, appAccountToken: 'uuid-happy' },
32
+ });
33
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
34
+ expect(decoded.transactionId).toBe('txn_synth_1');
35
+ expect(decoded.productId).toBe('sub_happy');
36
+ expect(decoded.expiresDate).toBe(1_900_000_000_000);
37
+ expect(decoded.appAccountToken).toBe('uuid-happy');
38
+ expect(decoded.signedDate).toBe(FIXED_SIGNED_DATE);
39
+ });
40
+
41
+ it('verifies the REAL Apple sandbox JWS against the vendored Apple roots end-to-end', async () => {
42
+ const jws = readFileSync(path.join(__dirname, 'fixtures', 'real-sandbox.jws'), 'utf8').trim();
43
+ const meta = JSON.parse(readFileSync(path.join(__dirname, 'fixtures', 'real-sandbox.json'), 'utf8'));
44
+ // default trustedRoots = APPLE_ROOT_CERTS (the real chain anchors to Apple Root CA G3)
45
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(jws);
46
+ expect(decoded.transactionId).toBe(meta.decoded_payload.transactionId);
47
+ expect(decoded.bundleId).toBe('io.arcblock.ai.stro');
48
+ expect(decoded.productId).toBe(meta.decoded_payload.productId);
49
+ expect(decoded.signedDate).toBe(meta.decoded_payload.signedDate);
50
+ });
51
+ });
52
+
53
+ describe('verifyAppleJws — bad input', () => {
54
+ it('rejects non-3-segment JWS', async () => {
55
+ await expectCode(verifyAppleJws('a.b'), 'FORMAT');
56
+ });
57
+
58
+ it('rejects x5c.length !== 3 with INVALID_CHAIN_LENGTH', async () => {
59
+ const { chain, trustedRoots } = freshChainCtx();
60
+ const { jws } = buildJws({ alg: 'ES256', x5c: chain.x5c.slice(0, 2) }, chain.payload, chain.keys.leafKeyPem);
61
+ await expectCode(verifyAppleJws(jws, { trustedRoots }), 'INVALID_CHAIN_LENGTH');
62
+ });
63
+
64
+ it('rejects a header that is not base64url JSON', async () => {
65
+ await expectCode(verifyAppleJws('!!!notjson.payload.sig'), 'HEADER');
66
+ });
67
+
68
+ it('rejects a payload that is not valid JSON (after a valid chain)', async () => {
69
+ const { chain, trustedRoots } = freshChainCtx();
70
+ const headerSeg = chain.jws.split('.')[0]!;
71
+ const badPayload = Buffer.from('not json at all').toString('base64url');
72
+ const jws = `${headerSeg}.${badPayload}.${chain.jws.split('.')[2]}`;
73
+ await expectCode(verifyAppleJws(jws, { trustedRoots }), 'PAYLOAD');
74
+ });
75
+ });
76
+
77
+ describe('verifyAppleJws — security', () => {
78
+ it('rejects a tampered payload (signature no longer matches)', async () => {
79
+ const { chain, trustedRoots } = freshChainCtx();
80
+ const [h, , s] = chain.jws.split('.');
81
+ const tampered = Buffer.from(JSON.stringify({ ...chain.payload, productId: 'EVIL' })).toString('base64url');
82
+ await expectCode(verifyAppleJws(`${h}.${tampered}.${s}`, { trustedRoots }), 'SIGNATURE');
83
+ });
84
+
85
+ it('rejects a tampered signature', async () => {
86
+ const { chain, trustedRoots } = freshChainCtx();
87
+ const [h, p] = chain.jws.split('.');
88
+ const sigBytes = Buffer.from(chain.jws.split('.')[2]!, 'base64url');
89
+ sigBytes[0] = sigBytes[0]! ^ 0xff;
90
+ await expectCode(verifyAppleJws(`${h}.${p}.${sigBytes.toString('base64url')}`, { trustedRoots }), 'SIGNATURE');
91
+ });
92
+
93
+ it('rejects alg: "none" and alg: "RS256" (alg whitelist)', async () => {
94
+ const none = freshChainCtx({ alg: 'none' });
95
+ await expectCode(verifyAppleJws(none.chain.jws, { trustedRoots: none.trustedRoots }), 'ALG');
96
+ const rs = freshChainCtx({ alg: 'RS256' });
97
+ await expectCode(verifyAppleJws(rs.chain.jws, { trustedRoots: rs.trustedRoots }), 'ALG');
98
+ });
99
+
100
+ it('rejects when the presented root is NOT a trusted anchor', async () => {
101
+ const { chain } = freshChainCtx();
102
+ // default APPLE_ROOT_CERTS does NOT contain the synthetic root
103
+ await expectCode(verifyAppleJws(chain.jws), 'ANCHOR');
104
+ });
105
+
106
+ it('rejects a cross-signed leaf (chain break) before signature', async () => {
107
+ const { chain, trustedRoots } = freshChainCtx();
108
+ const header = { alg: 'ES256', x5c: [chain.crossSignedLeaf, chain.x5c[1], chain.x5c[2]] };
109
+ const { jws } = buildJws(header, chain.payload, chain.keys.leafKeyPem);
110
+ await expectCode(verifyAppleJws(jws, { trustedRoots }), 'CHAIN');
111
+ });
112
+
113
+ it('rejects when the leaf is missing the Apple OID', async () => {
114
+ const { chain, trustedRoots } = freshChainCtx({ leafOid: false });
115
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'OID');
116
+ });
117
+
118
+ it('rejects when the intermediate is missing the Apple OID', async () => {
119
+ const { chain, trustedRoots } = freshChainCtx({ intOid: false });
120
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'OID');
121
+ });
122
+
123
+ it('does not pollute Object.prototype from a hostile __proto__ payload', async () => {
124
+ const { chain, trustedRoots } = freshChainCtx();
125
+ const headerSeg = chain.jws.split('.')[0]!;
126
+ // own __proto__ key via JSON.parse, then sign it so the chain is valid
127
+ const hostile = JSON.parse(
128
+ '{"__proto__":{"polluted":true},"transactionId":"t","productId":"p","signedDate":1700000000000}'
129
+ );
130
+ const { jws } = buildJws(
131
+ JSON.parse(Buffer.from(headerSeg, 'base64url').toString('utf8')),
132
+ hostile,
133
+ chain.keys.leafKeyPem
134
+ );
135
+ const decoded = await verifyAppleJws<Record<string, unknown>>(jws, { trustedRoots });
136
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
137
+ expect(Object.prototype.hasOwnProperty.call(decoded, '__proto__')).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe('verifyAppleJws — validity & clock skew (±60s, all 3 certs)', () => {
142
+ // boundary: reject if validTo < effectiveDate − 60s; pass at exactly −60s.
143
+ it('passes at the expiry boundary (effectiveDate = validTo + 60s) and rejects 1ms past it', async () => {
144
+ const validTo = new Date(FIXED_SIGNED_DATE);
145
+ const ctxPass = freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 });
146
+ await expect(verifyAppleJws(ctxPass.chain.jws, { trustedRoots: ctxPass.trustedRoots })).resolves.toBeDefined();
147
+ const ctxFail = freshChainCtx({ certValidTo: validTo, signedDate: FIXED_SIGNED_DATE + 60_000 + 1 });
148
+ await expectCode(verifyAppleJws(ctxFail.chain.jws, { trustedRoots: ctxFail.trustedRoots }), 'CERT_EXPIRED');
149
+ });
150
+
151
+ it('passes at the not-yet-valid boundary (effectiveDate = validFrom − 60s) and rejects 1ms before it', async () => {
152
+ const validFrom = new Date(FIXED_SIGNED_DATE);
153
+ const ctxPass = freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 });
154
+ await expect(verifyAppleJws(ctxPass.chain.jws, { trustedRoots: ctxPass.trustedRoots })).resolves.toBeDefined();
155
+ const ctxFail = freshChainCtx({ certValidFrom: validFrom, signedDate: FIXED_SIGNED_DATE - 60_000 - 1 });
156
+ await expectCode(verifyAppleJws(ctxFail.chain.jws, { trustedRoots: ctxFail.trustedRoots }), 'CERT_NOT_YET_VALID');
157
+ });
158
+
159
+ it('rejects when ONLY the intermediate is expired (proves int is checked, not just leaf)', async () => {
160
+ const { chain, trustedRoots } = freshChainCtx({
161
+ certWindows: { intermediate: { to: new Date(FIXED_SIGNED_DATE - 3_600_000) } },
162
+ });
163
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'CERT_EXPIRED');
164
+ });
165
+
166
+ it('rejects when ONLY the root is expired (proves root is checked)', async () => {
167
+ const { chain, trustedRoots } = freshChainCtx({
168
+ certWindows: { root: { to: new Date(FIXED_SIGNED_DATE - 3_600_000) } },
169
+ });
170
+ await expectCode(verifyAppleJws(chain.jws, { trustedRoots }), 'CERT_EXPIRED');
171
+ });
172
+ });
173
+
174
+ describe('verifyAppleJws — data loss (concurrency / statelessness)', () => {
175
+ it('resolves all concurrent verifications correctly (no shared mutable state)', async () => {
176
+ const { chain, trustedRoots } = freshChainCtx();
177
+ const results = await Promise.all(
178
+ Array.from({ length: 12 }, () => verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots }))
179
+ );
180
+ for (const r of results) expect(r.transactionId).toBe('txn_synth_1');
181
+ });
182
+ });
183
+
184
+ describe('verifyAppleJws — data damage (type/encoding fidelity)', () => {
185
+ it('keeps numbers as numbers and round-trips unicode byte-faithfully', async () => {
186
+ const { chain, trustedRoots } = freshChainCtx({
187
+ payload: { productId: '产品_🎉', expiresDate: 1_888_888_888_888, appAccountToken: 'uuid-😀' },
188
+ });
189
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
190
+ expect(decoded.productId).toBe('产品_🎉');
191
+ expect(decoded.appAccountToken).toBe('uuid-😀');
192
+ expect(typeof decoded.expiresDate).toBe('number');
193
+ expect(decoded.expiresDate).toBe(1_888_888_888_888);
194
+ });
195
+
196
+ it('leaves a missing expiresDate as undefined (never coerced to 0/null)', async () => {
197
+ const { chain, trustedRoots } = freshChainCtx(); // default payload has no expiresDate
198
+ const decoded = await verifyAppleJws<JwsTransactionPayload>(chain.jws, { trustedRoots });
199
+ expect('expiresDate' in decoded).toBe(false);
200
+ expect(decoded.expiresDate).toBeUndefined();
201
+ });
202
+ });
203
+
204
+ describe('verifyAppleJws — data leak (errors name the failure, not secrets)', () => {
205
+ it('throws AppleJwsVerifyError with a code and never echoes the raw cert/JWS material', async () => {
206
+ const { chain } = freshChainCtx();
207
+ try {
208
+ await verifyAppleJws(chain.jws); // ANCHOR (synthetic root not trusted)
209
+ throw new Error('should have rejected');
210
+ } catch (err) {
211
+ expect(err).toBeInstanceOf(AppleJwsVerifyError);
212
+ const message = (err as Error).message;
213
+ expect((err as AppleJwsVerifyError).code).toBe('ANCHOR');
214
+ // the long base64 cert/jws material must not leak into the message
215
+ expect(message).not.toContain(chain.x5c[0]!.slice(0, 40));
216
+ expect(message).not.toContain(chain.jws.split('.')[2]!.slice(0, 20));
217
+ }
218
+ });
219
+ });
@@ -0,0 +1,161 @@
1
+ // Unit tests for native StoreKit 1 receipt verification (Phase 3, Path C).
2
+ // fetch is mocked — we never hit Apple. Asserts parity with the evicted
3
+ // node-apple-receipt-verify: hosts, body, sandbox fallback, output shape.
4
+
5
+ import { verifyAppleReceiptNative } from '../../../src/integrations/app-store/native-receipt';
6
+
7
+ type Call = { url: string; body: any };
8
+ let calls: Call[] = [];
9
+
10
+ /** Mock fetch with one response per call (for the production→sandbox retry). */
11
+ function mockFetchSequence(responses: Array<{ ok?: boolean; status: number; json: object }>): void {
12
+ let i = 0;
13
+ calls = [];
14
+ global.fetch = jest.fn(async (url: unknown, init?: unknown) => {
15
+ const reqInit = init as RequestInit;
16
+ calls.push({ url: String(url), body: JSON.parse(String(reqInit.body)) });
17
+ const r = responses[Math.min(i, responses.length - 1)]!;
18
+ i += 1;
19
+ return { ok: r.ok ?? true, status: 200, json: async () => r.json } as unknown as Response;
20
+ }) as unknown as typeof fetch;
21
+ }
22
+
23
+ const item = (over: Record<string, unknown> = {}) => ({
24
+ product_id: 'sub_06',
25
+ transaction_id: 'txn_1',
26
+ original_transaction_id: 'orig_1',
27
+ purchase_date_ms: '1700000000000',
28
+ expires_date_ms: '1800000000000',
29
+ web_order_line_item_id: 'wo_1',
30
+ ...over,
31
+ });
32
+
33
+ const appleOk = (items: object[], bundleId = 'com.example.app') => ({
34
+ status: 0,
35
+ receipt: { bundle_id: bundleId, in_app: items },
36
+ latest_receipt_info: items,
37
+ });
38
+
39
+ afterEach(() => jest.restoreAllMocks());
40
+
41
+ describe('verifyAppleReceiptNative — happy path', () => {
42
+ it('maps Apple latest_receipt_info to the normalized shape (snake→camel, ms→Number)', async () => {
43
+ mockFetchSequence([{ status: 200, json: appleOk([item()]) }]);
44
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
45
+ expect(result).toEqual([
46
+ {
47
+ productId: 'sub_06',
48
+ transactionId: 'txn_1',
49
+ originalTransactionId: 'orig_1',
50
+ purchaseDate: 1700000000000,
51
+ expirationDate: 1800000000000,
52
+ bundleId: 'com.example.app',
53
+ webOrderLineItemId: 'wo_1',
54
+ },
55
+ ]);
56
+ expect(typeof result[0]!.expirationDate).toBe('number');
57
+ });
58
+
59
+ it('falls back to receipt.in_app when latest_receipt_info is absent', async () => {
60
+ mockFetchSequence([
61
+ { status: 200, json: { status: 0, receipt: { bundle_id: 'com.example.app', in_app: [item()] } } },
62
+ ]);
63
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
64
+ expect(result[0]!.transactionId).toBe('txn_1');
65
+ });
66
+ });
67
+
68
+ describe('verifyAppleReceiptNative — bad input', () => {
69
+ it('returns [] when both latest_receipt_info and in_app are empty', async () => {
70
+ mockFetchSequence([{ status: 200, json: { status: 0, receipt: { bundle_id: 'x', in_app: [] } } }]);
71
+ expect(await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' })).toEqual([]);
72
+ });
73
+
74
+ it('handles a non-numeric expires_date_ms as undefined (no NaN leak)', async () => {
75
+ mockFetchSequence([{ status: 200, json: appleOk([item({ expires_date_ms: 'not-a-number' })]) }]);
76
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
77
+ expect(result[0]!.expirationDate).toBeUndefined();
78
+ });
79
+ });
80
+
81
+ describe('verifyAppleReceiptNative — security', () => {
82
+ it('POSTs password + exclude-old-transactions to the production Apple host only', async () => {
83
+ mockFetchSequence([{ status: 200, json: appleOk([item()]) }]);
84
+ await verifyAppleReceiptNative({ receipt: 'RECEIPT_B64', sharedSecret: 'sk_secret' });
85
+ expect(calls).toHaveLength(1);
86
+ expect(calls[0]!.url).toBe('https://buy.itunes.apple.com/verifyReceipt');
87
+ expect(calls[0]!.body).toEqual({
88
+ 'receipt-data': 'RECEIPT_B64',
89
+ password: 'sk_secret',
90
+ 'exclude-old-transactions': true,
91
+ });
92
+ });
93
+
94
+ it('only ever talks to the two Apple verifyReceipt hosts (no host injection)', async () => {
95
+ mockFetchSequence([
96
+ { status: 200, json: { status: 21007 } },
97
+ { status: 200, json: appleOk([item()]) },
98
+ ]);
99
+ await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
100
+ const hosts = calls.map((c) => new URL(c.url).host);
101
+ expect(hosts).toEqual(['buy.itunes.apple.com', 'sandbox.itunes.apple.com']);
102
+ });
103
+ });
104
+
105
+ describe('verifyAppleReceiptNative — data loss (sandbox fallback + concurrency)', () => {
106
+ it.each([21007, 21002])('retries sandbox on status %i and returns the sandbox result', async (prodStatus) => {
107
+ mockFetchSequence([
108
+ { status: 200, json: { status: prodStatus } },
109
+ { status: 200, json: appleOk([item({ transaction_id: 'sandbox_txn' })]) },
110
+ ]);
111
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
112
+ expect(calls).toHaveLength(2);
113
+ expect(calls[1]!.url).toBe('https://sandbox.itunes.apple.com/verifyReceipt');
114
+ expect(result[0]!.transactionId).toBe('sandbox_txn');
115
+ });
116
+
117
+ it('two concurrent calls with different secrets each POST their OWN secret (no shared state)', async () => {
118
+ const seen: string[] = [];
119
+ global.fetch = jest.fn(async (_url: unknown, init?: unknown) => {
120
+ const body = JSON.parse(String((init as RequestInit).body));
121
+ seen.push(body.password);
122
+ return { ok: true, status: 200, json: async () => appleOk([item()]) } as unknown as Response;
123
+ }) as unknown as typeof fetch;
124
+ await Promise.all([
125
+ verifyAppleReceiptNative({ receipt: 'r1', sharedSecret: 'secret_A' }),
126
+ verifyAppleReceiptNative({ receipt: 'r2', sharedSecret: 'secret_B' }),
127
+ ]);
128
+ expect(seen.sort()).toEqual(['secret_A', 'secret_B']);
129
+ });
130
+ });
131
+
132
+ describe('verifyAppleReceiptNative — data damage', () => {
133
+ it('returns all multi-item entries (client.ts picks the latest-expiry one)', async () => {
134
+ mockFetchSequence([
135
+ {
136
+ status: 200,
137
+ json: appleOk([
138
+ item({ transaction_id: 'old', expires_date_ms: '1500000000000' }),
139
+ item({ transaction_id: 'new', expires_date_ms: '1800000000000' }),
140
+ ]),
141
+ },
142
+ ]);
143
+ const result = await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'sk' });
144
+ expect(result.map((r) => r.transactionId)).toEqual(['old', 'new']);
145
+ expect(result.every((r) => typeof r.expirationDate === 'number')).toBe(true);
146
+ });
147
+ });
148
+
149
+ describe('verifyAppleReceiptNative — data leak', () => {
150
+ it('throws on a non-retryable non-zero status with the code but NOT the shared secret', async () => {
151
+ mockFetchSequence([{ status: 200, json: { status: 21004 } }]);
152
+ let caught: Error | undefined;
153
+ try {
154
+ await verifyAppleReceiptNative({ receipt: 'b64', sharedSecret: 'super_secret_value' });
155
+ } catch (e) {
156
+ caught = e as Error;
157
+ }
158
+ expect(caught?.message).toMatch(/21004/);
159
+ expect(caught?.message).not.toContain('super_secret_value');
160
+ });
161
+ });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.29.5
17
+ version: 1.29.7
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -39,6 +39,7 @@ import crons from '../api/src/crons/index';
39
39
  import { createEmbeddedPaymentService } from '../api/src/service';
40
40
  import type { EmbeddedPaymentService } from '../api/src/service';
41
41
  import { context as requestContext } from '../api/src/libs/context';
42
+ import { warmTenantIdentity } from '../api/src/libs/did-connect/tenant-identity';
42
43
  import {
43
44
  createD1DbDriver,
44
45
  createD1LocksDriver,
@@ -231,7 +232,19 @@ export function buildFetch(deps: FetchDeps) {
231
232
  // the core's connect/context middleware then resolves via the identity driver,
232
233
  // which returns null → TENANT_HOST_UNRESOLVED 4xx (multi-mode fail-closed). The
233
234
  // bare health route still answers without a tenant.
234
- const run = () => deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
235
+ //
236
+ // Warm the tenant identity (app:ek + business wallets) INSIDE the withTenant
237
+ // scope before any route handler runs. The CF resource pipeline is the LITE
238
+ // app-shell (no contextMiddleware), so unlike the node host nothing else warms
239
+ // it — and wallet field access (e.g. `wallet.publicKey` in GET /api/vendors) is
240
+ // a SYNCHRONOUS getCachedTenantIdentity() that fails-closed on a cold cache.
241
+ // Best-effort (warmTenantIdentity swallows errors); cached 5 min per tenant so
242
+ // only the first request per window pays the RPC. Queue jobs / bootstrapTenant
243
+ // already warm on their own paths.
244
+ const run = async () => {
245
+ if (instanceDid) await warmTenantIdentity(instanceDid);
246
+ return deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
247
+ };
235
248
  const res = instanceDid ? await requestContext.withTenant(instanceDid, run) : await run();
236
249
 
237
250
  await deps.flush(); // drain workerd deferred queue work before responding