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
@@ -9,7 +9,9 @@
9
9
 
10
10
  import logger from './libs/logger';
11
11
  import { getDefaultInstanceDid, getTenantMode } from './libs/tenant';
12
+ import { createBlockletServerDidConnectRuntime } from './libs/auth';
12
13
  import { createEmbeddedPaymentService, type EmbeddedPaymentService } from './service';
14
+ import { attachNodeStatic } from './host-node/serve-static';
13
15
  import { sequelize } from './store/sequelize';
14
16
  import { blockletPort } from './libs/env';
15
17
 
@@ -23,6 +25,15 @@ export function buildService(): { service: EmbeddedPaymentService; port: number
23
25
  config: { ...process.env }, // Phase 12 narrows this to an explicit schema
24
26
  db: { sequelize },
25
27
  tenancy,
28
+ // S3-CF Phase 1 ①: the blocklet-server host owns static/SPA serving (moved out
29
+ // of the runtime-neutral buildHonoApp). attachNodeStatic is production-gated, so
30
+ // dev (this same bootstrap) is unaffected — identical to the prior behavior.
31
+ staticHandler: attachNodeStatic,
32
+ // S3-CF (DID convergence): blocklet-server is the ONE host that uses the
33
+ // @blocklet/sdk DID-Connect wrapper (autoConnect / notification relay /
34
+ // federated login). Injected explicitly via the same host-injection entry as
35
+ // CF/arc-node — not relied on as an implicit libs/auth default.
36
+ didConnectRuntime: createBlockletServerDidConnectRuntime(),
26
37
  });
27
38
 
28
39
  const port = parseInt(blockletPort()!, 10);
@@ -34,6 +34,7 @@ import { CheckoutSession } from '../store/models';
34
34
  import { createOverdueDetection } from './overdue-detection';
35
35
  import { createPaymentStat } from './payment-stat';
36
36
  import { retryPendingEvents } from './retry-pending-events';
37
+ import { perTenant } from './tenant-fanout';
37
38
  import { SubscriptionTrialWillEndSchedule } from './subscription-trial-will-end';
38
39
  import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
39
40
  import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
@@ -72,19 +73,19 @@ function init() {
72
73
  {
73
74
  name: 'subscription.will.renew',
74
75
  time: notificationCronTime(),
75
- fn: () => new SubscriptionWillRenewSchedule().run(),
76
+ fn: perTenant('subscription.will.renew', () => new SubscriptionWillRenewSchedule().run()),
76
77
  options: { runOnInit: true },
77
78
  },
78
79
  {
79
80
  name: 'subscription.trial.will.end',
80
81
  time: notificationCronTime(),
81
- fn: () => new SubscriptionTrialWillEndSchedule().run(),
82
+ fn: perTenant('subscription.trial.will.end', () => new SubscriptionTrialWillEndSchedule().run()),
82
83
  options: { runOnInit: true },
83
84
  },
84
85
  {
85
86
  name: 'customer.subscription.will_canceled',
86
87
  time: notificationCronTime(),
87
- fn: () => new SubscriptionWillCanceledSchedule().run(),
88
+ fn: perTenant('customer.subscription.will_canceled', () => new SubscriptionWillCanceledSchedule().run()),
88
89
  options: { runOnInit: true },
89
90
  },
90
91
  {
@@ -105,34 +106,34 @@ function init() {
105
106
  {
106
107
  name: 'checkoutSession.cleanup.expired',
107
108
  time: expiredSessionCleanupCronTime(),
108
- fn: async () => {
109
+ fn: perTenant('checkoutSession.cleanup.expired', async () => {
109
110
  const removedCount = await CheckoutSession.cleanupExpiredSessions();
110
111
  logger.info('CheckoutSession.cleanupExpiredSessions', { removedCount });
111
- },
112
+ }),
112
113
  options: { runOnInit: true },
113
114
  },
114
115
  {
115
116
  name: 'stripe.invoice.sync',
116
117
  time: stripeInvoiceCronTime(),
117
- fn: batchHandleStripeInvoices,
118
+ fn: perTenant('stripe.invoice.sync', batchHandleStripeInvoices),
118
119
  options: { runOnInit: false },
119
120
  },
120
121
  {
121
122
  name: 'stripe.payment.sync',
122
123
  time: stripePaymentCronTime(),
123
- fn: batchHandleStripePayments,
124
+ fn: perTenant('stripe.payment.sync', batchHandleStripePayments),
124
125
  options: { runOnInit: false },
125
126
  },
126
127
  {
127
128
  name: 'stripe.subscription.sync',
128
129
  time: stripeSubscriptionCronTime(),
129
- fn: batchHandleStripeSubscriptions,
130
+ fn: perTenant('stripe.subscription.sync', batchHandleStripeSubscriptions),
130
131
  options: { runOnInit: false },
131
132
  },
132
133
  {
133
134
  name: 'customer.stake.revoked',
134
135
  time: revokeStakeCronTime(),
135
- fn: checkStakeRevokeTx,
136
+ fn: perTenant('customer.stake.revoked', checkStakeRevokeTx),
136
137
  options: { runOnInit: false },
137
138
  },
138
139
  {
@@ -141,7 +142,7 @@ function init() {
141
142
  // webhook missed). See blocklets/core/api/src/integrations/iap-reconcile.ts.
142
143
  name: 'iap.reconcile',
143
144
  time: iapReconcileCronTime(),
144
- fn: () => runIapReconcile(),
145
+ fn: perTenant('iap.reconcile', () => runIapReconcile()),
145
146
  options: { runOnInit: false },
146
147
  },
147
148
  {
@@ -150,19 +151,19 @@ function init() {
150
151
  // See blocklets/core/api/src/crons/retry-pending-events.ts.
151
152
  name: 'event.retry',
152
153
  time: eventRetryCronTime(),
153
- fn: () => retryPendingEvents(),
154
+ fn: perTenant('event.retry', () => retryPendingEvents()),
154
155
  options: { runOnInit: false },
155
156
  },
156
157
  {
157
158
  name: 'payment.stat',
158
159
  time: paymentStatCronTime(),
159
- fn: () => createPaymentStat(),
160
+ fn: perTenant('payment.stat', () => createPaymentStat()),
160
161
  options: { runOnInit: false },
161
162
  },
162
163
  {
163
164
  name: 'payment.daily.report',
164
165
  time: overdueDetectionCronTime(),
165
- fn: () => createOverdueDetection(),
166
+ fn: perTenant('payment.daily.report', () => createOverdueDetection()),
166
167
  options: { runOnInit: false },
167
168
  },
168
169
  {
@@ -0,0 +1,82 @@
1
+ // Multi-tenant cron fan-out (S3 arc-payment-embed — Phase 7).
2
+ //
3
+ // Background crons that issue a top-level tenant-scoped model query
4
+ // (subscription schedules, reconciliation, payment stat, overdue detection,
5
+ // stake-revoke, expired-session cleanup, stripe sync, event retry) have NO
6
+ // request to carry a tenant. In multi mode `getInstanceDid()` throws
7
+ // TENANT_CONTEXT_MISSING and the whole pass does nothing. Single mode is
8
+ // unaffected — the default tenant auto-resolves.
9
+ //
10
+ // Tenant enumeration is SELF-CONTAINED (decision 2026-06-13): payment-core owns
11
+ // its own scope, so we iterate the tenants that have payment configuration in
12
+ // OUR OWN store (`provisionTenant` seeds a PaymentMethod row per tenant) rather
13
+ // than asking the host for a registry. A tenant with no payment rows has
14
+ // nothing for these crons to do, and this fits a lazily-provisioned host
15
+ // (arc-node) where a tenant appears in payment.db only after its first request.
16
+ //
17
+ // Queue-starter crons (startXQueue) are NOT wrapped: each persisted job already
18
+ // carries its own instance_did and runs inside withTenant(job.tenant) via the
19
+ // queue runtime (libs/queue runJobWithTenant). Wrapping them would re-scan and
20
+ // double-process.
21
+
22
+ import { withTenant } from '../libs/context';
23
+ import logger from '../libs/logger';
24
+ import { getTenantMode } from '../libs/tenant';
25
+ import { systemFindAll } from '../store/scoped';
26
+
27
+ /**
28
+ * Tenants that have payment configuration in payment-core's own store. Reads
29
+ * across tenants (systemFindAll runs inside runAsSystem so the TenantModel base
30
+ * does not re-scope to a — here absent — active tenant). DISTINCT is done in JS
31
+ * to stay compatible with both real Sequelize and the worker's sequelize-d1
32
+ * shim. PaymentMethod is the canonical "provisioned tenant" marker; a tenant
33
+ * that has any payment data necessarily has a payment method.
34
+ */
35
+ export async function listProvisionedTenants(): Promise<string[]> {
36
+ // eslint-disable-next-line global-require
37
+ const { PaymentMethod } = require('../store/models');
38
+ // raw:true returns plain rows, not Model instances — cast through unknown.
39
+ const rows = (await systemFindAll(PaymentMethod, {
40
+ attributes: ['instance_did'],
41
+ raw: true,
42
+ })) as unknown as Array<{ instance_did?: string | null }>;
43
+ const dids = new Set<string>();
44
+ for (const row of rows) {
45
+ if (row?.instance_did) dids.add(row.instance_did);
46
+ }
47
+ return [...dids];
48
+ }
49
+
50
+ /**
51
+ * Wrap a tenant-scoped cron fn so it runs once per provisioned tenant inside
52
+ * `withTenant`. Single mode runs the fn as-is (the default tenant auto-resolves
53
+ * in getInstanceDid). Multi mode enumerates provisioned tenants and runs the fn
54
+ * per tenant; per-tenant errors are ISOLATED (caught + logged) so one tenant's
55
+ * failure never aborts the rest of the pass.
56
+ *
57
+ * @param listTenants enumeration seam (defaults to listProvisionedTenants;
58
+ * injected in tests so the fan-out logic is exercised without a DB).
59
+ */
60
+ export function perTenant(
61
+ name: string,
62
+ fn: () => Promise<unknown> | unknown,
63
+ listTenants: () => Promise<string[]> = listProvisionedTenants
64
+ ): () => Promise<void> {
65
+ return async () => {
66
+ if (getTenantMode() === 'single') {
67
+ await fn();
68
+ return;
69
+ }
70
+ const dids = await listTenants();
71
+ if (dids.length === 0) return;
72
+ logger.info('cron.tenant.fanout', { cron: name, tenantCount: dids.length });
73
+ for (const instanceDid of dids) {
74
+ // async callback so a SYNCHRONOUS throw from fn surfaces as a rejection
75
+ // (and stays inside the tenant's ALS scope) — then .catch isolates it.
76
+ // eslint-disable-next-line no-await-in-loop, require-await -- sequential per-tenant pass; async wraps sync throws
77
+ await withTenant(instanceDid, async () => fn()).catch((error: unknown) =>
78
+ logger.error('cron tenant pass failed', { cron: name, instanceDid, error })
79
+ );
80
+ }
81
+ };
82
+ }
@@ -0,0 +1,33 @@
1
+ // Node host adapter — the AUTH_SERVICE-backed DID-Connect runtime for the
2
+ // arc-node embedded host. The node analog of cloudflare/did-connect-runtime.ts:
3
+ // the shared @arcblock/did-connect-js runtime whose signing wallet/appInfo are
4
+ // resolved per-tenant via resolveTenantIdentity (getInstanceAppIdentity), NOT a
5
+ // fixed isolate key.
6
+ //
7
+ // Without this, a bare host falls back to createBlockletServerDidConnectRuntime,
8
+ // whose WalletAuthenticator is constructed with `makeWallet()` (env BLOCKLET_APP_SK)
9
+ // — which arc never sets — so the first DID-Connect sign (e.g. /api/did/payment/token
10
+ // → generateSession) throws "Missing public key for SK wallet: BLOCKLET_APP_PK".
11
+ //
12
+ // Differences from the CF runtime are node-only:
13
+ // - txEncoder = the @ocap/client/encode CBOR encoder (same one the
14
+ // blocklet-server runtime passes), resolved from the host's node_modules.
15
+ // - tokenStorage omitted → buildTokenStorage's file-backed nedb default
16
+ // (os.tmpdir on a bare host; DID-Connect session tokens are ephemeral).
17
+ // - chainInfo omitted → the 14 payment DID handlers resolve it per-payment in
18
+ // onConnect, exactly as the blocklet-server runtime (which passes none).
19
+ import type { DidConnectRuntime, DidConnectTokenStorage } from '../libs/auth';
20
+ import { createDidConnectJsRuntime } from '../libs/did-connect/runtime-did-connect-js';
21
+
22
+ export function createNodeDidConnectRuntime(opts?: {
23
+ tokenStorage?: DidConnectTokenStorage;
24
+ timeout?: number;
25
+ }): DidConnectRuntime {
26
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
27
+ const { createTxEncoder } = require('@ocap/client/encode');
28
+ return createDidConnectJsRuntime({
29
+ tokenStorage: opts?.tokenStorage,
30
+ txEncoder: createTxEncoder(),
31
+ timeout: opts?.timeout ?? 30000,
32
+ });
33
+ }
@@ -0,0 +1,68 @@
1
+ // P3b (README D3 / F3) — clean node static + SPA handler for embedded hosts (arc).
2
+ //
3
+ // Unlike `attachNodeStatic` (serve-static.ts), this is NOT blocklet-server bound:
4
+ // - no isProduction gate, no blockletAppDir (host passes an explicit webRoot)
5
+ // - does NOT reuse the @blocklet/sdk-bound `fallback` middleware, which injects
6
+ // getBlockletJs / theme server-side. This handler serves index.html VERBATIM
7
+ // — the window.blocklet bootstrap is already baked in at BUILD time (P2), so
8
+ // there is ZERO server-side injection (T3b.3).
9
+ //
10
+ // Wired via the host `staticHandler` slot (P3a / service.ts:81), AFTER the
11
+ // api/connect routes. Order (T3b.1): SPA fallback FIRST (html GET/HEAD that is
12
+ // not a RESOURCE_PATTERN asset → index.html), serveStatic AFTER (real asset
13
+ // files; a miss falls through to 404, never index.html — T3b.2).
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+
17
+ import type { Hono } from 'hono';
18
+ // eslint-disable-next-line import/no-extraneous-dependencies
19
+ import { RESOURCE_PATTERN } from '@blocklet/constant';
20
+
21
+ function acceptsHtml(accept: string): boolean {
22
+ if (!accept) return true;
23
+ return accept.includes('text/html') || accept.includes('application/xhtml+xml') || accept.includes('*/*');
24
+ }
25
+
26
+ /**
27
+ * Build a host-injectable static/SPA handler over `webRoot` (the arc webRoot =
28
+ * `@arcblock/payment-service/web`). Returns a `(app) => void` for the
29
+ * staticHandler slot. Throws at construction if webRoot has no index.html
30
+ * (fail fast — a misconfigured webRoot must not silently 404 every navigation).
31
+ */
32
+ export function createNodeStaticHandler(webRoot: string): (app: Hono) => void {
33
+ const indexPath = path.join(webRoot, 'index.html');
34
+ if (!fs.existsSync(indexPath)) {
35
+ throw new Error(`createNodeStaticHandler: webRoot has no index.html: ${indexPath}`);
36
+ }
37
+ // index.html is an immutable build artifact for the process lifetime — read once.
38
+ let cachedIndex: string | null = null;
39
+
40
+ return (app: Hono) => {
41
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
42
+ const { serveStatic } = require('@hono/node-server/serve-static');
43
+
44
+ // SPA fallback FIRST — html navigations (not assets) get index.html VERBATIM.
45
+ app.use('*', async (c, next) => {
46
+ const method = c.req.method.toUpperCase();
47
+ if (
48
+ (method !== 'GET' && method !== 'HEAD') ||
49
+ !acceptsHtml(c.req.header('accept') || '') ||
50
+ RESOURCE_PATTERN.test(c.req.path)
51
+ ) {
52
+ return next();
53
+ }
54
+ if (cachedIndex == null) cachedIndex = fs.readFileSync(indexPath, 'utf8');
55
+ return c.html(cachedIndex);
56
+ });
57
+
58
+ // Real asset files. Pass the ABSOLUTE webRoot: serveStatic does
59
+ // `join(root, filename)` + existsSync, so an absolute root resolves the file
60
+ // regardless of process.cwd(). (attachNodeStatic mapped to a cwd-relative
61
+ // path; that breaks in an embedded host whose cwd is not the app dir — e.g.
62
+ // the arc daemon, whose cwd ≠ the node_modules webRoot.) A miss falls
63
+ // through (→ 404).
64
+ app.use('*', serveStatic({ root: webRoot }));
65
+ };
66
+ }
67
+
68
+ export default createNodeStaticHandler;
@@ -0,0 +1,41 @@
1
+ // Node host (blocklet-server) static + SPA serving.
2
+ //
3
+ // S3-CF Phase 1 inversion ①: this is the node-only static/SPA shell that used to
4
+ // live INSIDE buildHonoApp (service.ts). It is moved here so the runtime-neutral
5
+ // hono app carries ZERO node:fs / @hono/node-server — that is what let `http.fetch`
6
+ // converge to a single surface shared by node, CF, and the standalone worker.
7
+ //
8
+ // The blocklet-server entry (bootstrap.ts) injects this as the `staticHandler`
9
+ // slot. It is a verbatim move of the old block (serveStatic + the node:fs
10
+ // fallback), still production-gated, so blocklet-server behavior is unchanged. The
11
+ // CF/standalone worker serves assets via env.ASSETS and injects nothing here, so
12
+ // this module is never reached by the worker bundle.
13
+ import path from 'path';
14
+
15
+ import type { Hono } from 'hono';
16
+
17
+ import { isProduction, blockletAppDir } from '../libs/env';
18
+
19
+ /**
20
+ * Wire production static + SPA fallback onto the full node hono app. No-op outside
21
+ * production (identical to the old `if (isProduction())` gate in buildHonoApp).
22
+ *
23
+ * `fallback` runs first and skips asset paths (RESOURCE_PATTERN) + non-html, so
24
+ * real files fall through to serveStatic and html navigations get the injected
25
+ * index.html. Both modules are required lazily so this file's import stays cheap.
26
+ */
27
+ export function attachNodeStatic(app: Hono): void {
28
+ if (!isProduction()) return;
29
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
30
+ const { serveStatic } = require('@hono/node-server/serve-static');
31
+ // eslint-disable-next-line global-require
32
+ const { fallback } = require('../middlewares/hono/fallback');
33
+ const staticDir = path.resolve(blockletAppDir()!, 'dist');
34
+ // serveStatic resolves `root` relative to process.cwd(); map the absolute app
35
+ // dist dir back to a cwd-relative path so it resolves to the same place.
36
+ const staticRoot = path.relative(process.cwd(), staticDir) || '.';
37
+ app.use('*', fallback('index.html', { root: staticDir })); // injected index.html for html GET (skips assets)
38
+ app.use('*', serveStatic({ root: staticRoot })); // real asset files
39
+ }
40
+
41
+ export default attachNodeStatic;
@@ -1,8 +1,10 @@
1
+ import os from 'os';
1
2
  import path from 'path';
2
3
 
3
4
  import type { LiteralUnion } from 'type-fest';
4
5
  import type { WalletObject } from '@ocap/wallet';
5
6
  import env, { blockletAppId } from './env';
7
+ import { getIdentityDriver } from './drivers';
6
8
 
7
9
  import logger from './logger';
8
10
 
@@ -91,42 +93,179 @@ function makeWallet(...args: any[]): WalletObject {
91
93
  return getWallet(...args);
92
94
  }
93
95
 
94
- export const wallet: WalletObject = lazyProxy(() => makeWallet());
95
- export const ethWallet: WalletObject = lazyProxy(() => makeWallet('ethereum'));
96
+ // The blocklet-server business wallets — env-derived (BLOCKLET_APP_SK), lazy as
97
+ // before. They are the FALLBACK when the active IdentityDriver provides no
98
+ // getBusinessWallet (blocklet-server / tests); the @blocklet/sdk getWallet alone
99
+ // handles the remote-sign / delegation / migration cases a bare appSk cannot.
100
+ const envWallet: WalletObject = lazyProxy(() => makeWallet());
101
+ const envEthWallet: WalletObject = lazyProxy(() => makeWallet('ethereum'));
96
102
 
97
- // Workaround for migrated blocklets where `BLOCKLET_APP_SK` is a rotating session
98
- // key whose derived address appId. Upstream @blocklet/sdk's WalletAuthenticator
99
- // passes `wallet: getWallet()` to did-connect-js, so outer.agentDid becomes that
100
- // rotating address. Meanwhile the federated cert (signed by master) sets
101
- // agentDid = `did:abt:${verifySite.appId}` (permanent), so the wallet-side strict
102
- // check `cert.agentDid === outer.agentDid` fails with "Agent did does not match
103
- // with certificate issuer."
103
+ // THE SINGLE SEAM (wallet-authenticator-dynamic.md §0 "差异收敛在 IdentityDriver"):
104
+ // the active business wallet is whatever the active IdentityDriver provides, else
105
+ // the env wallet. arc-node + CF inject a driver whose getBusinessWallet resolves the
106
+ // current request/job tenant's wallet from the warmed cache (fail-closed outside a
107
+ // warmed scope); blocklet-server's default driver provides none → env wallet,
108
+ // unchanged. No consumer branches on the runtime only on driver capability. The
109
+ // driver is consulted on EVERY access so two concurrent tenants never share a wallet.
110
+ function activeBusinessWallet(chain: 'arcblock' | 'ethereum'): WalletObject {
111
+ const driver = getIdentityDriver();
112
+ if (typeof driver.getBusinessWallet === 'function') {
113
+ return driver.getBusinessWallet(chain);
114
+ }
115
+ return chain === 'ethereum' ? envEthWallet : envWallet;
116
+ }
117
+
118
+ /** Per-access proxy onto the active tenant's business wallet (see activeBusinessWallet). */
119
+ function businessWalletProxy(chain: 'arcblock' | 'ethereum'): WalletObject {
120
+ return new Proxy({} as WalletObject, {
121
+ get(_t, prop) {
122
+ const w = activeBusinessWallet(chain) as any;
123
+ const value = w[prop];
124
+ if (typeof value !== 'function') return value;
125
+ if (value._isMockFunction) return value; // keep jest spies usable (parity with lazyProxy)
126
+ return value.bind(w);
127
+ },
128
+ set(_t, prop, value) {
129
+ (activeBusinessWallet(chain) as any)[prop] = value;
130
+ return true;
131
+ },
132
+ has(_t, prop) {
133
+ return prop in (activeBusinessWallet(chain) as any);
134
+ },
135
+ });
136
+ }
137
+
138
+ export const wallet: WalletObject = businessWalletProxy('arcblock');
139
+ export const ethWallet: WalletObject = businessWalletProxy('ethereum');
140
+
141
+ // S3-CF Phase 1 (DID convergence): the DID-Connect token storage is a host slot.
142
+ export interface DidConnectTokenStorage {
143
+ read(token: string): Promise<any> | any;
144
+ create(token: string, status?: string): Promise<any> | any;
145
+ update(token: string, updates: Record<string, any>): Promise<any> | any;
146
+ delete(token: string): Promise<any> | any;
147
+ [key: string]: any;
148
+ }
149
+
150
+ // S3-CF Phase 1 (DID convergence) — the DID-Connect RUNTIME is host-injectable.
104
151
  //
105
- // Force the authenticator to derive its signing wallet from BLOCKLET_APP_PK
106
- // (permanent app pk address = appId), aligning with the fix in
107
- // @blocklet/sdk PR #12810 that already corrected getDelegatee.
108
- export const authenticator: any = lazyProxy(() => {
109
- // eslint-disable-next-line global-require, import/no-extraneous-dependencies
110
- const { WalletAuthenticator } = require('@blocklet/sdk/lib/wallet-authenticator');
111
- return new WalletAuthenticator({ wallet: makeWallet(undefined, '', 'sk') });
112
- });
152
+ // The boundary is by HOST, not by node-vs-CF:
153
+ // - blocklet-server → the @blocklet/sdk WalletAuthenticator/WalletHandlers
154
+ // wrapper (autoConnect / notification relay /
155
+ // federated login). The ONLY runtime that uses SDK.
156
+ // - arc-node embedded + CF → the REAL @arcblock/did-connect-js stack, identity
157
+ // from AUTH_SERVICE.getInstanceAppIdentity, host-
158
+ // injected tokenStorage. They differ ONLY in the host
159
+ // adapter (storage/DB/event/waitUntil, CF chain
160
+ // config/txEncoder/timeout), NOT in SDK-vs-non-SDK.
161
+ // Unified = the host-injection + identity-resolution mechanism; SDK is one runtime
162
+ // implementation, not the node default. Every host injects via setDidConnectRuntime
163
+ // BEFORE `handlers` first materializes (lazyProxy → buildConnectRoutesHono).
164
+ export interface DidConnectRuntime {
165
+ /** Build the DID-Connect authenticator (signs sessions/certs). */
166
+ createAuthenticator(): any;
167
+ /** Wrap the authenticator + tokenStorage into the WalletHandlers used to attach routes. */
168
+ createHandlers(opts: { authenticator: any; tokenStorage: DidConnectTokenStorage }): any;
169
+ /** The DID-Connect token store; falls back to the blocklet-server nedb default when omitted. */
170
+ tokenStorage?: DidConnectTokenStorage;
171
+ }
113
172
 
114
- export const handlers: any = lazyProxy(() => {
173
+ let injectedRuntime: DidConnectRuntime | null = null;
174
+ let injectedTokenStorage: DidConnectTokenStorage | null = null;
175
+
176
+ /** Inject the full DID-Connect runtime (CF: real @arcblock/did-connect-js stack). */
177
+ export function setDidConnectRuntime(runtime: DidConnectRuntime | null): void {
178
+ injectedRuntime = runtime;
179
+ }
180
+
181
+ /** Inject only the DID-Connect token storage (storage slot; runtime keeps its default authenticator/handlers). */
182
+ export function setDidConnectTokenStorage(storage: DidConnectTokenStorage | null): void {
183
+ injectedTokenStorage = storage;
184
+ }
185
+
186
+ // The blocklet-server runtime — the ONLY runtime that uses the @blocklet/sdk
187
+ // wallet wrapper (autoConnect / notification relay / memberAppInfo / federated
188
+ // login / BlockletService). This is NOT "the node default": arc-node embedded and
189
+ // CF use the AUTH_SERVICE + @arcblock/did-connect-js runtime instead. The
190
+ // blocklet-server bootstrap injects this explicitly; it is also the legacy
191
+ // fallback when no runtime is injected (tests / a bare blocklet-server start).
192
+ //
193
+ // The signing wallet is derived from BLOCKLET_APP_PK (permanent app pk → address =
194
+ // appId) to fix the migrated-blocklet rotating-session-key cert mismatch
195
+ // ("Agent did does not match with certificate issuer", @blocklet/sdk PR #12810).
196
+ export function createBlockletServerDidConnectRuntime(): DidConnectRuntime {
197
+ return {
198
+ createAuthenticator() {
199
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
200
+ const { WalletAuthenticator } = require('@blocklet/sdk/lib/wallet-authenticator');
201
+ // @blocklet/sdk bundles @arcblock/did-connect-js@4.0.2, which made `txEncoder`
202
+ // mandatory for encoding *Tx prepareTx claims (scan-to-pay → TransferV3Tx) and
203
+ // dropped the old internal @ocap/client fallback (now only a devDependency; the
204
+ // shipped dist never requires it). The SDK's WalletAuthenticator wrapper does not
205
+ // inject one, so we pass the same CBOR encoder the CF/arc runtime uses
206
+ // (`createTxEncoder` from @ocap/client/encode). It passes through the wrapper's
207
+ // `...getAuthenticatorProps(options)` spread untouched.
208
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
209
+ const { createTxEncoder } = require('@ocap/client/encode');
210
+ return new WalletAuthenticator({ wallet: makeWallet(undefined, '', 'sk'), txEncoder: createTxEncoder() });
211
+ },
212
+ createHandlers({ authenticator: auth, tokenStorage }) {
213
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
214
+ const { WalletHandlers } = require('@blocklet/sdk/lib/wallet-handler');
215
+ return new WalletHandlers({ authenticator: auth, tokenStorage });
216
+ },
217
+ };
218
+ }
219
+
220
+ // Consume the injected runtime. When none is injected we fall back to the
221
+ // blocklet-server (SDK) runtime as a LEGACY compat for a bare blocklet-server /
222
+ // test start — NOT as a generic node default. CF and arc-node embedded hosts MUST
223
+ // inject their AUTH_SERVICE runtime; if a CF host ever reaches this fallback, the
224
+ // workerd @blocklet/sdk wallet-* shims are fail-fast (they throw on construct), so
225
+ // it can never silently register zero DID routes. (A spec also asserts the
226
+ // AUTH_SERVICE runtime never imports the @blocklet/sdk wallet modules.)
227
+ function activeRuntime(): DidConnectRuntime {
228
+ return injectedRuntime ?? createBlockletServerDidConnectRuntime();
229
+ }
230
+
231
+ function buildTokenStorage(): DidConnectTokenStorage {
232
+ // priority: runtime-provided store → explicit storage slot → blocklet-server
233
+ // nedb default (file-backed; the node blocklet-server host only).
234
+ if (injectedRuntime?.tokenStorage) return injectedRuntime.tokenStorage;
235
+ if (injectedTokenStorage) return injectedTokenStorage;
115
236
  // eslint-disable-next-line global-require, import/no-extraneous-dependencies
116
237
  const AuthStorage = require('@arcblock/did-connect-storage-nedb');
117
- // eslint-disable-next-line global-require, import/no-extraneous-dependencies
118
- const { WalletHandlers } = require('@blocklet/sdk/lib/wallet-handler');
119
- return new WalletHandlers({
120
- authenticator,
121
- tokenStorage: new AuthStorage({
122
- dbPath: path.join(env.dataDir, 'auth.db'),
123
- // @ts-ignore
124
- onload: console.warn,
125
- }),
238
+ return new AuthStorage({
239
+ // `env.dataDir` (the blocklet runtime data dir) is undefined in a bare
240
+ // embedded host like arc — `store/sequelize.ts` already guards its own
241
+ // use, but this DID-Connect token store was the one unguarded path and
242
+ // crashed `buildConnectRoutesHono` with `path.join(undefined, …)`. Fall
243
+ // back to the OS temp dir (DID-Connect session tokens are ephemeral; the
244
+ // blocklet server still gets its real dataDir natively).
245
+ dbPath: path.join(env.dataDir || os.tmpdir(), 'auth.db'),
246
+ // @ts-ignore
247
+ onload: console.warn,
126
248
  });
249
+ }
250
+
251
+ export const authenticator: any = lazyProxy(() => activeRuntime().createAuthenticator());
252
+
253
+ export const handlers: any = lazyProxy(() => {
254
+ const tokenStorage = buildTokenStorage();
255
+ return activeRuntime().createHandlers({ authenticator, tokenStorage });
127
256
  });
128
257
 
258
+ // The user directory (`blocklet.getUser` etc.). SAME single seam as the wallet:
259
+ // the active IdentityDriver's `directory()` if it provides one, else the real
260
+ // @blocklet/sdk BlockletService. arc-node injects a DID-echo directory (the real
261
+ // BlockletService can't construct without BLOCKLET_APP_ID); CF resolves the real
262
+ // BlockletService to its build-alias shim; blocklet-server gets the real one. No
263
+ // `isCfWorker`/runtime branch — only "does the driver provide a directory".
129
264
  export const blocklet: any = lazyProxy(() => {
265
+ const driver = getIdentityDriver();
266
+ if (typeof driver.directory === 'function') {
267
+ return driver.directory();
268
+ }
130
269
  ensureNotificationPatch();
131
270
  // eslint-disable-next-line global-require, import/no-extraneous-dependencies
132
271
  const { BlockletService } = require('@blocklet/sdk/service/auth');
@@ -63,6 +63,17 @@ class RequestContextManager {
63
63
  throw new TenantError(TENANT_CONTEXT_MISSING, 'tenant context is missing in multi-tenant mode');
64
64
  }
65
65
 
66
+ /**
67
+ * Non-throwing peek at the established tenant context — returns the stored
68
+ * instanceDid or undefined when no context is set (regardless of tenant mode).
69
+ * Used by the DID-Connect tenant-context middleware to decide whether the
70
+ * request is already scoped (e.g. the CF worker wrapped /api/* in withTenant) or
71
+ * needs its own Host→tenant resolution.
72
+ */
73
+ peekInstanceDid(): string | undefined {
74
+ return this.storage.getStore()?.instanceDid;
75
+ }
76
+
66
77
  /**
67
78
  * Run fn as a system operation: TenantModel scoping is bypassed for the span
68
79
  * of fn so legitimate cross-tenant reads can load rows regardless of tenant.