payment-kit 1.29.4 → 1.29.6
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 +4 -1
- package/api/src/libs/env.ts +16 -0
- package/api/src/libs/notification/index.ts +43 -0
- package/api/src/libs/payment.ts +28 -3
- package/api/src/middlewares/hono/context.ts +20 -8
- package/api/src/queues/credit-grant.ts +7 -2
- package/api/src/queues/discount-status.ts +17 -11
- package/api/src/queues/invoice.ts +20 -10
- package/api/src/queues/subscription.ts +29 -20
- package/api/src/queues/token-transfer.ts +7 -1
- package/api/src/queues/vendors/commission.ts +22 -6
- package/api/src/queues/vendors/status-check.ts +27 -12
- package/api/src/routes/connect/collect-batch.ts +1 -1
- package/api/src/routes/connect/collect.ts +1 -1
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/routes/connect/recharge-account.ts +1 -1
- package/api/src/routes/connect/recharge.ts +1 -1
- package/api/src/routes/connect/shared.ts +1 -1
- package/api/src/routes/hono/bootstrap.ts +65 -0
- package/api/src/routes/hono/index.ts +6 -0
- package/api/src/service.ts +15 -2
- package/api/tests/libs/gas-payer-extra.spec.ts +74 -0
- package/api/tests/libs/notification-guard.spec.ts +61 -0
- package/api/tests/middlewares/hono/context.spec.ts +29 -1
- package/api/tests/queues/starter-tenant-dispatch.spec.ts +171 -0
- package/api/tests/routes/hono/bootstrap.spec.ts +87 -0
- package/blocklet.yml +1 -1
- package/cloudflare/cf-adapter.ts +14 -1
- package/cloudflare/esbuild-cf-config.cjs +10 -1
- package/package.json +7 -7
package/api/src/crons/index.ts
CHANGED
|
@@ -169,7 +169,10 @@ function init() {
|
|
|
169
169
|
{
|
|
170
170
|
name: 'deposit.vault',
|
|
171
171
|
time: depositVaultCronTime(),
|
|
172
|
-
|
|
172
|
+
// startDepositVaultQueue uses a SCOPED findAll (not systemFindAll), so it
|
|
173
|
+
// must run inside a tenant — perTenant fans it out per provisioned tenant
|
|
174
|
+
// (multi) or runs it once for the default tenant (single).
|
|
175
|
+
fn: perTenant('deposit.vault', () => startDepositVaultQueue()),
|
|
173
176
|
options: { runOnInit: true },
|
|
174
177
|
},
|
|
175
178
|
{
|
package/api/src/libs/env.ts
CHANGED
|
@@ -180,6 +180,22 @@ export const sqlBenchmark = (): boolean => readConfig('SQL_BENCHMARK') === '1';
|
|
|
180
180
|
export const cfEnv = (): any => (globalThis as any).__CF_ENV__;
|
|
181
181
|
export const isCfWorker = (): boolean => !!cfEnv();
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* THE single, reusable gate for Blocklet-Server-ONLY features.
|
|
185
|
+
*
|
|
186
|
+
* True only under Blocklet Server, where the full @blocklet/sdk host runtime is
|
|
187
|
+
* present — BLOCKLET_APP_ID plus the rest of the env the SDK's
|
|
188
|
+
* `checkBlockletEnvironment` requires (BLOCKLET_DID / BLOCKLET_APP_EK / ABT_NODE_*).
|
|
189
|
+
* BLOCKLET_APP_ID is the canonical marker; arc-node and the CF worker run the
|
|
190
|
+
* embedded core with NONE of it.
|
|
191
|
+
*
|
|
192
|
+
* Any integration that only works on Blocklet Server (the @blocklet/sdk
|
|
193
|
+
* notification / eventbus / relay transports, and any future host-service feature)
|
|
194
|
+
* MUST gate on this and skip itself off Blocklet Server — never branch on
|
|
195
|
+
* isCfWorker()/hasDynamicIdentity() ad hoc, so "what is BS-only" stays in one place.
|
|
196
|
+
*/
|
|
197
|
+
export const isBlockletServer = (): boolean => hasConfig('BLOCKLET_APP_ID');
|
|
198
|
+
|
|
183
199
|
export default {
|
|
184
200
|
...env,
|
|
185
201
|
};
|
|
@@ -3,10 +3,53 @@ import BlockletNotification from '@blocklet/sdk/service/notification';
|
|
|
3
3
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './template/base';
|
|
4
4
|
import { CheckoutSession, CreditGrant, Invoice, Meter, MeterEvent, Subscription } from '../../store/models';
|
|
5
5
|
import { getNotificationSettings, shouldSendSystemNotification } from '../setting';
|
|
6
|
+
import { isBlockletServer } from '../env';
|
|
6
7
|
import logger from '../logger';
|
|
7
8
|
import { events } from '../event';
|
|
8
9
|
import { createFlexibleEvent } from '../audit';
|
|
9
10
|
|
|
11
|
+
let blockletSdkTransportGuarded = false;
|
|
12
|
+
|
|
13
|
+
/** Replace `fns` with no-ops on both the module namespace and its `default` export. */
|
|
14
|
+
function noopSdkTransport(mod: any, fns: string[]): void {
|
|
15
|
+
const noop = async (): Promise<undefined> => undefined;
|
|
16
|
+
for (const target of [mod, mod?.default]) {
|
|
17
|
+
if (!target) continue;
|
|
18
|
+
for (const fn of fns) target[fn] = noop;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Several @blocklet/sdk service transports run `checkBlockletEnvironment`, which
|
|
24
|
+
* throws `BLOCKLET_APP_ID does not exist in environments` when that env is absent:
|
|
25
|
+
* - service/notification → sendToUser / sendToRelay / sendToMail
|
|
26
|
+
* - service/eventbus → publish / subscribe / unsubscribe
|
|
27
|
+
* BLOCKLET_APP_ID only exists under blocklet-server; arc-node and the CF worker have
|
|
28
|
+
* NO such transport, so the post-payment notification job crashed and every event
|
|
29
|
+
* broadcast logged a "Failed to publish event via EventBus" error.
|
|
30
|
+
*
|
|
31
|
+
* Off blocklet-server we replace those transport functions with no-ops — at the SDK
|
|
32
|
+
* module level, not the call sites, so the app-side logic (notification settings
|
|
33
|
+
* check, the `events` emit that drives `broadcast`) still runs and only the actual
|
|
34
|
+
* send/publish is skipped. One patch covers every caller: __esModule is true, so
|
|
35
|
+
* esbuild's __toESM keeps live getters and every `import … from '@blocklet/sdk/...'`
|
|
36
|
+
* (default OR namespace) resolves to this same module object at call time — hence
|
|
37
|
+
* patching both `mod` and `mod.default`.
|
|
38
|
+
*
|
|
39
|
+
* Idempotent; gated on the exact env the SDK checks, so blocklet-server is byte-for-
|
|
40
|
+
* byte unchanged (it returns before requiring/mutating anything).
|
|
41
|
+
*/
|
|
42
|
+
export function ensureBlockletSdkTransportGuard(): void {
|
|
43
|
+
if (blockletSdkTransportGuarded) return;
|
|
44
|
+
blockletSdkTransportGuarded = true;
|
|
45
|
+
if (isBlockletServer()) return; // Blocklet Server: real transports, leave them intact
|
|
46
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
47
|
+
noopSdkTransport(require('@blocklet/sdk/service/notification'), ['sendToUser', 'sendToRelay', 'sendToMail']);
|
|
48
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
49
|
+
noopSdkTransport(require('@blocklet/sdk/service/eventbus'), ['publish', 'subscribe', 'unsubscribe']);
|
|
50
|
+
logger.info('[blocklet-sdk] notification + eventbus transport disabled — no BLOCKLET_APP_ID (off blocklet-server)');
|
|
51
|
+
}
|
|
52
|
+
|
|
10
53
|
export class Notification {
|
|
11
54
|
template: BaseEmailTemplate;
|
|
12
55
|
type?: string;
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/indent */
|
|
2
2
|
import { isEthereumDid } from '@arcblock/did';
|
|
3
3
|
import { getWalletDid } from '@blocklet/sdk/lib/did';
|
|
4
|
+
import type OcapClient from '@ocap/client';
|
|
4
5
|
import type { DelegateState, TokenLimit } from '@ocap/client';
|
|
5
6
|
import { toTxHash } from '@ocap/mcrypto';
|
|
6
7
|
import { BN, fromUnitToToken } from '@ocap/util';
|
|
@@ -21,7 +22,12 @@ import {
|
|
|
21
22
|
Customer,
|
|
22
23
|
Subscription,
|
|
23
24
|
} from '../store/models';
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
getDelegationAddressWithFallback,
|
|
27
|
+
backfillDelegationAddress,
|
|
28
|
+
getMigratedFromList,
|
|
29
|
+
getAppStakeAddressWithFallback,
|
|
30
|
+
} from './wallet-migration';
|
|
25
31
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
26
32
|
import { blocklet, ethWallet, wallet, getVaultAddress } from './auth';
|
|
27
33
|
import logger from './logger';
|
|
@@ -520,8 +526,27 @@ export function isCreditSufficientForPayment(args: {
|
|
|
520
526
|
return { sufficient: true, balance };
|
|
521
527
|
}
|
|
522
528
|
|
|
523
|
-
export async function getGasPayerExtra(
|
|
524
|
-
|
|
529
|
+
export async function getGasPayerExtra(
|
|
530
|
+
txBuffer: Buffer,
|
|
531
|
+
headers?: { [key: string]: string },
|
|
532
|
+
// The ocap client for the tx's chain. When provided AND the app has staked for
|
|
533
|
+
// gas, the app is the deliberate sponsor for this method's payments: use ITS OWN
|
|
534
|
+
// gas-payer header and IGNORE any wallet-provided one. ArcWallet attaches a
|
|
535
|
+
// gas-payer header to every wallet-initiated tx, so without this gate the app's
|
|
536
|
+
// staked sponsor (the self-sign branch below) is never reached — the chain
|
|
537
|
+
// rejects the wallet's header and falls back to charging `from` (the user's
|
|
538
|
+
// possibly-empty account) → GAS_PAYER_NOT_ON_CHAIN. Best-effort: any stake-check
|
|
539
|
+
// error keeps the wallet header (prior behavior), so a flaky chain read never
|
|
540
|
+
// blocks a payment.
|
|
541
|
+
client?: OcapClient
|
|
542
|
+
) {
|
|
543
|
+
const appStaked = client
|
|
544
|
+
? await getAppStakeAddressWithFallback({ client })
|
|
545
|
+
.then((r) => !!r)
|
|
546
|
+
.catch(() => false)
|
|
547
|
+
: false;
|
|
548
|
+
|
|
549
|
+
if (!appStaked && headers && headers['x-gas-payer-sig'] && headers['x-gas-payer-pk']) {
|
|
525
550
|
return { headers };
|
|
526
551
|
}
|
|
527
552
|
|
|
@@ -55,16 +55,28 @@ export function contextMiddleware(): MiddlewareHandler {
|
|
|
55
55
|
requestedBy = user.did;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
// Resolve tenant from the raw Host (single point)
|
|
59
|
-
//
|
|
58
|
+
// Resolve tenant from the raw Host (single point) — UNLESS the host already
|
|
59
|
+
// scoped this request (e.g. the CF worker wrapped /.well-known/payment/* in
|
|
60
|
+
// withTenant before the core ran, and set its identity driver's
|
|
61
|
+
// resolveInstanceDidForHost to null on purpose). Honor that pre-established
|
|
62
|
+
// context instead of re-resolving, mirroring the DID-Connect tenant middleware
|
|
63
|
+
// (service.ts buildConnectRoutesHono) so native and connect routes behave
|
|
64
|
+
// identically. blocklet-server / arc-node never pre-wrap → peek is undefined →
|
|
65
|
+
// the Host resolution path is unchanged. Multi-mode unknown host → 4xx
|
|
66
|
+
// fail-closed, no default-tenant fallback.
|
|
60
67
|
let instanceDid: string;
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
const preResolved = context.peekInstanceDid();
|
|
69
|
+
if (preResolved) {
|
|
70
|
+
instanceDid = preResolved;
|
|
71
|
+
} else {
|
|
72
|
+
try {
|
|
73
|
+
instanceDid = await resolveTenantForHost(c.req.header('host'));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err instanceof TenantError && err.code === TENANT_HOST_UNRESOLVED) {
|
|
76
|
+
return c.json({ error: { code: err.code, message: err.message } }, 400);
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
66
79
|
}
|
|
67
|
-
throw err;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
return context.run({ requestId, requestedBy, instanceDid }, async () => {
|
|
@@ -6,6 +6,7 @@ import pAll from 'p-all';
|
|
|
6
6
|
import dayjs from '../libs/dayjs';
|
|
7
7
|
import createQueue, { assertJobObjectTenant } from '../libs/queue';
|
|
8
8
|
import { systemFindAll, systemFindByPk } from '../store/scoped';
|
|
9
|
+
import { withTenant } from '../libs/context';
|
|
9
10
|
import {
|
|
10
11
|
AutoRechargeConfig,
|
|
11
12
|
CreditGrant,
|
|
@@ -440,7 +441,10 @@ export const startCreditGrantQueue = async () => {
|
|
|
440
441
|
|
|
441
442
|
const invoiceResults = await Promise.allSettled(
|
|
442
443
|
invoicesToSchedule.map(async (invoice) => {
|
|
443
|
-
|
|
444
|
+
// Cross-tenant scan → enqueue in the invoice's own tenant (push stamps the job
|
|
445
|
+
// tenant via getInstanceDid, fail-closed in multi otherwise).
|
|
446
|
+
const dispatch = () => addInvoiceCreditJob(invoice.id);
|
|
447
|
+
await (invoice.instance_did ? withTenant(invoice.instance_did, dispatch) : dispatch());
|
|
444
448
|
return invoice.id;
|
|
445
449
|
})
|
|
446
450
|
);
|
|
@@ -454,7 +458,8 @@ export const startCreditGrantQueue = async () => {
|
|
|
454
458
|
|
|
455
459
|
const results = await Promise.allSettled(
|
|
456
460
|
grantsToSchedule.map(async (grant) => {
|
|
457
|
-
|
|
461
|
+
const dispatch = () => scheduleCreditGrantJobs(grant);
|
|
462
|
+
await (grant.instance_did ? withTenant(grant.instance_did, dispatch) : dispatch());
|
|
458
463
|
return grant.id;
|
|
459
464
|
})
|
|
460
465
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pAll from 'p-all';
|
|
2
|
+
import { withTenant } from '../libs/context';
|
|
2
3
|
import createQueue, { assertJobObjectTenant } from '../libs/queue';
|
|
3
4
|
import { Coupon, PromotionCode } from '../store/models';
|
|
4
5
|
import logger from '../libs/logger';
|
|
@@ -151,21 +152,26 @@ export const startDiscountStatusQueue = async () => {
|
|
|
151
152
|
|
|
152
153
|
const coupons = await systemFindAll(Coupon, { where: { valid: true } });
|
|
153
154
|
const promotionCodes = await systemFindAll(PromotionCode, { where: { active: true } });
|
|
155
|
+
|
|
156
|
+
// Cross-tenant scan: addDiscountStatusJob pushes, which stamps the job tenant via
|
|
157
|
+
// getInstanceDid (fails closed in multi mode without context). Run each enqueue in
|
|
158
|
+
// its record's tenant; per-record catch isolates one bad row from the whole pass.
|
|
159
|
+
const enqueue = (record: Coupon | PromotionCode, type: DiscountType, options: { runAt?: number }) => async () => {
|
|
160
|
+
const dispatch = () => addDiscountStatusJob(record, type, false, options);
|
|
161
|
+
try {
|
|
162
|
+
await (record.instance_did ? withTenant(record.instance_did, dispatch) : dispatch());
|
|
163
|
+
} catch (error) {
|
|
164
|
+
logger.error('startDiscountStatusQueue: enqueue failed', { type, id: record.id, error });
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
154
168
|
await pAll(
|
|
155
|
-
coupons.map(
|
|
156
|
-
(coupon) => () =>
|
|
157
|
-
addDiscountStatusJob(coupon, 'coupon', false, {
|
|
158
|
-
runAt: coupon.redeem_by,
|
|
159
|
-
})
|
|
160
|
-
),
|
|
169
|
+
coupons.map((coupon) => enqueue(coupon, 'coupon', { runAt: coupon.redeem_by })),
|
|
161
170
|
{ concurrency: 5 }
|
|
162
171
|
);
|
|
163
172
|
await pAll(
|
|
164
|
-
promotionCodes.map(
|
|
165
|
-
(promotionCode
|
|
166
|
-
addDiscountStatusJob(promotionCode, 'promotion-code', false, {
|
|
167
|
-
runAt: promotionCode.expires_at,
|
|
168
|
-
})
|
|
173
|
+
promotionCodes.map((promotionCode) =>
|
|
174
|
+
enqueue(promotionCode, 'promotion-code', { runAt: promotionCode.expires_at })
|
|
169
175
|
),
|
|
170
176
|
{ concurrency: 5 }
|
|
171
177
|
);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
2
|
import { systemFindAll, systemFindByPk } from '../store/scoped';
|
|
3
|
+
import { withTenant } from '../libs/context';
|
|
4
|
+
import { perTenant } from '../crons/tenant-fanout';
|
|
3
5
|
|
|
4
6
|
import { getInvoiceShouldPayTotal, handleOverdraftProtectionInvoiceAfterPayment } from '../libs/invoice';
|
|
5
7
|
import { createEvent, reportAuditFailure } from '../libs/audit';
|
|
@@ -231,15 +233,20 @@ export const startInvoiceQueue = async () => {
|
|
|
231
233
|
});
|
|
232
234
|
|
|
233
235
|
const results = await Promise.allSettled(
|
|
234
|
-
invoices.map(
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
236
|
+
invoices.map((x) => {
|
|
237
|
+
const dispatch = async () => {
|
|
238
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
239
|
+
if (supportAutoCharge === false) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const exist = await invoiceQueue.get(x.id);
|
|
243
|
+
if (!exist) {
|
|
244
|
+
await invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
// Cross-tenant scan → run each invoice's enqueue in its own tenant: the push
|
|
248
|
+
// stamps the job tenant via getInstanceDid, fail-closed in multi otherwise.
|
|
249
|
+
return x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch();
|
|
243
250
|
})
|
|
244
251
|
);
|
|
245
252
|
|
|
@@ -248,7 +255,10 @@ export const startInvoiceQueue = async () => {
|
|
|
248
255
|
logger.warn(`Failed to process ${failed} invoices in startInvoiceQueue`);
|
|
249
256
|
}
|
|
250
257
|
|
|
251
|
-
|
|
258
|
+
// batchHandleStripeInvoices uses scoped findAll → run once per provisioned
|
|
259
|
+
// tenant (perTenant) so its queries resolve a tenant; the stripe.invoice.sync
|
|
260
|
+
// cron uses the same wrapper. Single mode runs it once, unchanged.
|
|
261
|
+
await perTenant('startInvoiceQueue.stripe', batchHandleStripeInvoices)();
|
|
252
262
|
} catch (error) {
|
|
253
263
|
logger.error('Error in startInvoiceQueue:', error);
|
|
254
264
|
} finally {
|
|
@@ -4,6 +4,8 @@ import { BN } from '@ocap/util';
|
|
|
4
4
|
import { Op } from 'sequelize';
|
|
5
5
|
import { paymentReloadSubscriptionJobs } from '../libs/env';
|
|
6
6
|
import { systemFindAll, systemFindByPk, systemFindOne } from '../store/scoped';
|
|
7
|
+
import { withTenant } from '../libs/context';
|
|
8
|
+
import { perTenant } from '../crons/tenant-fanout';
|
|
7
9
|
import { createEvent, reportAuditFailure } from '../libs/audit';
|
|
8
10
|
import { ensurePassportRevoked } from '../integrations/blocklet/passport';
|
|
9
11
|
// eslint-disable-next-line import/no-cycle
|
|
@@ -1603,27 +1605,32 @@ export const startSubscriptionQueue = async () => {
|
|
|
1603
1605
|
});
|
|
1604
1606
|
|
|
1605
1607
|
const results = await Promise.allSettled(
|
|
1606
|
-
subscriptions.map(
|
|
1607
|
-
const
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1608
|
+
subscriptions.map((x) => {
|
|
1609
|
+
const dispatch = async () => {
|
|
1610
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
1611
|
+
if (supportAutoCharge === false) {
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (['past_due', 'paused'].includes(x.status)) {
|
|
1615
|
+
const willCancel = x.cancel_at || x.cancel_at_period_end;
|
|
1616
|
+
if (x.status === 'past_due' && willCancel) {
|
|
1617
|
+
const existingJob = await subscriptionQueue.get(`cancel-${x.id}`);
|
|
1618
|
+
if (!existingJob) {
|
|
1619
|
+
await addSubscriptionJob(x, 'cancel', true, x.cancel_at || x.current_period_end);
|
|
1620
|
+
}
|
|
1617
1621
|
}
|
|
1622
|
+
logger.info(`skip add cycle subscription job because status is ${x.status}`, {
|
|
1623
|
+
subscription: x.id,
|
|
1624
|
+
action: 'cycle',
|
|
1625
|
+
});
|
|
1626
|
+
return;
|
|
1618
1627
|
}
|
|
1619
|
-
logger.info(
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
|
|
1626
|
-
await addSubscriptionJob(x, 'cycle', paymentReloadSubscriptionJobs());
|
|
1628
|
+
logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
|
|
1629
|
+
await addSubscriptionJob(x, 'cycle', paymentReloadSubscriptionJobs());
|
|
1630
|
+
};
|
|
1631
|
+
// Cross-tenant scan → run each subscription's enqueue in its own tenant: the
|
|
1632
|
+
// push stamps the job tenant via getInstanceDid, fail-closed in multi otherwise.
|
|
1633
|
+
return x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch();
|
|
1627
1634
|
})
|
|
1628
1635
|
);
|
|
1629
1636
|
|
|
@@ -1632,7 +1639,9 @@ export const startSubscriptionQueue = async () => {
|
|
|
1632
1639
|
logger.warn(`Failed to process ${failed} subscriptions in startSubscriptionQueue`);
|
|
1633
1640
|
}
|
|
1634
1641
|
|
|
1635
|
-
|
|
1642
|
+
// batchHandleStripeSubscriptions uses scoped findAll → run once per provisioned
|
|
1643
|
+
// tenant (perTenant); the stripe.subscription.sync cron uses the same wrapper.
|
|
1644
|
+
await perTenant('startSubscriptionQueue.stripe', batchHandleStripeSubscriptions)();
|
|
1636
1645
|
} catch (error) {
|
|
1637
1646
|
logger.error('Error in startSubscriptionQueue:', error);
|
|
1638
1647
|
} finally {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BN } from '@ocap/util';
|
|
2
|
+
import { withTenant } from '../libs/context';
|
|
2
3
|
import logger from '../libs/logger';
|
|
3
4
|
import createQueue, { assertJobObjectTenant } from '../libs/queue';
|
|
4
5
|
import { getAccountState, transferTokenFromCustomer, getCustomerTokenBalance } from '../integrations/arcblock/token';
|
|
@@ -313,7 +314,7 @@ export async function startTokenTransferQueue(): Promise<void> {
|
|
|
313
314
|
// Process in batches to add jobs
|
|
314
315
|
await Promise.all(
|
|
315
316
|
pendingTransactions.map(async (transaction) => {
|
|
316
|
-
|
|
317
|
+
const dispatch = async () => {
|
|
317
318
|
const customer = (await systemFindByPk(Customer, transaction.customer_id))!;
|
|
318
319
|
const creditGrant = (await systemFindByPk(CreditGrant, transaction.credit_grant_id))!;
|
|
319
320
|
|
|
@@ -326,6 +327,11 @@ export async function startTokenTransferQueue(): Promise<void> {
|
|
|
326
327
|
meterEventId: transaction.source!,
|
|
327
328
|
subscriptionId: transaction.subscription_id || undefined,
|
|
328
329
|
});
|
|
330
|
+
};
|
|
331
|
+
try {
|
|
332
|
+
// Cross-tenant scan: run each transaction's enqueue in its own tenant so the
|
|
333
|
+
// push (injectJobTenant -> getInstanceDid) is stamped, not fail-closed in multi.
|
|
334
|
+
await (transaction.instance_did ? withTenant(transaction.instance_did, dispatch) : dispatch());
|
|
329
335
|
} catch (error: any) {
|
|
330
336
|
logger.error('Failed to add pending transfer job', {
|
|
331
337
|
transactionId: transaction.id,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withTenant } from '../../libs/context';
|
|
1
2
|
import { events } from '../../libs/event';
|
|
2
3
|
import logger from '../../libs/logger';
|
|
3
4
|
import createQueue, { assertJobObjectTenant } from '../../libs/queue';
|
|
@@ -157,13 +158,28 @@ export const startVendorCommissionQueue = async () => {
|
|
|
157
158
|
},
|
|
158
159
|
});
|
|
159
160
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
161
|
+
// Each pending intent belongs to a tenant: the queue push stamps the job tenant
|
|
162
|
+
// via getInstanceDid, which fails closed in multi mode without a tenant context.
|
|
163
|
+
// for-of + await, NOT forEach(async): a fire-and-forget rejection here becomes an
|
|
164
|
+
// unhandledRejection -> FATAL on boot. Per-record try/catch isolates one bad row.
|
|
165
|
+
for (const x of payments) {
|
|
166
|
+
const invoiceId = x.invoice_id;
|
|
167
|
+
if (invoiceId) {
|
|
168
|
+
const dispatch = async () => {
|
|
169
|
+
const id = `vendor-commission-${invoiceId}`;
|
|
170
|
+
const exist = await vendorCommissionQueue.get(id);
|
|
171
|
+
if (!exist) {
|
|
172
|
+
vendorCommissionQueue.push({ id, job: { invoiceId, retryOnError: true } });
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
try {
|
|
176
|
+
// eslint-disable-next-line no-await-in-loop
|
|
177
|
+
await (x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch());
|
|
178
|
+
} catch (error) {
|
|
179
|
+
logger.error('startVendorCommissionQueue: re-queue failed', { invoiceId, error });
|
|
180
|
+
}
|
|
165
181
|
}
|
|
166
|
-
}
|
|
182
|
+
}
|
|
167
183
|
};
|
|
168
184
|
|
|
169
185
|
events.on('invoice.paid', async (invoice) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { joinURL } from 'ufo';
|
|
2
2
|
import { Auth as VendorAuth } from '@blocklet/payment-vendor';
|
|
3
|
+
import { withTenant } from '../../libs/context';
|
|
3
4
|
import createQueue, { assertJobObjectTenant } from '../../libs/queue';
|
|
4
5
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
5
6
|
import { ProductVendor } from '../../store/models';
|
|
@@ -18,20 +19,34 @@ export const startVendorStatusCheckSchedule = async () => {
|
|
|
18
19
|
return;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// Cross-tenant scan: each push stamps the job tenant via getInstanceDid, which
|
|
23
|
+
// fails closed in multi mode without a tenant context. Wrap each session's pushes
|
|
24
|
+
// in withTenant; per-session try/catch isolates one bad row. (`continue`, not
|
|
25
|
+
// `return`: a session with no vendor_info must skip — not abort the whole scan.)
|
|
21
26
|
for (const checkoutSession of checkoutSessions) {
|
|
22
27
|
const vendorInfo = checkoutSession.vendor_info;
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
28
|
+
if (vendorInfo?.length) {
|
|
29
|
+
const dispatch = () => {
|
|
30
|
+
for (const vendor of vendorInfo) {
|
|
31
|
+
if (vendor.status === 'sent') {
|
|
32
|
+
vendorStatusCheckQueue.push({
|
|
33
|
+
id: `vendor-status-check-${checkoutSession.id}-${vendor.vendor_id}`,
|
|
34
|
+
job: {
|
|
35
|
+
checkoutSessionId: checkoutSession.id,
|
|
36
|
+
vendorId: vendor.vendor_id,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
// eslint-disable-next-line no-await-in-loop
|
|
45
|
+
await (checkoutSession.instance_did ? withTenant(checkoutSession.instance_did, dispatch) : dispatch());
|
|
46
|
+
} catch (error) {
|
|
47
|
+
logger.error('startVendorStatusCheckSchedule: enqueue failed', {
|
|
48
|
+
checkoutSessionId: checkoutSession.id,
|
|
49
|
+
error,
|
|
35
50
|
});
|
|
36
51
|
}
|
|
37
52
|
}
|
|
@@ -136,7 +136,7 @@ export default {
|
|
|
136
136
|
const txHash = await client.sendTransferV3Tx(
|
|
137
137
|
// @ts-ignore
|
|
138
138
|
{ tx, wallet: fromAddress(userDid) },
|
|
139
|
-
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
139
|
+
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
|
|
140
140
|
);
|
|
141
141
|
await afterTxExecution({ tx_hash: txHash, payer: userDid, type: 'transfer' });
|
|
142
142
|
|
|
@@ -158,7 +158,7 @@ export default {
|
|
|
158
158
|
const txHash = await client.sendTransferV3Tx(
|
|
159
159
|
// @ts-ignore
|
|
160
160
|
{ tx, wallet: fromAddress(userDid) },
|
|
161
|
-
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
161
|
+
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
|
|
162
162
|
);
|
|
163
163
|
|
|
164
164
|
await afterTxExecution({
|
|
@@ -145,7 +145,7 @@ export default {
|
|
|
145
145
|
const txHash = await client.sendTransferV3Tx(
|
|
146
146
|
// @ts-ignore
|
|
147
147
|
{ tx, wallet: fromAddress(userDid) },
|
|
148
|
-
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
148
|
+
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
|
|
149
149
|
);
|
|
150
150
|
|
|
151
151
|
const quoteValidation = await validateQuoteForPayment({
|
|
@@ -201,7 +201,7 @@ export default {
|
|
|
201
201
|
const txHash = await client.sendTransferV3Tx(
|
|
202
202
|
// @ts-ignore
|
|
203
203
|
{ tx, wallet: fromAddress(userDid) },
|
|
204
|
-
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
204
|
+
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
|
|
205
205
|
);
|
|
206
206
|
|
|
207
207
|
await afterTxExecution({
|
|
@@ -128,7 +128,7 @@ export default {
|
|
|
128
128
|
const txHash = await client.sendTransferV3Tx(
|
|
129
129
|
// @ts-ignore
|
|
130
130
|
{ tx, wallet: fromAddress(userDid) },
|
|
131
|
-
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request))
|
|
131
|
+
await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
|
|
132
132
|
);
|
|
133
133
|
|
|
134
134
|
logger.info('Recharge successful', {
|
|
@@ -1573,7 +1573,7 @@ async function executeSingleTransaction(
|
|
|
1573
1573
|
const { buffer } = await client[`encode${type}Tx`]({ tx });
|
|
1574
1574
|
return client[`send${type}Tx`](
|
|
1575
1575
|
{ tx, wallet: fromPublicKey(userPk, toTypeInfo(userDid)) },
|
|
1576
|
-
await getGasPayerExtra(buffer, gasPayerHeaders)
|
|
1576
|
+
await getGasPayerExtra(buffer, gasPayerHeaders, client)
|
|
1577
1577
|
);
|
|
1578
1578
|
}
|
|
1579
1579
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// On-demand, idempotent per-tenant bootstrap — mounted at /api/bootstrap.
|
|
2
|
+
//
|
|
3
|
+
// blocklet-server runs `bootstrapTenant` automatically at startup (service.ts
|
|
4
|
+
// start → single-mode default tenant, or multi-mode via the listInstanceDids
|
|
5
|
+
// hook). The arc-node daemon and the CF worker do NOT: they only wire the
|
|
6
|
+
// lightweight `provisionTenant` (DB seed) into the request path and never call
|
|
7
|
+
// `bootstrapTenant`, so the heavy per-tenant integrations — chiefly
|
|
8
|
+
// `ensureStakedForGas` (declare + stake the app wallet for gas) — never run, and
|
|
9
|
+
// the app can't sponsor on-chain gas.
|
|
10
|
+
//
|
|
11
|
+
// This route exposes the SAME idempotent bootstrap so ANY runtime can trigger it
|
|
12
|
+
// on demand (a one-off curl, an ops cron, an admin button) WITHOUT putting it on
|
|
13
|
+
// the startup path. Every step is idempotent (ensureStakedForGas skips when
|
|
14
|
+
// already staked or under-funded), so repeated calls are safe.
|
|
15
|
+
import { Hono } from 'hono';
|
|
16
|
+
|
|
17
|
+
import { authenticate } from '../../middlewares/hono/security';
|
|
18
|
+
import { getInstanceDid } from '../../libs/context';
|
|
19
|
+
import { wallet } from '../../libs/auth';
|
|
20
|
+
import { hasStakedForGas } from '../../integrations/arcblock/stake';
|
|
21
|
+
import { PaymentMethod } from '../../store/models';
|
|
22
|
+
import logger from '../../libs/logger';
|
|
23
|
+
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
|
|
26
|
+
// Triggers an on-chain stake (spends gas tokens) → restrict to owner/admin or a
|
|
27
|
+
// signed component call. Relax only if a runtime needs to drive it unauthenticated.
|
|
28
|
+
const authAdmin = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'] });
|
|
29
|
+
|
|
30
|
+
app.get('/', authAdmin, async (c) => {
|
|
31
|
+
const instanceDid = getInstanceDid();
|
|
32
|
+
try {
|
|
33
|
+
// The SAME idempotent per-tenant bootstrap the startup path runs on
|
|
34
|
+
// blocklet-server. Lazy require: a top-level import would close a load-time
|
|
35
|
+
// cycle (service.ts → routes/hono → here). bootstrapTenant re-enters
|
|
36
|
+
// withTenant + warmTenantIdentity, so calling it from the request context is safe.
|
|
37
|
+
// eslint-disable-next-line global-require
|
|
38
|
+
const { bootstrapTenant } = require('../../service');
|
|
39
|
+
await bootstrapTenant(instanceDid);
|
|
40
|
+
|
|
41
|
+
// Report the resulting on-chain status per arcblock method (idempotent read)
|
|
42
|
+
// so the caller can verify the stake actually landed.
|
|
43
|
+
const methods = await PaymentMethod.findAll({ where: { type: 'arcblock', active: true } });
|
|
44
|
+
const chains = await Promise.all(
|
|
45
|
+
methods.map(async (method) => {
|
|
46
|
+
const host = method.settings?.arcblock?.api_host;
|
|
47
|
+
try {
|
|
48
|
+
const client = method.getOcapClient();
|
|
49
|
+
const { state } = await client.getAccountState({ address: wallet.address });
|
|
50
|
+
const stakedForGas = await hasStakedForGas(method);
|
|
51
|
+
return { method: method.name, host, declared: !!state, balance: state?.balance ?? null, stakedForGas };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { method: method.name, host, error: (err as Error).message };
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return c.json({ ok: true, instanceDid, appWallet: wallet.address, chains });
|
|
59
|
+
} catch (err) {
|
|
60
|
+
logger.error('bootstrap endpoint failed', { instanceDid, error: err });
|
|
61
|
+
return c.json({ ok: false, instanceDid, error: (err as Error).message }, 500);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export default app;
|
|
@@ -48,6 +48,7 @@ import vendor from './vendor';
|
|
|
48
48
|
import webhookAttempts from './webhook-attempts';
|
|
49
49
|
import webhookEndpoints from './webhook-endpoints';
|
|
50
50
|
import archive from './archive';
|
|
51
|
+
import bootstrap from './bootstrap';
|
|
51
52
|
|
|
52
53
|
export function mountMigratedResources(native: Hono, opts: { appShell?: MiddlewareHandler[] } = {}): void {
|
|
53
54
|
// The node host passes its full app-shell pipeline (cors/xss/csrf/cdn/i18n/
|
|
@@ -110,6 +111,11 @@ export function mountMigratedResources(native: Hono, opts: { appShell?: Middlewa
|
|
|
110
111
|
g('/api/webhook-attempts', webhookAttempts);
|
|
111
112
|
g('/api/webhook-endpoints', webhookEndpoints);
|
|
112
113
|
g('/api/archive', archive);
|
|
114
|
+
|
|
115
|
+
// On-demand idempotent per-tenant bootstrap (declare/stake the app wallet for
|
|
116
|
+
// gas). arc-node/CF never run the startup bootstrapTenant, so this is the
|
|
117
|
+
// runtime-agnostic trigger — see routes/hono/bootstrap.ts.
|
|
118
|
+
g('/api/bootstrap', bootstrap);
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
export default mountMigratedResources;
|
package/api/src/service.ts
CHANGED
|
@@ -402,7 +402,7 @@ let listInstanceDidsHook: (() => Promise<string[]> | string[]) | undefined;
|
|
|
402
402
|
* tenancy.listInstanceDids). Single mode bootstraps the default tenant from
|
|
403
403
|
* start() automatically (original behavior preserved).
|
|
404
404
|
*/
|
|
405
|
-
async function bootstrapTenant(instanceDid: string): Promise<void> {
|
|
405
|
+
export async function bootstrapTenant(instanceDid: string): Promise<void> {
|
|
406
406
|
if (!instanceDid || typeof instanceDid !== 'string') {
|
|
407
407
|
throw new TenantError(TENANT_CONTEXT_MISSING, 'bootstrapTenant requires an instanceDid');
|
|
408
408
|
}
|
|
@@ -518,6 +518,12 @@ async function startBackgroundServices(): Promise<void> {
|
|
|
518
518
|
}
|
|
519
519
|
servicesStarted = true;
|
|
520
520
|
|
|
521
|
+
// Disable @blocklet/sdk host transports (notification + eventbus) off Blocklet
|
|
522
|
+
// Server — arc-node / CF have no BLOCKLET_APP_ID, so sendToUser/publish would
|
|
523
|
+
// crash/spam. Gated on isBlockletServer(); runs in EVERY runtime via
|
|
524
|
+
// lifecycle.start(); no-op on Blocklet Server.
|
|
525
|
+
require('./libs/notification').ensureBlockletSdkTransportGuard();
|
|
526
|
+
|
|
521
527
|
const crons = require('./crons/index').default;
|
|
522
528
|
const { initResourceHandler } = require('./integrations/blocklet/resource');
|
|
523
529
|
const { initUserHandler } = require('./integrations/blocklet/user');
|
|
@@ -547,7 +553,14 @@ async function startBackgroundServices(): Promise<void> {
|
|
|
547
553
|
['discount status', require('./queues/discount-status').startDiscountStatusQueue],
|
|
548
554
|
];
|
|
549
555
|
for (const [name, start] of starters) {
|
|
550
|
-
|
|
556
|
+
// .catch() is load-bearing: a starter that does a cross-tenant systemFindAll
|
|
557
|
+
// then enqueues without a tenant context rejects in multi mode (injectJobTenant
|
|
558
|
+
// -> getInstanceDid -> TENANT_CONTEXT_MISSING). Fire-and-forget without .catch()
|
|
559
|
+
// turns that into an unhandledRejection -> FATAL on boot. Degrade + log instead;
|
|
560
|
+
// per-starter tenant correctness is handled inside each starter (L2).
|
|
561
|
+
Promise.resolve(start())
|
|
562
|
+
.then(() => logger.info(`${name} queue started`))
|
|
563
|
+
.catch((error: unknown) => logger.error(`${name} queue failed to start`, { error }));
|
|
551
564
|
}
|
|
552
565
|
|
|
553
566
|
// cron + global handlers (tenant-agnostic; runOnInit is skipped in multi mode)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// getGasPayerExtra gas-payer selection (libs/payment.ts).
|
|
2
|
+
//
|
|
3
|
+
// Policy (decided 2026-06): when the app has staked for gas it is the deliberate
|
|
4
|
+
// sponsor, so its OWN gas-payer header must win over any wallet-provided one —
|
|
5
|
+
// otherwise ArcWallet's header (attached to every wallet tx) always wins, the
|
|
6
|
+
// app's stake is never used, and the chain falls back to charging `from`
|
|
7
|
+
// (GAS_PAYER_NOT_ON_CHAIN). These tests pin that gate. The heavy payment.ts deps
|
|
8
|
+
// are mocked so the suite stays DB-free.
|
|
9
|
+
const getAppStakeAddressWithFallback = jest.fn();
|
|
10
|
+
jest.mock('../../src/libs/wallet-migration', () => ({
|
|
11
|
+
getAppStakeAddressWithFallback,
|
|
12
|
+
getDelegationAddressWithFallback: jest.fn(),
|
|
13
|
+
backfillDelegationAddress: jest.fn(),
|
|
14
|
+
getMigratedFromList: jest.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const wallet = { signJWT: jest.fn(async () => 'APP_SIG'), publicKey: 'APP_PK', address: 'zApp' };
|
|
18
|
+
jest.mock('../../src/libs/auth', () => ({
|
|
19
|
+
wallet,
|
|
20
|
+
ethWallet: {},
|
|
21
|
+
blocklet: {},
|
|
22
|
+
getVaultAddress: jest.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
jest.mock('@ocap/mcrypto', () => ({
|
|
26
|
+
...jest.requireActual('@ocap/mcrypto'),
|
|
27
|
+
toTxHash: () => 'TXHASH',
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { getGasPayerExtra } from '../../src/libs/payment';
|
|
31
|
+
|
|
32
|
+
const buffer = Buffer.from('tx-bytes');
|
|
33
|
+
const walletHeaders = { 'x-gas-payer-sig': 'WALLET_SIG', 'x-gas-payer-pk': 'WALLET_PK' };
|
|
34
|
+
const fakeClient = {} as any;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
getAppStakeAddressWithFallback.mockReset();
|
|
38
|
+
wallet.signJWT.mockClear();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('getGasPayerExtra — app-staked gas-payer preference', () => {
|
|
42
|
+
it('no client: wallet header wins (prior behavior)', async () => {
|
|
43
|
+
const out = await getGasPayerExtra(buffer, walletHeaders);
|
|
44
|
+
expect(out).toEqual({ headers: walletHeaders });
|
|
45
|
+
expect(getAppStakeAddressWithFallback).not.toHaveBeenCalled();
|
|
46
|
+
expect(wallet.signJWT).not.toHaveBeenCalled();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('client + app NOT staked: wallet header still wins', async () => {
|
|
50
|
+
getAppStakeAddressWithFallback.mockResolvedValue(null);
|
|
51
|
+
const out = await getGasPayerExtra(buffer, walletHeaders, fakeClient);
|
|
52
|
+
expect(out).toEqual({ headers: walletHeaders });
|
|
53
|
+
expect(wallet.signJWT).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('client + app STAKED: app self-sponsors, ignoring the wallet header', async () => {
|
|
57
|
+
getAppStakeAddressWithFallback.mockResolvedValue({ address: 'zStake', source: 'current' });
|
|
58
|
+
const out = await getGasPayerExtra(buffer, walletHeaders, fakeClient);
|
|
59
|
+
expect(out).toEqual({ headers: { 'x-gas-payer-sig': 'APP_SIG', 'x-gas-payer-pk': 'APP_PK' } });
|
|
60
|
+
expect(wallet.signJWT).toHaveBeenCalledWith({ txHash: 'TXHASH' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('no wallet header: app self-signs regardless', async () => {
|
|
64
|
+
const out = await getGasPayerExtra(buffer);
|
|
65
|
+
expect(out).toEqual({ headers: { 'x-gas-payer-sig': 'APP_SIG', 'x-gas-payer-pk': 'APP_PK' } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('stake check error: keeps the wallet header (best-effort, never blocks a payment)', async () => {
|
|
69
|
+
getAppStakeAddressWithFallback.mockRejectedValue(new Error('chain timeout'));
|
|
70
|
+
const out = await getGasPayerExtra(buffer, walletHeaders, fakeClient);
|
|
71
|
+
expect(out).toEqual({ headers: walletHeaders });
|
|
72
|
+
expect(wallet.signJWT).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ensureBlockletSdkTransportGuard (libs/notification/index.ts).
|
|
2
|
+
//
|
|
3
|
+
// @blocklet/sdk's notification (sendToUser/sendToRelay/sendToMail) and eventbus
|
|
4
|
+
// (publish/subscribe/unsubscribe) throw "BLOCKLET_APP_ID does not exist in
|
|
5
|
+
// environments" off Blocklet Server (arc-node / CF). The guard no-ops both SDK
|
|
6
|
+
// transports there — at the module level, so app-side logic still runs and only
|
|
7
|
+
// the send/publish is skipped — and leaves Blocklet Server untouched. Gated on the
|
|
8
|
+
// single reusable predicate isBlockletServer() (= BLOCKLET_APP_ID present).
|
|
9
|
+
describe('ensureBlockletSdkTransportGuard', () => {
|
|
10
|
+
const ORIG = process.env.BLOCKLET_APP_ID;
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (ORIG === undefined) delete process.env.BLOCKLET_APP_ID;
|
|
14
|
+
else process.env.BLOCKLET_APP_ID = ORIG;
|
|
15
|
+
jest.resetModules();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// Fresh module registry per case so the once-only guard flag resets; fresh mock
|
|
19
|
+
// SDK modules are patched in isolation (no global @blocklet/sdk pollution).
|
|
20
|
+
function loadAndGuard(): { notification: any; eventbus: any } {
|
|
21
|
+
let notification: any;
|
|
22
|
+
let eventbus: any;
|
|
23
|
+
jest.isolateModules(() => {
|
|
24
|
+
const notif = { sendToUser: 'REAL', sendToRelay: 'REAL', sendToMail: 'REAL' };
|
|
25
|
+
notification = { ...notif, default: { ...notif }, getSender: () => ({}) };
|
|
26
|
+
const evt = { publish: 'REAL', subscribe: 'REAL', unsubscribe: 'REAL' };
|
|
27
|
+
eventbus = { ...evt, default: { ...evt } };
|
|
28
|
+
jest.doMock('@blocklet/sdk/service/notification', () => notification);
|
|
29
|
+
jest.doMock('@blocklet/sdk/service/eventbus', () => eventbus);
|
|
30
|
+
// eslint-disable-next-line global-require
|
|
31
|
+
require('../../src/libs/notification').ensureBlockletSdkTransportGuard();
|
|
32
|
+
});
|
|
33
|
+
return { notification, eventbus };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('off Blocklet Server (no BLOCKLET_APP_ID): no-ops notification + eventbus on module + default', async () => {
|
|
37
|
+
delete process.env.BLOCKLET_APP_ID;
|
|
38
|
+
const { notification, eventbus } = loadAndGuard();
|
|
39
|
+
for (const target of [notification, notification.default]) {
|
|
40
|
+
expect(target.sendToUser).not.toBe('REAL');
|
|
41
|
+
expect(target.sendToRelay).not.toBe('REAL');
|
|
42
|
+
expect(target.sendToMail).not.toBe('REAL');
|
|
43
|
+
await expect(target.sendToUser('zUser', { title: 't' })).resolves.toBeUndefined();
|
|
44
|
+
}
|
|
45
|
+
for (const target of [eventbus, eventbus.default]) {
|
|
46
|
+
expect(target.publish).not.toBe('REAL');
|
|
47
|
+
expect(target.subscribe).not.toBe('REAL');
|
|
48
|
+
expect(target.unsubscribe).not.toBe('REAL');
|
|
49
|
+
await expect(target.publish('invoice.paid', {})).resolves.toBeUndefined();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('on Blocklet Server (BLOCKLET_APP_ID set): leaves both transports intact', () => {
|
|
54
|
+
process.env.BLOCKLET_APP_ID = 'z_app_pid';
|
|
55
|
+
const { notification, eventbus } = loadAndGuard();
|
|
56
|
+
expect(notification.sendToUser).toBe('REAL');
|
|
57
|
+
expect(notification.default.sendToUser).toBe('REAL');
|
|
58
|
+
expect(eventbus.publish).toBe('REAL');
|
|
59
|
+
expect(eventbus.default.publish).toBe('REAL');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// header (the fork reads c.req.header('host') — never a proxy header).
|
|
5
5
|
import { Hono } from 'hono';
|
|
6
6
|
import { ensureI18n, contextMiddleware } from '../../../src/middlewares/hono/context';
|
|
7
|
-
import { getInstanceDid } from '../../../src/libs/context';
|
|
7
|
+
import { context, getInstanceDid } from '../../../src/libs/context';
|
|
8
8
|
import { getDefaultInstanceDid } from '../../../src/libs/tenant';
|
|
9
9
|
import {
|
|
10
10
|
setIdentityDriver,
|
|
@@ -111,3 +111,31 @@ describe('hono contextMiddleware — multi mode (Host→tenant, fail-closed)', (
|
|
|
111
111
|
expect(handlerRuns).toBe(0);
|
|
112
112
|
});
|
|
113
113
|
});
|
|
114
|
+
|
|
115
|
+
describe('hono contextMiddleware — pre-scoped request (CF worker withTenant)', () => {
|
|
116
|
+
// The CF worker pre-resolves Host→tenant and wraps the core in withTenant, then
|
|
117
|
+
// sets its identity driver's resolveInstanceDidForHost to null on purpose. A host
|
|
118
|
+
// that the driver CANNOT resolve must still succeed when a tenant context is
|
|
119
|
+
// already established — otherwise native routes (/api/settings) 400 with
|
|
120
|
+
// TENANT_HOST_UNRESOLVED while connect routes (which already honor the context)
|
|
121
|
+
// work. This pins the native+connect parity.
|
|
122
|
+
const nullDriver: IdentityDriver = { resolveInstanceDidForHost: () => null };
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
126
|
+
setIdentityDriver(nullDriver);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('honors the pre-established tenant context instead of re-resolving the host', async () => {
|
|
130
|
+
const app = buildApp();
|
|
131
|
+
const res = await context.withTenant(TENANT_A, () => get(app, 'todo.localhost:8787'));
|
|
132
|
+
expect(res.status).toBe(200);
|
|
133
|
+
expect((await res.json()).tenant).toBe(TENANT_A);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('still fails closed 400 when NO context is pre-established and the host is unresolvable', async () => {
|
|
137
|
+
const r = await get(buildApp(), 'todo.localhost:8787');
|
|
138
|
+
expect(r.status).toBe(400);
|
|
139
|
+
expect((await r.json()).error.code).toBe('TENANT_HOST_UNRESOLVED');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { Sequelize } from 'sequelize';
|
|
5
|
+
import { SequelizeStorage, Umzug } from 'umzug';
|
|
6
|
+
|
|
7
|
+
import { withTenant } from '../../src/libs/context';
|
|
8
|
+
import { TENANT_A, TENANT_B } from '../fixtures/tenants';
|
|
9
|
+
|
|
10
|
+
// Layer-2 (build-phases) regression coverage for the boot/cron starter dispatch
|
|
11
|
+
// fix (commit "fix(payment-core): tenant-context safety for boot/cron queue
|
|
12
|
+
// dispatch"). Mirrors event-tenant.spec.ts's startEventQueue case for the FATAL
|
|
13
|
+
// site: startVendorCommissionQueue used forEach(async) + a push outside
|
|
14
|
+
// withTenant, so in multi mode injectJobTenant -> getInstanceDid threw
|
|
15
|
+
// TENANT_CONTEXT_MISSING as an unhandled rejection -> daemon crash on boot.
|
|
16
|
+
|
|
17
|
+
jest.mock('../../src/libs/logger', () => ({
|
|
18
|
+
__esModule: true,
|
|
19
|
+
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// neutralize the queue engine (real createQueue scans the jobs table on import,
|
|
23
|
+
// racing the temp DB lifecycle) — same shim event-tenant.spec.ts uses.
|
|
24
|
+
jest.mock('../../src/libs/queue', () => ({
|
|
25
|
+
__esModule: true,
|
|
26
|
+
default: () => ({
|
|
27
|
+
push: jest.fn(),
|
|
28
|
+
pushAndWait: jest.fn(),
|
|
29
|
+
cancel: jest.fn(),
|
|
30
|
+
on: jest.fn(),
|
|
31
|
+
get: jest.fn().mockResolvedValue(null),
|
|
32
|
+
}),
|
|
33
|
+
assertJobObjectTenant: jest.fn(),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const STORE_DIR = path.join(__dirname, '../../src/store');
|
|
37
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'starter-dispatch-'));
|
|
38
|
+
const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
|
|
39
|
+
const umzug = new Umzug({
|
|
40
|
+
migrations: {
|
|
41
|
+
glob: ['migrations/*.ts', { cwd: STORE_DIR }],
|
|
42
|
+
resolve: ({ name, path: p, context }) => {
|
|
43
|
+
// eslint-disable-next-line import/no-dynamic-require, global-require
|
|
44
|
+
const migration = require(p!);
|
|
45
|
+
return {
|
|
46
|
+
name: name.replace(/\.ts$/, '.js'),
|
|
47
|
+
up: () => migration.up({ context }),
|
|
48
|
+
down: () => migration.down({ context }),
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
context: sequelize.getQueryInterface(),
|
|
53
|
+
storage: new SequelizeStorage({ sequelize }),
|
|
54
|
+
logger: undefined,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let models: any;
|
|
58
|
+
let startVendorCommissionQueue: any;
|
|
59
|
+
let vendorCommissionQueue: any;
|
|
60
|
+
let getInstanceDid: any;
|
|
61
|
+
let logger: any;
|
|
62
|
+
|
|
63
|
+
beforeAll(async () => {
|
|
64
|
+
await umzug.up();
|
|
65
|
+
// eslint-disable-next-line global-require
|
|
66
|
+
models = require('../../src/store/models');
|
|
67
|
+
models.initialize(sequelize);
|
|
68
|
+
// eslint-disable-next-line global-require
|
|
69
|
+
({ startVendorCommissionQueue, vendorCommissionQueue } = require('../../src/queues/vendors/commission'));
|
|
70
|
+
// eslint-disable-next-line global-require
|
|
71
|
+
({ getInstanceDid } = require('../../src/libs/context'));
|
|
72
|
+
// eslint-disable-next-line global-require
|
|
73
|
+
logger = require('../../src/libs/logger').default;
|
|
74
|
+
}, 120000);
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await sequelize.close();
|
|
78
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const seedPI = (tenant: string, invoiceId: string) =>
|
|
82
|
+
withTenant(tenant, () =>
|
|
83
|
+
models.PaymentIntent.create({
|
|
84
|
+
instance_did: tenant,
|
|
85
|
+
livemode: false,
|
|
86
|
+
amount: '100',
|
|
87
|
+
currency_id: 'cur_test',
|
|
88
|
+
payment_method_id: 'pm_test',
|
|
89
|
+
status: 'requires_capture',
|
|
90
|
+
capture_method: 'automatic',
|
|
91
|
+
confirmation_method: 'automatic',
|
|
92
|
+
invoice_id: invoiceId,
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
jest.clearAllMocks();
|
|
99
|
+
await sequelize.query('DELETE FROM payment_intents');
|
|
100
|
+
});
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
|
|
103
|
+
else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('startVendorCommissionQueue — per-tenant boot dispatch (multi-tenant)', () => {
|
|
107
|
+
it('multi mode: each tenant intent is enqueued INSIDE its own tenant (no crash, correct stamp)', async () => {
|
|
108
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
109
|
+
await seedPI(TENANT_A, 'inv_a');
|
|
110
|
+
await seedPI(TENANT_B, 'inv_b');
|
|
111
|
+
|
|
112
|
+
// the push captures the tenant injectJobTenant would read (getInstanceDid).
|
|
113
|
+
// would THROW in multi mode if the push ran outside withTenant (the old bug).
|
|
114
|
+
const stamps: Record<string, string> = {};
|
|
115
|
+
let threw = false;
|
|
116
|
+
(vendorCommissionQueue.push as jest.Mock).mockImplementation((arg: any) => {
|
|
117
|
+
try {
|
|
118
|
+
stamps[arg.id] = getInstanceDid();
|
|
119
|
+
} catch {
|
|
120
|
+
threw = true;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await expect(startVendorCommissionQueue()).resolves.toBeUndefined();
|
|
125
|
+
|
|
126
|
+
expect(threw).toBe(false);
|
|
127
|
+
expect(vendorCommissionQueue.push).toHaveBeenCalledTimes(2);
|
|
128
|
+
expect(stamps['vendor-commission-inv_a']).toBe(TENANT_A);
|
|
129
|
+
expect(stamps['vendor-commission-inv_b']).toBe(TENANT_B);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('multi mode: a tenant-less intent is isolated by the per-row catch — pass does not crash', async () => {
|
|
133
|
+
process.env.PAYMENT_TENANT_MODE = 'multi';
|
|
134
|
+
const pi = await seedPI(TENANT_A, 'inv_a');
|
|
135
|
+
// null the tenant directly (bypass the scoped writer)
|
|
136
|
+
await sequelize.query('UPDATE payment_intents SET instance_did = NULL WHERE id = $id', {
|
|
137
|
+
bind: { id: pi.id },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// a tenant-less row runs dispatch() with NO withTenant; the real push's
|
|
141
|
+
// injectJobTenant -> getInstanceDid throws in multi mode. Simulate that here.
|
|
142
|
+
(vendorCommissionQueue.push as jest.Mock).mockImplementation(() => {
|
|
143
|
+
getInstanceDid();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// the per-row try/catch must absorb it — the starter resolves, never rejects.
|
|
147
|
+
await expect(startVendorCommissionQueue()).resolves.toBeUndefined();
|
|
148
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
149
|
+
'startVendorCommissionQueue: re-queue failed',
|
|
150
|
+
expect.objectContaining({ invoiceId: 'inv_a' })
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('single mode: enqueues without a tenant context (default tenant), no throw', async () => {
|
|
155
|
+
delete process.env.PAYMENT_TENANT_MODE; // single
|
|
156
|
+
await seedPI(TENANT_A, 'inv_a');
|
|
157
|
+
|
|
158
|
+
let threw = false;
|
|
159
|
+
(vendorCommissionQueue.push as jest.Mock).mockImplementation(() => {
|
|
160
|
+
try {
|
|
161
|
+
getInstanceDid();
|
|
162
|
+
} catch {
|
|
163
|
+
threw = true;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await expect(startVendorCommissionQueue()).resolves.toBeUndefined();
|
|
168
|
+
expect(threw).toBe(false);
|
|
169
|
+
expect(vendorCommissionQueue.push).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Unit test for the on-demand bootstrap endpoint (routes/hono/bootstrap.ts).
|
|
2
|
+
// The handler is isolated from its heavy deps: bootstrapTenant (service), the
|
|
3
|
+
// arcblock stake helpers, the models, the business wallet, and tenant context are
|
|
4
|
+
// mocked, and `authenticate` is stubbed to a pass-through (auth gating itself is
|
|
5
|
+
// covered by middlewares/hono/security.spec.ts). This keeps the test DB-free while
|
|
6
|
+
// pinning the handler contract: it runs the idempotent bootstrap for the current
|
|
7
|
+
// tenant and reports per-chain stake status.
|
|
8
|
+
const bootstrapTenant = jest.fn(async () => {});
|
|
9
|
+
jest.mock('../../../src/service', () => ({ bootstrapTenant }));
|
|
10
|
+
|
|
11
|
+
const findAll = jest.fn(async () => [] as any[]);
|
|
12
|
+
jest.mock('../../../src/store/models', () => ({ PaymentMethod: { findAll } }));
|
|
13
|
+
|
|
14
|
+
const hasStakedForGas = jest.fn(async () => false);
|
|
15
|
+
jest.mock('../../../src/integrations/arcblock/stake', () => ({ hasStakedForGas }));
|
|
16
|
+
|
|
17
|
+
jest.mock('../../../src/libs/auth', () => ({ wallet: { address: 'zAppWalletTest' } }));
|
|
18
|
+
jest.mock('../../../src/libs/context', () => ({ getInstanceDid: () => 'did:abt:zTenantTest' }));
|
|
19
|
+
jest.mock('../../../src/middlewares/hono/security', () => ({
|
|
20
|
+
authenticate: () => (_c: any, next: any) => next(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { Hono } from 'hono';
|
|
24
|
+
import bootstrap from '../../../src/routes/hono/bootstrap';
|
|
25
|
+
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
app.route('/api/bootstrap', bootstrap);
|
|
28
|
+
const get = () => app.fetch(new Request('http://app.local/api/bootstrap', { headers: { host: 'app.local' } }));
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
bootstrapTenant.mockClear();
|
|
32
|
+
findAll.mockReset().mockResolvedValue([]);
|
|
33
|
+
hasStakedForGas.mockReset().mockResolvedValue(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('GET /api/bootstrap — on-demand idempotent bootstrap', () => {
|
|
37
|
+
it('runs bootstrapTenant for the current tenant and returns a status report', async () => {
|
|
38
|
+
const r = await get();
|
|
39
|
+
expect(r.status).toBe(200);
|
|
40
|
+
const body = await r.json();
|
|
41
|
+
expect(body).toMatchObject({
|
|
42
|
+
ok: true,
|
|
43
|
+
instanceDid: 'did:abt:zTenantTest',
|
|
44
|
+
appWallet: 'zAppWalletTest',
|
|
45
|
+
chains: [],
|
|
46
|
+
});
|
|
47
|
+
expect(bootstrapTenant).toHaveBeenCalledWith('did:abt:zTenantTest');
|
|
48
|
+
expect(bootstrapTenant).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('is idempotent — repeated calls re-run the bootstrap and keep succeeding', async () => {
|
|
52
|
+
await get();
|
|
53
|
+
const r2 = await get();
|
|
54
|
+
expect(r2.status).toBe(200);
|
|
55
|
+
expect((await r2.json()).ok).toBe(true);
|
|
56
|
+
expect(bootstrapTenant).toHaveBeenCalledTimes(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reports declared/balance/stakedForGas per arcblock method', async () => {
|
|
60
|
+
const getAccountState = jest.fn(async () => ({ state: { balance: '100' } }));
|
|
61
|
+
findAll.mockResolvedValueOnce([
|
|
62
|
+
{
|
|
63
|
+
name: 'ArcBlock Beta',
|
|
64
|
+
settings: { arcblock: { api_host: 'https://beta.abtnetwork.io/api/' } },
|
|
65
|
+
getOcapClient: () => ({ getAccountState }),
|
|
66
|
+
} as any,
|
|
67
|
+
]);
|
|
68
|
+
hasStakedForGas.mockResolvedValueOnce(true);
|
|
69
|
+
|
|
70
|
+
const r = await get();
|
|
71
|
+
const body = await r.json();
|
|
72
|
+
expect(body.chains[0]).toMatchObject({
|
|
73
|
+
method: 'ArcBlock Beta',
|
|
74
|
+
host: 'https://beta.abtnetwork.io/api/',
|
|
75
|
+
declared: true,
|
|
76
|
+
balance: '100',
|
|
77
|
+
stakedForGas: true,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fails closed with 500 + message when the bootstrap throws', async () => {
|
|
82
|
+
bootstrapTenant.mockRejectedValueOnce(new Error('chain down'));
|
|
83
|
+
const r = await get();
|
|
84
|
+
expect(r.status).toBe(500);
|
|
85
|
+
expect(await r.json()).toMatchObject({ ok: false, instanceDid: 'did:abt:zTenantTest', error: 'chain down' });
|
|
86
|
+
});
|
|
87
|
+
});
|
package/blocklet.yml
CHANGED
package/cloudflare/cf-adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -288,8 +288,17 @@ const alias = {
|
|
|
288
288
|
'crypto-js/enc-utf8': s('shims/noop.ts'),
|
|
289
289
|
'crypto-js/enc-utf16': s('shims/noop.ts'),
|
|
290
290
|
|
|
291
|
-
// Force ESM-only to avoid CJS+ESM double-bundling
|
|
291
|
+
// Force ESM-only to avoid CJS+ESM double-bundling. These packages ship an
|
|
292
|
+
// exports map with separate import/require conditions, so esbuild picks the
|
|
293
|
+
// condition by call kind — and the payment graph reaches them via BOTH `import`
|
|
294
|
+
// (business code) and `require` (source require('ethers') + CJS @ocap/* deps),
|
|
295
|
+
// pulling two full copies. Pinning each bare specifier straight at its ESM
|
|
296
|
+
// entry (bypassing the exports map, like require.resolve can't — see valibot)
|
|
297
|
+
// collapses the duplicate. Measured on dist/cf.js: 1236KB → 1028KB gzip (-17%).
|
|
292
298
|
valibot: path.resolve(cfDir, '../../..', 'node_modules/valibot/dist/index.mjs'), // 396KB → ~200KB
|
|
299
|
+
ethers: path.resolve(cfDir, '../../..', 'node_modules/ethers/lib.esm/index.js'), // 767KB(cjs406+esm267) → 299KB; also drops the lib.commonjs/wordlists the esm-only dropEthersWordlists plugin never matched
|
|
300
|
+
'aes-js': path.resolve(cfDir, '../../..', 'node_modules/aes-js/lib.esm/index.js'), // 90KB → 42KB (CJS copy pulled by ethers/json-keystore + @ocap)
|
|
301
|
+
'@adraffy/ens-normalize': path.resolve(cfDir, '../../..', 'node_modules/@adraffy/ens-normalize/dist/index.mjs'), // 73KB → 36KB (CJS copy pulled by ethers/hash/namehash)
|
|
293
302
|
// CF Workers: noop modules not useful in Workers environment
|
|
294
303
|
phoenix: s('shims/noop.ts'), // 36KB — Phoenix channels, only used by @arcblock/ws
|
|
295
304
|
numbro: s('shims/noop.ts'), // 49KB — number formatting, replaced inline in util.ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.29.
|
|
3
|
+
"version": "1.29.6",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "blocklet dev --open",
|
|
6
6
|
"prelint": "npm run types",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@abtnode/cron": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
50
50
|
"@apple/app-store-server-library": "^3.1.0",
|
|
51
51
|
"@arcblock/did": "^1.30.24",
|
|
52
|
-
"@arcblock/did-connect-js": "^4.0.
|
|
52
|
+
"@arcblock/did-connect-js": "^4.0.7",
|
|
53
53
|
"@arcblock/did-connect-react": "^3.5.4",
|
|
54
54
|
"@arcblock/did-connect-storage-nedb": "^1.8.0",
|
|
55
55
|
"@arcblock/did-util": "^1.30.24",
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
"@blocklet/error": "^0.3.5",
|
|
62
62
|
"@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
63
63
|
"@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
64
|
-
"@blocklet/payment-broker-client": "1.29.
|
|
65
|
-
"@blocklet/payment-react": "1.29.
|
|
66
|
-
"@blocklet/payment-vendor": "1.29.
|
|
64
|
+
"@blocklet/payment-broker-client": "1.29.6",
|
|
65
|
+
"@blocklet/payment-react": "1.29.6",
|
|
66
|
+
"@blocklet/payment-vendor": "1.29.6",
|
|
67
67
|
"@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
68
68
|
"@blocklet/ui-react": "^3.5.4",
|
|
69
69
|
"@blocklet/uploader": "^0.3.20",
|
|
@@ -135,7 +135,7 @@
|
|
|
135
135
|
"devDependencies": {
|
|
136
136
|
"@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
|
|
137
137
|
"@arcblock/eslint-config-ts": "^0.3.3",
|
|
138
|
-
"@blocklet/payment-types": "1.29.
|
|
138
|
+
"@blocklet/payment-types": "1.29.6",
|
|
139
139
|
"@types/connect": "^3.4.38",
|
|
140
140
|
"@types/debug": "^4.1.12",
|
|
141
141
|
"@types/dotenv-flow": "^3.3.3",
|
|
@@ -183,5 +183,5 @@
|
|
|
183
183
|
"parser": "typescript"
|
|
184
184
|
}
|
|
185
185
|
},
|
|
186
|
-
"gitHead": "
|
|
186
|
+
"gitHead": "ee97756d3e9ba56def933580a6cb6bb93d97b55c"
|
|
187
187
|
}
|