payment-kit 1.29.2 → 1.29.3
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/bootstrap.ts +11 -0
- package/api/src/crons/index.ts +14 -13
- package/api/src/crons/tenant-fanout.ts +82 -0
- package/api/src/host-node/did-connect-runtime-node.ts +33 -0
- package/api/src/host-node/serve-static-arc.ts +68 -0
- package/api/src/host-node/serve-static.ts +41 -0
- package/api/src/libs/auth.ts +166 -27
- package/api/src/libs/context.ts +11 -0
- package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
- package/api/src/libs/did-connect/tenant-identity.ts +221 -0
- package/api/src/libs/drivers/identity.ts +61 -0
- package/api/src/libs/drivers/index.ts +1 -1
- package/api/src/libs/http-fetch-adapter.ts +11 -1
- package/api/src/libs/queue/index.ts +14 -2
- package/api/src/middlewares/hono/context.ts +7 -0
- package/api/src/middlewares/hono/csrf.ts +13 -2
- package/api/src/middlewares/hono/security.ts +6 -11
- package/api/src/queues/checkout-session.ts +21 -9
- package/api/src/queues/event.ts +29 -7
- package/api/src/queues/payment.ts +23 -9
- package/api/src/queues/payout.ts +28 -16
- package/api/src/queues/refund.ts +18 -6
- package/api/src/routes/hono/customers.ts +6 -1
- package/api/src/routes/hono/refunds.ts +2 -3
- package/api/src/service.ts +178 -31
- package/api/src/store/sequelize.ts +16 -1
- package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
- package/api/tests/crons/tenant-fanout.spec.ts +158 -0
- package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
- package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
- package/api/tests/libs/service-host.spec.ts +37 -0
- package/api/tests/queues/event-tenant.spec.ts +60 -4
- package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
- package/api/tests/service/fail-closed-http.spec.ts +79 -0
- package/api/tests/service/static-arc-handler.spec.ts +101 -0
- package/api/tests/service/static-externalized.spec.ts +48 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
- package/cloudflare/README.md +8 -21
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
- package/cloudflare/build.ts +10 -5
- package/cloudflare/cf-adapter.ts +419 -0
- package/cloudflare/did-connect-runtime.ts +96 -0
- package/cloudflare/did-connect-token-storage.ts +151 -0
- package/cloudflare/esbuild-cf-config.cjs +407 -0
- package/cloudflare/run-build.js +33 -357
- package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
- package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
- package/cloudflare/tests/cf-adapter.spec.ts +244 -0
- package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
- package/cloudflare/vite.config.ts +53 -45
- package/cloudflare/worker.ts +98 -56
- package/cloudflare/wrangler.json +0 -6
- package/cloudflare/wrangler.jsonc +0 -6
- package/cloudflare/wrangler.local-e2e.jsonc +0 -1
- package/cloudflare/wrangler.staging.json +0 -6
- package/package.json +7 -7
- package/scripts/bootstrap-inject.ts +166 -0
- package/src/app.tsx +2 -1
- package/src/libs/service-host.ts +13 -0
- package/vite.arc.config.ts +159 -0
- package/cloudflare/did-connect-auth.ts +0 -310
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +0 -8
|
@@ -7,6 +7,7 @@ import { ensureStakedForGas } from '../integrations/arcblock/stake';
|
|
|
7
7
|
import { transferErc20FromUser } from '../integrations/ethereum/token';
|
|
8
8
|
import { createEvent, reportAuditFailure } from '../libs/audit';
|
|
9
9
|
import { blocklet, wallet } from '../libs/auth';
|
|
10
|
+
import { withTenant } from '../libs/context';
|
|
10
11
|
import dayjs from '../libs/dayjs';
|
|
11
12
|
import CustomError from '../libs/error';
|
|
12
13
|
import { events } from '../libs/event';
|
|
@@ -1379,16 +1380,29 @@ export const startPaymentQueue = async () => {
|
|
|
1379
1380
|
},
|
|
1380
1381
|
});
|
|
1381
1382
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1383
|
+
// Each pending intent belongs to a tenant: the scoped supportAutoCharge query
|
|
1384
|
+
// and the queue push (push stamps the job tenant via getInstanceDid) must run in
|
|
1385
|
+
// that tenant's context — multi mode fails closed otherwise. for-of + await,
|
|
1386
|
+
// NOT forEach(async): a fire-and-forget rejection here becomes an
|
|
1387
|
+
// unhandledRejection → FATAL on boot. Per-record try/catch isolates one bad row.
|
|
1388
|
+
for (const x of payments) {
|
|
1389
|
+
const dispatch = async () => {
|
|
1390
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.payment_method_id);
|
|
1391
|
+
if (supportAutoCharge === false) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const exist = await paymentQueue.get(x.id);
|
|
1395
|
+
if (!exist) {
|
|
1396
|
+
paymentQueue.push({ id: x.id, job: { paymentIntentId: x.id } });
|
|
1397
|
+
}
|
|
1398
|
+
};
|
|
1399
|
+
try {
|
|
1400
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1401
|
+
await (x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch());
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
logger.error('startPaymentQueue: re-queue failed', { id: x.id, error });
|
|
1390
1404
|
}
|
|
1391
|
-
}
|
|
1405
|
+
}
|
|
1392
1406
|
};
|
|
1393
1407
|
|
|
1394
1408
|
paymentQueue.on('failed', ({ id, job, error }) => {
|
package/api/src/queues/payout.ts
CHANGED
|
@@ -4,6 +4,7 @@ import logger from '../libs/logger';
|
|
|
4
4
|
import { getGasPayerExtra } from '../libs/payment';
|
|
5
5
|
import createQueue, { assertJobObjectTenant } from '../libs/queue';
|
|
6
6
|
import { wallet, ethWallet } from '../libs/auth';
|
|
7
|
+
import { withTenant } from '../libs/context';
|
|
7
8
|
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
8
9
|
import { PaymentMethod } from '../store/models/payment-method';
|
|
9
10
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
@@ -244,24 +245,35 @@ export const startPayoutQueue = async () => {
|
|
|
244
245
|
},
|
|
245
246
|
});
|
|
246
247
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
248
|
+
// Per-record tenant context: queue push stamps the job tenant via getInstanceDid,
|
|
249
|
+
// which fails closed in multi mode without it. for-of + await (not forEach(async),
|
|
250
|
+
// which leaks an unhandledRejection → FATAL on boot); per-record catch isolates.
|
|
251
|
+
for (const payout of payouts) {
|
|
252
|
+
const dispatch = async () => {
|
|
253
|
+
const exist = await payoutQueue.get(payout.id);
|
|
254
|
+
if (!exist) {
|
|
255
|
+
// Use next attempt time if set
|
|
256
|
+
if (payout.next_attempt && payout.next_attempt > dayjs().unix()) {
|
|
257
|
+
payoutQueue.push({
|
|
258
|
+
id: payout.id,
|
|
259
|
+
job: { payoutId: payout.id, retryOnError: true },
|
|
260
|
+
runAt: payout.next_attempt,
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
payoutQueue.push({
|
|
264
|
+
id: payout.id,
|
|
265
|
+
job: { payoutId: payout.id, retryOnError: true },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
262
268
|
}
|
|
269
|
+
};
|
|
270
|
+
try {
|
|
271
|
+
// eslint-disable-next-line no-await-in-loop
|
|
272
|
+
await (payout.instance_did ? withTenant(payout.instance_did, dispatch) : dispatch());
|
|
273
|
+
} catch (error) {
|
|
274
|
+
logger.error('startPayoutQueue: re-queue failed', { id: payout.id, error });
|
|
263
275
|
}
|
|
264
|
-
}
|
|
276
|
+
}
|
|
265
277
|
};
|
|
266
278
|
|
|
267
279
|
// Listen for newly created payouts
|
package/api/src/queues/refund.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { isRefundReasonSupportedByStripe } from '../libs/refund';
|
|
|
3
3
|
import { checkRemainingStake, getSubscriptionStakeAddress } from '../libs/subscription';
|
|
4
4
|
import { sendErc20ToUser } from '../integrations/ethereum/token';
|
|
5
5
|
import { wallet } from '../libs/auth';
|
|
6
|
+
import { withTenant } from '../libs/context';
|
|
6
7
|
import CustomError, { NonRetryableError } from '../libs/error';
|
|
7
8
|
import { events } from '../libs/event';
|
|
8
9
|
import logger from '../libs/logger';
|
|
@@ -493,13 +494,24 @@ export const refundQueue = createQueue<RefundJob>({
|
|
|
493
494
|
|
|
494
495
|
export const startRefundQueue = async () => {
|
|
495
496
|
const refunds = await systemFindAll(Refund, { where: { status: ['pending'] } });
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
497
|
+
// Per-record tenant context: queue push stamps the job tenant via getInstanceDid,
|
|
498
|
+
// which fails closed in multi mode without it. for-of + await (not forEach(async),
|
|
499
|
+
// which leaks an unhandledRejection → FATAL on boot); per-record catch isolates.
|
|
500
|
+
for (const x of refunds) {
|
|
501
|
+
const dispatch = async () => {
|
|
502
|
+
const exist = await refundQueue.get(x.id);
|
|
503
|
+
if (!exist) {
|
|
504
|
+
refundQueue.push({ id: x.id, job: { refundId: x.id } });
|
|
505
|
+
logger.info('Re-queued pending refund', { id: x.id });
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
try {
|
|
509
|
+
// eslint-disable-next-line no-await-in-loop
|
|
510
|
+
await (x.instance_did ? withTenant(x.instance_did, dispatch) : dispatch());
|
|
511
|
+
} catch (error) {
|
|
512
|
+
logger.error('startRefundQueue: re-queue failed', { id: x.id, error });
|
|
501
513
|
}
|
|
502
|
-
}
|
|
514
|
+
}
|
|
503
515
|
};
|
|
504
516
|
|
|
505
517
|
refundQueue.on('failed', ({ id, job, error }) => {
|
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
import { Hono } from 'hono';
|
|
6
6
|
import Joi from 'joi';
|
|
7
7
|
import pick from 'lodash/pick';
|
|
8
|
-
|
|
8
|
+
// Use the CommonJS build (`lib`), not the ESM `es` build: `es/lib/isEmail.js`
|
|
9
|
+
// uses extensionless relative imports (`./util/assertString`) that Node ESM
|
|
10
|
+
// strict resolution rejects with ERR_MODULE_NOT_FOUND when payment-core is
|
|
11
|
+
// embedded in a Node host like arc. The `lib` build is plain CJS and resolves
|
|
12
|
+
// everywhere. Same default export.
|
|
13
|
+
import isEmail from 'validator/lib/isEmail';
|
|
9
14
|
|
|
10
15
|
import { Op, type WhereOptions } from 'sequelize';
|
|
11
16
|
import { BN } from '@ocap/util';
|
|
@@ -11,7 +11,6 @@ import { Hono } from 'hono';
|
|
|
11
11
|
import Joi from 'joi';
|
|
12
12
|
import pick from 'lodash/pick';
|
|
13
13
|
|
|
14
|
-
import { getWallet } from '@blocklet/sdk/lib/wallet';
|
|
15
14
|
import { Op } from 'sequelize';
|
|
16
15
|
import { sendErc20ToUser } from '../../integrations/ethereum/token';
|
|
17
16
|
import {
|
|
@@ -498,8 +497,8 @@ app.post('/sync', authAdmin, async (c: any) => {
|
|
|
498
497
|
}
|
|
499
498
|
}
|
|
500
499
|
|
|
501
|
-
// Get system DID as default payer (发款方)
|
|
502
|
-
const systemDid =
|
|
500
|
+
// Get system DID as default payer (发款方) — the active tenant's business wallet.
|
|
501
|
+
const systemDid = wallet.address;
|
|
503
502
|
|
|
504
503
|
// Create mock customer if customerName is provided, otherwise use "Broker" as default
|
|
505
504
|
const finalCustomerName = customerName || 'Broker';
|
package/api/src/service.ts
CHANGED
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
/* eslint-disable global-require, max-classes-per-file */
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
2
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
5
3
|
import { CustomError, formatError, getStatusFromError } from '@blocklet/error';
|
|
6
4
|
import { Hono } from 'hono';
|
|
7
5
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
8
|
-
import { isProduction
|
|
6
|
+
import { isProduction } from './libs/env';
|
|
9
7
|
|
|
10
8
|
import logger from './libs/logger';
|
|
11
9
|
import { context as requestContext } from './libs/context';
|
|
12
10
|
import { TENANT_CONTEXT_MISSING, TenantError, getTenantMode, getDefaultInstanceDid } from './libs/tenant';
|
|
13
11
|
import type { LocksDriver, QueueHostHooks, CronDriver, IdentityDriver, SecretsDriver } from './libs/drivers';
|
|
12
|
+
import type { DidConnectTokenStorage, DidConnectRuntime } from './libs/auth';
|
|
14
13
|
import type { PaymentFetchOptions, FetchHandler } from './libs/http-fetch-adapter';
|
|
15
14
|
|
|
16
15
|
export type { PaymentFetchOptions } from './libs/http-fetch-adapter';
|
|
@@ -56,6 +55,30 @@ export interface PaymentCoreSlots {
|
|
|
56
55
|
identity?: IdentityDriver;
|
|
57
56
|
secrets?: SecretsDriver;
|
|
58
57
|
tenancy?: TenancySlot;
|
|
58
|
+
/**
|
|
59
|
+
* S3-CF Phase 1 inversion ③: a host-provided DID-Connect token storage (same
|
|
60
|
+
* reversal as db/queue/cron). The CF worker injects a D1-backed store so the
|
|
61
|
+
* token handshake state lands in PAYMENT_DB (strongly consistent); node hosts
|
|
62
|
+
* omit it and keep the file-backed nedb default. The 14 DID-Connect handler
|
|
63
|
+
* routes are unchanged — only the persistence backend swaps.
|
|
64
|
+
*/
|
|
65
|
+
storage?: DidConnectTokenStorage;
|
|
66
|
+
/**
|
|
67
|
+
* S3-CF (DID convergence): the host-injected DID-Connect runtime (the host
|
|
68
|
+
* injection entry). blocklet-server injects createBlockletServerDidConnectRuntime
|
|
69
|
+
* (@blocklet/sdk wrapper); CF + arc-node embedded inject the real
|
|
70
|
+
* @arcblock/did-connect-js runtime (AUTH_SERVICE identity + host tokenStorage).
|
|
71
|
+
* Takes precedence over `storage` (the runtime carries its own tokenStorage).
|
|
72
|
+
*/
|
|
73
|
+
didConnectRuntime?: DidConnectRuntime;
|
|
74
|
+
/**
|
|
75
|
+
* S3-CF Phase 1 inversion ①: a host-provided static/SPA handler wired onto the
|
|
76
|
+
* full node hono app AFTER the api/connect routes. The node blocklet-server host
|
|
77
|
+
* injects host-node/serve-static's `attachNodeStatic` to replicate today's SPA
|
|
78
|
+
* serving; the CF/standalone worker serves assets via env.ASSETS and omits this,
|
|
79
|
+
* keeping the runtime-neutral `http.fetch` free of node:fs.
|
|
80
|
+
*/
|
|
81
|
+
staticHandler?: (app: Hono) => void;
|
|
59
82
|
}
|
|
60
83
|
|
|
61
84
|
export interface PaymentCoreLifecycle {
|
|
@@ -75,9 +98,11 @@ export interface PaymentCoreService {
|
|
|
75
98
|
handler: Hono;
|
|
76
99
|
/**
|
|
77
100
|
* The embeddable HTTP surface for hosts that own their own app shell. The CF
|
|
78
|
-
* worker mounts `resourceRoutes` into its Hono pipeline (Option 3 seam:
|
|
79
|
-
*
|
|
80
|
-
*
|
|
101
|
+
* worker mounts `resourceRoutes` into its Hono pipeline (Option 3 seam: resource
|
|
102
|
+
* routes only — no app shell static/fallback, no DID-Connect handlers). S3-CF
|
|
103
|
+
* (DID convergence): the worker now mounts the 14 payment DID actions from the
|
|
104
|
+
* shared core `buildConnectRoutesHono` (backed by the injected CF DID-Connect
|
|
105
|
+
* runtime), not a private surface.
|
|
81
106
|
* Phase 4: a hono app (`/api/healthz` + the migrated resource domains, each
|
|
82
107
|
* scoped to its own app-shell pipeline), mounted by the worker via `app.route`.
|
|
83
108
|
*/
|
|
@@ -108,6 +133,17 @@ export interface PaymentCoreService {
|
|
|
108
133
|
* bootstraps the default tenant from lifecycle.start automatically).
|
|
109
134
|
*/
|
|
110
135
|
bootstrapTenant: (instanceDid: string) => Promise<void>;
|
|
136
|
+
/**
|
|
137
|
+
* Idempotent per-tenant base-data provisioning: seeds the default ArcBlock
|
|
138
|
+
* payment methods (main + beta) and their base currencies, scoped to the
|
|
139
|
+
* tenant's instanceDid. The single-tenant `20230911-seeding` migration writes
|
|
140
|
+
* these with NO instance_did, so they are invisible to multi-tenant queries;
|
|
141
|
+
* a multi-tenant host calls this per tenant (e.g. lazily on first request) so
|
|
142
|
+
* the tenant has the payment method/currency every downstream entity (meters,
|
|
143
|
+
* products, prices, subscriptions) depends on. No secrets / no chain calls —
|
|
144
|
+
* pure records. Skips if the tenant already has an arcblock method.
|
|
145
|
+
*/
|
|
146
|
+
provisionTenant: (instanceDid: string) => Promise<void>;
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
/**
|
|
@@ -229,6 +265,22 @@ function connectHandlerModules(): any[] {
|
|
|
229
265
|
export function buildConnectRoutesHono(): Hono {
|
|
230
266
|
const { handlers } = require('./libs/auth');
|
|
231
267
|
const connectApp = new Hono();
|
|
268
|
+
|
|
269
|
+
// S3-CF (DID convergence) F: a LIGHTWEIGHT tenant-context-only middleware — NOT
|
|
270
|
+
// the full resource pipeline (no cors/xss/csrf/i18n/cdn; DID-Connect carries its
|
|
271
|
+
// own ensureSignedJson and did-connect-js registers its own CORS). The CF / AUTH_
|
|
272
|
+
// SERVICE runtimes resolve the per-tenant signing identity + scope the token store
|
|
273
|
+
// by the TenantContext instanceDid, so every DID route must run inside one. If the
|
|
274
|
+
// host already established a context (e.g. the CF worker wrapped /api/* in
|
|
275
|
+
// withTenant) we pass through; otherwise we resolve Host→tenant once and wrap.
|
|
276
|
+
connectApp.use('*', async (c, next) => {
|
|
277
|
+
const { context: requestCtx } = require('./libs/context');
|
|
278
|
+
if (requestCtx.peekInstanceDid()) return next();
|
|
279
|
+
const { resolveTenantForHost } = require('./libs/drivers/identity');
|
|
280
|
+
const instanceDid = await resolveTenantForHost(c.req.header('host'));
|
|
281
|
+
return requestCtx.withTenant(instanceDid, () => next());
|
|
282
|
+
});
|
|
283
|
+
|
|
232
284
|
for (const h of connectHandlerModules()) handlers.attach(Object.assign({ app: connectApp }, h));
|
|
233
285
|
return connectApp;
|
|
234
286
|
}
|
|
@@ -245,15 +297,20 @@ export function buildConnectRoutesHono(): Hono {
|
|
|
245
297
|
* populated by `configureNative`.
|
|
246
298
|
* ③ connectApp — the DID-Connect sub-app, mounted alongside `native` (NOT under
|
|
247
299
|
* it — it carries its own ensureSignedJson, no xss/csrf/cors).
|
|
248
|
-
* ④ static + SPA fallback (
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
300
|
+
* ④ host static + SPA fallback (optional) — S3-CF Phase 1 inversion ①: the
|
|
301
|
+
* node-only static/SPA shell is no longer wired HERE (that kept
|
|
302
|
+
* `http.fetch` node-bound). The HOST injects `attachStatic`
|
|
303
|
+
* (node blocklet-server replicates today's serving via
|
|
304
|
+
* host-node/serve-static; the CF/standalone worker serves assets
|
|
305
|
+
* via env.ASSETS and injects nothing). Absent → the app serves
|
|
306
|
+
* no static and carries ZERO node:fs, so node, CF, and the
|
|
307
|
+
* standalone worker share this one surface.
|
|
255
308
|
*/
|
|
256
|
-
export function buildHonoApp(
|
|
309
|
+
export function buildHonoApp(
|
|
310
|
+
configureNative?: (native: Hono) => void,
|
|
311
|
+
getConnectApp?: () => Hono,
|
|
312
|
+
attachStatic?: (app: Hono) => void
|
|
313
|
+
): Hono {
|
|
257
314
|
const app = new Hono();
|
|
258
315
|
|
|
259
316
|
// Unified error handling — the hono equivalent of express-async-errors + the
|
|
@@ -279,19 +336,10 @@ export function buildHonoApp(configureNative?: (native: Hono) => void, getConnec
|
|
|
279
336
|
app.route('/', getConnectApp()); // ③ DID-Connect
|
|
280
337
|
}
|
|
281
338
|
|
|
282
|
-
// ④
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
const { serveStatic } = require('@hono/node-server/serve-static');
|
|
287
|
-
const { fallback } = require('./middlewares/hono/fallback');
|
|
288
|
-
const staticDir = path.resolve(blockletAppDir()!, 'dist');
|
|
289
|
-
// serveStatic resolves `root` relative to process.cwd(); map the absolute
|
|
290
|
-
// app dist dir back to a cwd-relative path so it resolves to the same place.
|
|
291
|
-
const staticRoot = path.relative(process.cwd(), staticDir) || '.';
|
|
292
|
-
app.use('*', fallback('index.html', { root: staticDir })); // injected index.html for html GET (skips assets)
|
|
293
|
-
app.use('*', serveStatic({ root: staticRoot })); // real asset files
|
|
294
|
-
}
|
|
339
|
+
// ④ host-provided static + SPA fallback, wired LAST (after api/connect routes)
|
|
340
|
+
// so real routes win and only unmatched html navigations hit the fallback.
|
|
341
|
+
// The node:fs implementation lives in host-node/serve-static (node-only).
|
|
342
|
+
attachStatic?.(app);
|
|
295
343
|
|
|
296
344
|
return app;
|
|
297
345
|
}
|
|
@@ -363,11 +411,17 @@ async function bootstrapTenant(instanceDid: string): Promise<void> {
|
|
|
363
411
|
const { ensureWebhookRegistered } = require('./integrations/stripe/setup');
|
|
364
412
|
const { ensureCreateOverdraftProtectionPrices } = require('./libs/overdraft-protection');
|
|
365
413
|
const { scheduleHealthChecks } = require('./queues/exchange-rate-health');
|
|
414
|
+
const { warmTenantIdentity } = require('./libs/did-connect/tenant-identity');
|
|
366
415
|
|
|
367
416
|
// Run the whole bootstrap inside the tenant context. We AWAIT each op so its
|
|
368
417
|
// async continuation stays in-scope (a fire-and-forget would lose the ALS
|
|
369
418
|
// context after the callback returns).
|
|
370
419
|
await requestContext.withTenant(instanceDid, async () => {
|
|
420
|
+
// Warm the tenant's signing identity first — the lifecycle analog of the
|
|
421
|
+
// HTTP/queue warm. ensureStakedForGas reads `wallet.address` synchronously;
|
|
422
|
+
// without a warmed cache it fails-closed on the AUTH_SERVICE runtimes (no-op
|
|
423
|
+
// on blocklet-server). Best-effort, like the other bootstrap steps.
|
|
424
|
+
await warmTenantIdentity(instanceDid);
|
|
371
425
|
await Promise.resolve(syncCurrencyLogo()).catch((error: unknown) =>
|
|
372
426
|
logger.error('bootstrapTenant: syncCurrencyLogo failed', { instanceDid, error })
|
|
373
427
|
);
|
|
@@ -387,6 +441,76 @@ async function bootstrapTenant(instanceDid: string): Promise<void> {
|
|
|
387
441
|
});
|
|
388
442
|
}
|
|
389
443
|
|
|
444
|
+
// Per-tenant analog of the single-tenant `20230911-seeding` migration: that
|
|
445
|
+
// migration bulk-inserts the default ArcBlock methods/currencies with NO
|
|
446
|
+
// instance_did, so they are invisible to a multi-tenant query. This recreates
|
|
447
|
+
// them scoped to one tenant. Mirrors the proven create order in
|
|
448
|
+
// routes/hono/payment-methods.ts (method → currency → method.default_currency_id).
|
|
449
|
+
async function provisionTenant(instanceDid: string): Promise<void> {
|
|
450
|
+
if (!instanceDid || typeof instanceDid !== 'string') {
|
|
451
|
+
throw new TenantError(TENANT_CONTEXT_MISSING, 'provisionTenant requires an instanceDid');
|
|
452
|
+
}
|
|
453
|
+
// eslint-disable-next-line global-require, import/no-extraneous-dependencies
|
|
454
|
+
const { fromTokenToUnit } = require('@ocap/util');
|
|
455
|
+
const { PaymentMethod, PaymentCurrency } = require('./store/models');
|
|
456
|
+
|
|
457
|
+
const CHAINS = [
|
|
458
|
+
{ chainId: 'main', livemode: true, symbol: 'ABT', label: 'ArcBlock Main', contract: 'z35nNRvYxBoHitx9yZ5ATS88psfShzPPBLxYD' },
|
|
459
|
+
{ chainId: 'beta', livemode: false, symbol: 'TBA', label: 'ArcBlock Beta', contract: 'z35n6UoHSi9MED4uaQy6ozFgKPaZj2UKrurBG' },
|
|
460
|
+
];
|
|
461
|
+
|
|
462
|
+
await requestContext.withTenant(instanceDid, async () => {
|
|
463
|
+
// Idempotent: a tenant that already has an arcblock method is provisioned.
|
|
464
|
+
const existing = await PaymentMethod.findOne({ where: { type: 'arcblock' } });
|
|
465
|
+
if (existing) return;
|
|
466
|
+
|
|
467
|
+
for (const chain of CHAINS) {
|
|
468
|
+
const logo = '/methods/arcblock.png';
|
|
469
|
+
const method = await PaymentMethod.create({
|
|
470
|
+
instance_did: instanceDid,
|
|
471
|
+
active: true,
|
|
472
|
+
livemode: chain.livemode,
|
|
473
|
+
locked: true,
|
|
474
|
+
type: 'arcblock',
|
|
475
|
+
name: chain.label,
|
|
476
|
+
description: `Process payments with tokens on ArcBlock ${chain.chainId} chain`,
|
|
477
|
+
logo,
|
|
478
|
+
confirmation: { type: 'immediate' },
|
|
479
|
+
settings: {
|
|
480
|
+
arcblock: {
|
|
481
|
+
chain_id: chain.chainId,
|
|
482
|
+
api_host: `https://${chain.chainId}.abtnetwork.io/api/`,
|
|
483
|
+
explorer_host: `https://${chain.chainId}.abtnetwork.io/explorer/`,
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
features: { recurring: true, refund: true, dispute: false },
|
|
487
|
+
metadata: {},
|
|
488
|
+
});
|
|
489
|
+
const currency = await PaymentCurrency.create({
|
|
490
|
+
instance_did: instanceDid,
|
|
491
|
+
active: true,
|
|
492
|
+
livemode: chain.livemode,
|
|
493
|
+
locked: true,
|
|
494
|
+
is_base_currency: true,
|
|
495
|
+
payment_method_id: method.id,
|
|
496
|
+
type: 'standard',
|
|
497
|
+
name: chain.symbol,
|
|
498
|
+
description: chain.symbol,
|
|
499
|
+
logo,
|
|
500
|
+
symbol: chain.symbol,
|
|
501
|
+
decimal: 18,
|
|
502
|
+
minimum_payment_amount: fromTokenToUnit(0.1, 18).toString(),
|
|
503
|
+
maximum_precision: 6,
|
|
504
|
+
maximum_payment_amount: fromTokenToUnit(100000000, 18).toString(),
|
|
505
|
+
contract: chain.contract,
|
|
506
|
+
metadata: {},
|
|
507
|
+
});
|
|
508
|
+
await method.update({ default_currency_id: currency.id });
|
|
509
|
+
}
|
|
510
|
+
logger.info('provisionTenant: seeded arcblock payment methods + currencies', { instanceDid });
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
390
514
|
async function startBackgroundServices(): Promise<void> {
|
|
391
515
|
if (servicesStarted) {
|
|
392
516
|
logger.info('payment core background services already started, skipping');
|
|
@@ -544,6 +668,12 @@ export function createEmbeddedPaymentService(slots: PaymentCoreSlots): EmbeddedP
|
|
|
544
668
|
|
|
545
669
|
// model binding — only after validation passed (no partial init)
|
|
546
670
|
const { initialize } = require('./store/models');
|
|
671
|
+
// Make the default-export `sequelize` resolve to the host instance too, so code
|
|
672
|
+
// that imports it directly (Price.expand's `sequelize.models.Product`, etc.) hits
|
|
673
|
+
// the SAME instance the models bind to — in a bare host the default would
|
|
674
|
+
// otherwise build a broken sqlite from an undefined data dir.
|
|
675
|
+
const { setDefaultSequelize } = require('./store/sequelize');
|
|
676
|
+
setDefaultSequelize(slots.db.sequelize);
|
|
547
677
|
initialize(slots.db.sequelize);
|
|
548
678
|
|
|
549
679
|
// locks slot (Phase 8): inject the locks driver if the host provides one;
|
|
@@ -567,6 +697,17 @@ export function createEmbeddedPaymentService(slots: PaymentCoreSlots): EmbeddedP
|
|
|
567
697
|
// (single key — Blocklet Server unchanged, existing ciphertext decryptable).
|
|
568
698
|
if (slots.secrets) setSecretsDriver(slots.secrets);
|
|
569
699
|
|
|
700
|
+
// DID-Connect runtime + storage slots (S3-CF DID convergence): the host injects
|
|
701
|
+
// its runtime (blocklet-server: SDK wrapper; CF/arc-node: real
|
|
702
|
+
// @arcblock/did-connect-js) and/or a token store. Set before buildConnectRoutesHono
|
|
703
|
+
// materializes `handlers`. The runtime is the primary host-injection entry; the
|
|
704
|
+
// storage-only slot remains for hosts that keep the default authenticator/handlers.
|
|
705
|
+
if (slots.didConnectRuntime || slots.storage) {
|
|
706
|
+
const { setDidConnectRuntime, setDidConnectTokenStorage } = require('./libs/auth');
|
|
707
|
+
if (slots.didConnectRuntime) setDidConnectRuntime(slots.didConnectRuntime);
|
|
708
|
+
if (slots.storage) setDidConnectTokenStorage(slots.storage);
|
|
709
|
+
}
|
|
710
|
+
|
|
570
711
|
// identity slot (Phase 10): the host injects a Host->tenant resolver for
|
|
571
712
|
// multi-tenant; the default driver resolves every host to the deployment app
|
|
572
713
|
// DID (single mode, unchanged Blocklet Server behavior).
|
|
@@ -611,10 +752,15 @@ export function createEmbeddedPaymentService(slots: PaymentCoreSlots): EmbeddedP
|
|
|
611
752
|
const { configureNativePipeline, fullPipeline } = require('./middlewares/hono/pipeline');
|
|
612
753
|
// eslint-disable-next-line global-require
|
|
613
754
|
const { mountMigratedResources } = require('./routes/hono');
|
|
614
|
-
return buildHonoApp(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
755
|
+
return buildHonoApp(
|
|
756
|
+
(native: Hono) => {
|
|
757
|
+
configureNativePipeline(native); // forked app-shell middleware (+ test stub)
|
|
758
|
+
mountMigratedResources(native, { appShell: fullPipeline() }); // full app-shell on the node host
|
|
759
|
+
},
|
|
760
|
+
getConnectRoutesHono,
|
|
761
|
+
// S3-CF Phase 1 ①: host-provided static/SPA shell (node injects it; CF omits).
|
|
762
|
+
slots.staticHandler
|
|
763
|
+
);
|
|
618
764
|
});
|
|
619
765
|
// D5: the base-strip Fetch adapter wraps the full hono app (arc consumer). Lazy
|
|
620
766
|
// require + memo so the adapter is built only on the first http.fetch call.
|
|
@@ -663,5 +809,6 @@ export function createEmbeddedPaymentService(slots: PaymentCoreSlots): EmbeddedP
|
|
|
663
809
|
rpc,
|
|
664
810
|
lifecycle,
|
|
665
811
|
bootstrapTenant,
|
|
812
|
+
provisionTenant,
|
|
666
813
|
};
|
|
667
814
|
}
|
|
@@ -61,7 +61,22 @@ const getSequelize = (): Sequelize => {
|
|
|
61
61
|
return instance;
|
|
62
62
|
};
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Point the default-export `sequelize` at the host-injected instance. In a bare
|
|
66
|
+
* host (arc embedding, no BLOCKLET_DATA_DIR) the default export is a deferred
|
|
67
|
+
* Proxy whose `buildSequelize()` would crash on `join(undefined, …)`; the host
|
|
68
|
+
* binds every model to ITS OWN sequelize via `initialize(slots.db.sequelize)`.
|
|
69
|
+
* Code that imports this default directly (e.g. `sequelize.models.Product` in
|
|
70
|
+
* Price.expand) must therefore resolve to that SAME instance — otherwise it
|
|
71
|
+
* builds the broken default. The factory calls this at the single bind point so
|
|
72
|
+
* the imported default and the model-bound instance are always one object.
|
|
73
|
+
* Equivalent to a no-op for blocklet-server/CF, where the default is already the
|
|
74
|
+
* bind target.
|
|
75
|
+
*/
|
|
76
|
+
export function setDefaultSequelize(seq: Sequelize): void {
|
|
77
|
+
instance = seq;
|
|
78
|
+
}
|
|
79
|
+
|
|
65
80
|
export const sequelize: Sequelize = env.dataDir
|
|
66
81
|
? getSequelize()
|
|
67
82
|
: new Proxy({} as Sequelize, {
|