payment-kit 1.29.2 → 1.29.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/api/src/bootstrap.ts +11 -0
  2. package/api/src/crons/index.ts +14 -13
  3. package/api/src/crons/tenant-fanout.ts +82 -0
  4. package/api/src/host-node/did-connect-runtime-node.ts +33 -0
  5. package/api/src/host-node/serve-static-arc.ts +68 -0
  6. package/api/src/host-node/serve-static.ts +41 -0
  7. package/api/src/libs/auth.ts +166 -27
  8. package/api/src/libs/context.ts +11 -0
  9. package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
  10. package/api/src/libs/did-connect/tenant-identity.ts +221 -0
  11. package/api/src/libs/drivers/identity.ts +61 -0
  12. package/api/src/libs/drivers/index.ts +1 -1
  13. package/api/src/libs/http-fetch-adapter.ts +11 -1
  14. package/api/src/libs/queue/index.ts +14 -2
  15. package/api/src/middlewares/hono/context.ts +7 -0
  16. package/api/src/middlewares/hono/csrf.ts +13 -2
  17. package/api/src/middlewares/hono/security.ts +6 -11
  18. package/api/src/queues/checkout-session.ts +21 -9
  19. package/api/src/queues/event.ts +29 -7
  20. package/api/src/queues/payment.ts +23 -9
  21. package/api/src/queues/payout.ts +28 -16
  22. package/api/src/queues/refund.ts +18 -6
  23. package/api/src/routes/hono/customers.ts +6 -1
  24. package/api/src/routes/hono/refunds.ts +2 -3
  25. package/api/src/service.ts +178 -31
  26. package/api/src/store/sequelize.ts +16 -1
  27. package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
  28. package/api/tests/crons/tenant-fanout.spec.ts +158 -0
  29. package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
  30. package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
  31. package/api/tests/libs/service-host.spec.ts +37 -0
  32. package/api/tests/queues/event-tenant.spec.ts +60 -4
  33. package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
  34. package/api/tests/service/fail-closed-http.spec.ts +79 -0
  35. package/api/tests/service/static-arc-handler.spec.ts +101 -0
  36. package/api/tests/service/static-externalized.spec.ts +48 -0
  37. package/blocklet.yml +1 -1
  38. package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
  39. package/cloudflare/README.md +8 -21
  40. package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
  41. package/cloudflare/build.ts +10 -5
  42. package/cloudflare/cf-adapter.ts +419 -0
  43. package/cloudflare/did-connect-runtime.ts +96 -0
  44. package/cloudflare/did-connect-token-storage.ts +151 -0
  45. package/cloudflare/esbuild-cf-config.cjs +407 -0
  46. package/cloudflare/run-build.js +33 -357
  47. package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
  48. package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
  49. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
  50. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
  51. package/cloudflare/tests/cf-adapter.spec.ts +244 -0
  52. package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
  53. package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
  54. package/cloudflare/vite.config.ts +53 -45
  55. package/cloudflare/worker.ts +98 -56
  56. package/cloudflare/wrangler.json +0 -6
  57. package/cloudflare/wrangler.jsonc +0 -6
  58. package/cloudflare/wrangler.local-e2e.jsonc +0 -1
  59. package/cloudflare/wrangler.staging.json +0 -6
  60. package/package.json +7 -7
  61. package/scripts/bootstrap-inject.ts +166 -0
  62. package/src/app.tsx +2 -1
  63. package/src/libs/service-host.ts +13 -0
  64. package/vite.arc.config.ts +159 -0
  65. package/cloudflare/did-connect-auth.ts +0 -310
  66. package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
  67. 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
- payments.forEach(async (x) => {
1383
- const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.payment_method_id);
1384
- if (supportAutoCharge === false) {
1385
- return;
1386
- }
1387
- const exist = await paymentQueue.get(x.id);
1388
- if (!exist) {
1389
- paymentQueue.push({ id: x.id, job: { paymentIntentId: x.id } });
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 }) => {
@@ -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
- payouts.forEach(async (payout) => {
248
- const exist = await payoutQueue.get(payout.id);
249
- if (!exist) {
250
- // Use next attempt time if set
251
- if (payout.next_attempt && payout.next_attempt > dayjs().unix()) {
252
- payoutQueue.push({
253
- id: payout.id,
254
- job: { payoutId: payout.id, retryOnError: true },
255
- runAt: payout.next_attempt,
256
- });
257
- } else {
258
- payoutQueue.push({
259
- id: payout.id,
260
- job: { payoutId: payout.id, retryOnError: true },
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
@@ -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
- refunds.forEach(async (x) => {
497
- const exist = await refundQueue.get(x.id);
498
- if (!exist) {
499
- refundQueue.push({ id: x.id, job: { refundId: x.id } });
500
- logger.info('Re-queued pending refund', { id: x.id });
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
- import isEmail from 'validator/es/lib/isEmail';
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 = getWallet().address;
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';
@@ -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, blockletAppDir } from './libs/env';
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
- * resource routes only — no app shell static/fallback, no DID-Connect
80
- * handlers, which the worker registers via its own Hono `attachDIDConnectRoutes`).
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 (production only) — what the express buildNodeHandler
249
- * served; now hono-native. `fallback` runs first and skips asset
250
- * paths (RESOURCE_PATTERN) + non-html, so real files fall through
251
- * to serveStatic and html navigations get the injected index.html.
252
- *
253
- * Static/fallback modules are required lazily so importing this module stays
254
- * side-effect-free for the workerd host (which never builds this app).
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(configureNative?: (native: Hono) => void, getConnectApp?: () => Hono): Hono {
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
- // ④ production static + SPA fallback (the express buildNodeHandler served these;
283
- // there is no more bridge to delegate to). Lazily required to keep this
284
- // module import side-effect-free for the workerd host.
285
- if (isProduction()) {
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((native: Hono) => {
615
- configureNativePipeline(native); // forked app-shell middleware (+ test stub)
616
- mountMigratedResources(native, { appShell: fullPipeline() }); // full app-shell on the node host
617
- }, getConnectRoutesHono);
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
- // eslint-disable-next-line import/prefer-default-export
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, {