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
@@ -0,0 +1,88 @@
1
+ // S3-CF (DID convergence) — the REAL @arcblock/did-connect-js runtime, SHARED by
2
+ // the non-blocklet-server hosts (CF + arc-node embedded).
3
+ //
4
+ // Both build the same authenticator/handlers; they differ ONLY in the host adapter
5
+ // passed in here (tokenStorage, chain config, txEncoder, timeout). The signing
6
+ // wallet + appInfo are FUNCTION-VALUED and resolved per-request, per-tenant through
7
+ // the shared `resolveTenantIdentity` (AUTH_SERVICE.getInstanceAppIdentity) — never
8
+ // a fixed isolate key — so one isolate serves every tenant with its own appSk.
9
+ //
10
+ // @arcblock/did-connect-js supports function-valued `wallet` (base.js: typeof
11
+ // wallet === 'function' → resolved via getWalletInfo with a timeout before each
12
+ // sign), so per-tenant signing is safe.
13
+
14
+ import type { DidConnectRuntime, DidConnectTokenStorage } from '../auth';
15
+ import { resolveTenantIdentity } from './tenant-identity';
16
+
17
+ /** Chain config for the DID-Connect authenticator (id/type/host), or a resolver. */
18
+ export type ChainInfoOption =
19
+ | { id: string; type: string; host: string }
20
+ | ((params: any) => { id: string; type: string; host: string } | Promise<{ id: string; type: string; host: string }>);
21
+
22
+ export interface DidConnectJsRuntimeOptions {
23
+ /** Host-injected token store (CF: D1 tenant-aware adapter). Omit to let
24
+ * buildTokenStorage fall back to the file-backed nedb default (arc-node). */
25
+ tokenStorage?: DidConnectTokenStorage;
26
+ /** Chain config for `signature`/`prepareTx` claims (omit on chain-less hosts). */
27
+ chainInfo?: ChainInfoOption;
28
+ /** On-chain tx encoder (CF: @ocap/client/encode CBOR encoder). Omit to disable tx claims. */
29
+ txEncoder?: (params: { type: string; data: any; wallet: any; chainHost: string }) => Promise<Buffer>;
30
+ /** Authenticator timeout (CF chain RPC can exceed the 8s default; worker used 30s). */
31
+ timeout?: number;
32
+ /** Branding fallback when an instance's getInstanceAppIdentity omits appInfo. */
33
+ defaultAppInfo?: { name?: string; description?: string; icon?: string };
34
+ }
35
+
36
+ const DEFAULT_APP_INFO = {
37
+ name: 'Payment Kit',
38
+ description: 'Payment Kit',
39
+ icon: 'https://www.arcblock.io/favicon.ico',
40
+ };
41
+
42
+ /**
43
+ * Build a DID-Connect runtime backed by the real @arcblock/did-connect-js stack.
44
+ * Used by `createCloudflareDidConnectRuntime` (CF) and the arc-node embedded host.
45
+ */
46
+ export function createDidConnectJsRuntime(opts: DidConnectJsRuntimeOptions): DidConnectRuntime {
47
+ const fallbackAppInfo = { ...DEFAULT_APP_INFO, ...(opts.defaultAppInfo ?? {}) };
48
+
49
+ return {
50
+ tokenStorage: opts.tokenStorage,
51
+
52
+ createAuthenticator() {
53
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
54
+ const { WalletAuthenticator } = require('@arcblock/did-connect-js');
55
+
56
+ const config: Record<string, any> = {
57
+ // Function-valued, per-tenant: resolve the signing wallet from the host
58
+ // IdentityDriver.getInstanceAppIdentity each time the authenticator signs.
59
+ // `.toJSON()` matches the standalone worker (the SDK reconstructs the full
60
+ // wallet — incl. sk — via fromJSON before signing).
61
+ wallet: async () => {
62
+ const { wallet } = await resolveTenantIdentity();
63
+ return wallet.toJSON();
64
+ },
65
+ appInfo: async ({ baseUrl }: { baseUrl?: string } = {}) => {
66
+ const { appInfo } = await resolveTenantIdentity();
67
+ return {
68
+ name: appInfo.name || fallbackAppInfo.name,
69
+ description: appInfo.description || fallbackAppInfo.description,
70
+ icon: appInfo.icon || fallbackAppInfo.icon,
71
+ link: appInfo.link || baseUrl,
72
+ };
73
+ },
74
+ timeout: opts.timeout ?? 30000,
75
+ };
76
+ if (opts.chainInfo) config.chainInfo = opts.chainInfo;
77
+ if (opts.txEncoder) config.txEncoder = opts.txEncoder;
78
+
79
+ return new WalletAuthenticator(config);
80
+ },
81
+
82
+ createHandlers({ authenticator, tokenStorage }) {
83
+ // eslint-disable-next-line global-require, import/no-extraneous-dependencies
84
+ const { WalletHandlers } = require('@arcblock/did-connect-js');
85
+ return new WalletHandlers({ authenticator, tokenStorage });
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,221 @@
1
+ // S3-CF (DID convergence) — the SHARED per-tenant DID-Connect identity resolver.
2
+ //
3
+ // Both non-blocklet-server runtimes (CF + arc-node embedded) build their REAL
4
+ // `@arcblock/did-connect-js` WalletAuthenticator from a per-tenant signing wallet
5
+ // resolved HERE — never from a fixed isolate-level APP_SK. The signing identity
6
+ // comes from the host IdentityDriver's `getInstanceAppIdentity(instanceDid)`
7
+ // (AUTH_SERVICE-backed), which already resolves the arc run-mode (instance app:sk
8
+ // → instance identity; else auth-service root; else fail-closed), so payment-core
9
+ // never reimplements "instance ?? root" and never reads connect-service internals.
10
+ //
11
+ // The wallet is derived from the returned `appSk` with @ocap/wallet (a stable
12
+ // public API) — the same ROLE_APPLICATION/ED25519/SHA3 wallet type the standalone
13
+ // CF worker used. The blocklet-server runtime does NOT use this resolver (it keeps
14
+ // the @blocklet/sdk wallet wrapper); only the AUTH_SERVICE/did-connect-js runtimes do.
15
+ //
16
+ // eslint-disable-next-line import/no-extraneous-dependencies
17
+ import * as Mcrypto from '@ocap/mcrypto';
18
+ // eslint-disable-next-line import/no-extraneous-dependencies
19
+ import { fromSecretKey, WalletType } from '@ocap/wallet';
20
+ import type { WalletObject } from '@ocap/wallet';
21
+
22
+ import { getInstanceDid } from '../context';
23
+ import { getIdentityDriver, type InstanceAppInfo, type BlockletDirectory } from '../drivers';
24
+ import logger from '../logger';
25
+
26
+ const walletType = {
27
+ role: Mcrypto.types.RoleType.ROLE_APPLICATION,
28
+ pk: Mcrypto.types.KeyType.ED25519,
29
+ hash: Mcrypto.types.HashType.SHA3,
30
+ };
31
+
32
+ // The ethereum business wallet is derived from the SAME instance appSk as the
33
+ // arcblock wallet, mirroring @blocklet/sdk getWallet('ethereum', appSk): the
34
+ // secp256k1/keccak WalletType('ethereum') over the first 66 chars of the appSk.
35
+ // Using @ocap/wallet directly (not the SDK) keeps this CF-worker-safe — the CF
36
+ // build shims the SDK wallet-* modules, but @ocap/wallet is a stable public API.
37
+ const ethWalletType = WalletType('ethereum');
38
+
39
+ export interface ResolvedTenantIdentity {
40
+ instanceDid: string;
41
+ /** App signing wallet derived from the instance appSk (the DID-Connect signer). */
42
+ wallet: WalletObject;
43
+ /** Ethereum business wallet derived from the same appSk (EVM chains / refunds). */
44
+ ethWallet: WalletObject;
45
+ /** Permanent app signing wallet (when keys rotated); falls back to `wallet`. */
46
+ permanentWallet: WalletObject;
47
+ /** Branding for DID-Connect prompts (name/description/icon/link). */
48
+ appInfo: InstanceAppInfo;
49
+ }
50
+
51
+ // Per-instance TTL cache of the DERIVED identity (Layer 2 of
52
+ // wallet-authenticator-dynamic.md). The business wallet proxies (libs/auth.ts)
53
+ // read it SYNCHRONOUSLY via getCachedTenantIdentity, so resolveTenantIdentity
54
+ // (async — it RPCs getInstanceAppIdentity) must run first per request/job (the
55
+ // HTTP contextMiddleware and queue runJobWithTenant warm it). TTL bounds key-
56
+ // rotation staleness; for a single-tenant deployment this holds exactly one entry.
57
+ // A hard size cap (insertion-order eviction via Map iteration) keeps a host with
58
+ // many tenants from growing the map unbounded — expired entries are pruned on
59
+ // read but never proactively, so the cap is the only growth bound.
60
+ const IDENTITY_TTL_MS = 5 * 60 * 1000;
61
+ const IDENTITY_CACHE_MAX = 512;
62
+ const identityCache = new Map<string, { value: ResolvedTenantIdentity; expiry: number }>();
63
+
64
+ function cacheIdentity(instanceDid: string, value: ResolvedTenantIdentity): void {
65
+ identityCache.set(instanceDid, { value, expiry: Date.now() + IDENTITY_TTL_MS });
66
+ // evict the oldest entry (Map preserves insertion order) once over the cap
67
+ if (identityCache.size > IDENTITY_CACHE_MAX) {
68
+ const oldest = identityCache.keys().next().value;
69
+ if (oldest !== undefined) identityCache.delete(oldest);
70
+ }
71
+ }
72
+
73
+ /** Drop a tenant's cached identity (or all) — key rotation / test isolation. */
74
+ export function clearTenantIdentityCache(instanceDid?: string): void {
75
+ if (instanceDid) identityCache.delete(instanceDid);
76
+ else identityCache.clear();
77
+ }
78
+
79
+ /**
80
+ * Whether the active host runtime resolves app identity dynamically per tenant
81
+ * (arc-node embedded + CF, via AUTH_SERVICE.getInstanceAppIdentity) vs the
82
+ * blocklet-server runtime, which keeps the @blocklet/sdk env wallet. The
83
+ * business wallet proxies branch on this so blocklet-server stays byte-for-byte
84
+ * unchanged (it never touches the resolver / cache).
85
+ */
86
+ export function hasDynamicIdentity(): boolean {
87
+ return typeof getIdentityDriver().getInstanceAppIdentity === 'function';
88
+ }
89
+
90
+ /**
91
+ * Resolve the current (or given) tenant's DID-Connect signing identity. Throws a
92
+ * clear error — never silently falls back to a fixed key — when:
93
+ * - the active IdentityDriver does not implement getInstanceAppIdentity (a
94
+ * non-SDK runtime reached an SDK-only driver), or
95
+ * - the instance has no app signing key (fail-closed; AUTH_SERVICE 4.0.3 itself
96
+ * fails closed when neither instance app:sk nor root APP_SK exists).
97
+ */
98
+ export async function resolveTenantIdentity(instanceDidArg?: string): Promise<ResolvedTenantIdentity> {
99
+ const instanceDid = instanceDidArg ?? getInstanceDid();
100
+
101
+ const cached = identityCache.get(instanceDid);
102
+ if (cached && cached.expiry > Date.now()) return cached.value;
103
+
104
+ const driver = getIdentityDriver();
105
+ if (typeof driver.getInstanceAppIdentity !== 'function') {
106
+ throw new Error(
107
+ 'resolveTenantIdentity: the active IdentityDriver does not implement getInstanceAppIdentity — ' +
108
+ 'a non-blocklet-server DID-Connect runtime requires an AUTH_SERVICE-backed identity driver'
109
+ );
110
+ }
111
+ const identity = await driver.getInstanceAppIdentity(instanceDid);
112
+ if (!identity || !identity.appSk) {
113
+ throw new Error(`resolveTenantIdentity: no app signing key for instance "${instanceDid}" (fail-closed)`);
114
+ }
115
+ // A too-short appSk would silently yield a WRONG ethereum address (slice below)
116
+ // and thus a wrong receiving/signing wallet — fail closed instead. A real
117
+ // ED25519 app sk is 128 hex chars; the eth slice needs at least 66.
118
+ if (identity.appSk.length < 66) {
119
+ throw new Error(
120
+ `resolveTenantIdentity: appSk for instance "${instanceDid}" is too short ` +
121
+ `(${identity.appSk.length} chars) to derive a wallet (fail-closed)`
122
+ );
123
+ }
124
+ const wallet = fromSecretKey(identity.appSk, walletType) as WalletObject;
125
+ // Mirror @blocklet/sdk getWallet('ethereum', appSk): secp256k1 over appSk[0..66].
126
+ const ethWallet = fromSecretKey(identity.appSk.slice(0, 66), ethWalletType) as WalletObject;
127
+ const permanentWallet = identity.appPsk ? (fromSecretKey(identity.appPsk, walletType) as WalletObject) : wallet;
128
+ const value: ResolvedTenantIdentity = {
129
+ instanceDid,
130
+ wallet,
131
+ ethWallet,
132
+ permanentWallet,
133
+ appInfo: identity.appInfo ?? {},
134
+ };
135
+ cacheIdentity(instanceDid, value);
136
+ return value;
137
+ }
138
+
139
+ /**
140
+ * SYNCHRONOUS accessor for the current (or given) tenant's resolved identity —
141
+ * the business wallet proxies (libs/auth.ts) call this on every `wallet.address`
142
+ * / `wallet.sign(...)`. It NEVER resolves (no RPC): it reads the cache that
143
+ * `warmTenantIdentity` populated earlier in the request/job. A miss means the
144
+ * identity was never warmed (fail-closed): throw rather than silently fall back
145
+ * to a wrong/default key.
146
+ */
147
+ export function getCachedTenantIdentity(instanceDidArg?: string): ResolvedTenantIdentity {
148
+ const instanceDid = instanceDidArg ?? getInstanceDid();
149
+ const cached = identityCache.get(instanceDid);
150
+ if (!cached || cached.expiry <= Date.now()) {
151
+ throw new Error(
152
+ `tenant identity for "${instanceDid}" is not resolved (fail-closed) — ` +
153
+ 'warmTenantIdentity must run in the request/job scope before any wallet access'
154
+ );
155
+ }
156
+ return cached.value;
157
+ }
158
+
159
+ /**
160
+ * Best-effort warm of the current tenant's identity into the cache so later
161
+ * SYNCHRONOUS wallet access resolves. No-op for the blocklet-server runtime
162
+ * (env wallet, no dynamic driver). Errors are swallowed (logged): a request that
163
+ * never touches a wallet must not be blocked by an identity hiccup, while one
164
+ * that DOES touch it still fails-closed at getCachedTenantIdentity.
165
+ */
166
+ export async function warmTenantIdentity(instanceDidArg?: string): Promise<void> {
167
+ if (!hasDynamicIdentity()) return;
168
+ try {
169
+ await resolveTenantIdentity(instanceDidArg);
170
+ } catch (err: unknown) {
171
+ logger.warn('[tenant-identity] warm failed — wallet access will fail-closed', {
172
+ error: err instanceof Error ? err.message : String(err),
173
+ });
174
+ }
175
+ }
176
+
177
+ // The host user directory for the embedded runtime. In the DID-Connect world the
178
+ // user's DID IS the wallet DID, so getUser echoes it as a connected wallet account
179
+ // (so getWalletDid(user) resolves to the user's own DID); the rest are no-ops. This
180
+ // is the shared semantics the CF build-alias shim also implements
181
+ // (cloudflare/shims/blocklet-sdk/auth-service.ts).
182
+ const EMBEDDED_DIRECTORY: BlockletDirectory = {
183
+ getUser(did: string) {
184
+ if (!did) return { user: null };
185
+ return {
186
+ user: { did, fullName: did, email: '', phone: '', remark: '', connectedAccounts: [{ provider: 'wallet', did }] },
187
+ };
188
+ },
189
+ getUsers() {
190
+ return { users: [] };
191
+ },
192
+ getVault() {
193
+ return null;
194
+ },
195
+ getBlocklet() {
196
+ return { id: '', site: { id: '' } };
197
+ },
198
+ };
199
+
200
+ /**
201
+ * The embedded host identity services — the AUTH_SERVICE-backed implementations of
202
+ * the optional IdentityDriver methods, derived entirely from getInstanceAppIdentity
203
+ * (so a host need not reimplement them). arc-node spreads BOTH into its identity
204
+ * driver; CF spreads `getBusinessWallet` only (it keeps its build-alias directory
205
+ * shim). Stateless — every call reads the active driver via resolveTenantIdentity /
206
+ * getCachedTenantIdentity, so one instance serves every tenant.
207
+ */
208
+ export function createEmbeddedIdentityServices(): {
209
+ getBusinessWallet(chain: 'arcblock' | 'ethereum'): WalletObject;
210
+ directory(): BlockletDirectory;
211
+ } {
212
+ return {
213
+ getBusinessWallet(chain: 'arcblock' | 'ethereum'): WalletObject {
214
+ const identity = getCachedTenantIdentity();
215
+ return chain === 'ethereum' ? identity.ethWallet : identity.wallet;
216
+ },
217
+ directory(): BlockletDirectory {
218
+ return EMBEDDED_DIRECTORY;
219
+ },
220
+ };
221
+ }
@@ -13,8 +13,34 @@
13
13
  // unchanged; multi-tenant hosts inject a driver that maps Host -> instanceDid
14
14
  // and returns null for unknown hosts (the middleware then fails closed 4xx).
15
15
 
16
+ import type { WalletObject } from '@ocap/wallet';
17
+
16
18
  import { getDefaultInstanceDid, getTenantMode, TenantError, TENANT_HOST_UNRESOLVED } from '../tenant';
17
19
 
20
+ /** Branding/profile used by DID-Connect prompts (mirrors did-connect-service InstanceAppInfoDTO). */
21
+ export interface InstanceAppInfo {
22
+ name?: string;
23
+ description?: string;
24
+ icon?: string;
25
+ link?: string;
26
+ }
27
+
28
+ /**
29
+ * Per-instance app signing identity used to build the DID-Connect authenticator
30
+ * (mirrors did-connect-service@4.0.3 InstanceAppIdentityDTO). The AUTH_SERVICE
31
+ * RPC `getInstanceAppIdentity(instanceDid)` already resolves the arc run-mode
32
+ * (instance `app:sk` → instance identity; else auth-service root `APP_SK/APP_PSK`;
33
+ * else fail-closed), so the payment runtime never reimplements "instance ?? root".
34
+ */
35
+ export interface InstanceAppIdentity {
36
+ /** Current app signing key for this instance. */
37
+ appSk: string;
38
+ /** Permanent app signing key, present when the instance has rotated keys. */
39
+ appPsk?: string;
40
+ /** Branding/profile for DID-Connect prompts. */
41
+ appInfo?: InstanceAppInfo;
42
+ }
43
+
18
44
  export interface IdentityDriver {
19
45
  /**
20
46
  * Resolve a request Host to its tenant instanceDid, or null/undefined when
@@ -28,6 +54,41 @@ export interface IdentityDriver {
28
54
  * driver uses the process key and never calls it.
29
55
  */
30
56
  getAppEk?(instanceDid: string): Promise<string> | string;
57
+ /**
58
+ * S3-CF (DID convergence): the per-instance app signing identity backing the
59
+ * DID-Connect authenticator. The AUTH_SERVICE-backed driver (CF + arc-node
60
+ * embedded) implements this via `AUTH_SERVICE.getInstanceAppIdentity`; the
61
+ * blocklet-server runtime uses the @blocklet/sdk wallet wrapper and never calls
62
+ * it. Optional on the contract for that reason — `resolveTenantIdentity` throws
63
+ * a clear error if a non-SDK runtime reaches a driver that lacks it.
64
+ */
65
+ getInstanceAppIdentity?(instanceDid: string): Promise<InstanceAppIdentity> | InstanceAppIdentity;
66
+ /**
67
+ * The current tenant's business chain wallet (the receiving address + on-chain
68
+ * tx/refund/payout signer). SYNCHRONOUS — `wallet.address` is read inline on hot
69
+ * paths — so it reads the warmed per-tenant cache (see warmTenantIdentity), never
70
+ * an RPC. Present on AUTH_SERVICE-backed drivers (arc-node + CF, derived from
71
+ * getInstanceAppIdentity); ABSENT on the blocklet-server default, where auth.ts
72
+ * falls back to the @blocklet/sdk env wallet (which alone handles the remote-sign
73
+ * / delegation / migration cases a bare appSk cannot). This is the single seam:
74
+ * a consumer never branches on the runtime, only on "does the driver provide it".
75
+ */
76
+ getBusinessWallet?(chain: 'arcblock' | 'ethereum'): WalletObject;
77
+ /**
78
+ * The host user directory (`blocklet.getUser` etc.). Present on the arc-node
79
+ * embedded driver (a DID-echo: the DID-Connect DID IS the wallet DID); ABSENT on
80
+ * blocklet-server (real BlockletService) and on CF (its build-alias shim). auth.ts
81
+ * uses `driver.directory?.() ?? <real BlockletService>` — same one-seam pattern.
82
+ */
83
+ directory?(): BlockletDirectory;
84
+ }
85
+
86
+ /** The subset of @blocklet/sdk BlockletService the payment runtime calls. */
87
+ export interface BlockletDirectory {
88
+ getUser(did: string, opts?: any): Promise<{ user: any }> | { user: any };
89
+ getUsers(params?: any): Promise<{ users: any[] }> | { users: any[] };
90
+ getVault(): Promise<any> | any;
91
+ getBlocklet(): Promise<any> | any;
31
92
  }
32
93
 
33
94
  /**
@@ -33,7 +33,7 @@ export function applyPaymentCoreMigrations(driver: DbDriverForMigrations): Promi
33
33
  return applySql(driver, paymentCoreSqlMigrations);
34
34
  }
35
35
 
36
- export type { IdentityDriver } from './identity';
36
+ export type { IdentityDriver, InstanceAppIdentity, InstanceAppInfo, BlockletDirectory } from './identity';
37
37
  export { createDefaultIdentityDriver, setIdentityDriver, getIdentityDriver } from './identity';
38
38
 
39
39
  export type { SecretsDriver } from './secrets';
@@ -37,7 +37,17 @@ export function createFetchHandler(app: Hono): FetchHandler {
37
37
  const method = request.method.toUpperCase();
38
38
  const hasBody = method !== 'GET' && method !== 'HEAD';
39
39
  const body = hasBody ? await request.arrayBuffer() : undefined;
40
- return app.fetch(new Request(url.toString(), { method, headers: request.headers, body }));
40
+ // re-expose the stripped mount prefix as `x-path-prefix` so internal
41
+ // absolute-URL builders reconstruct the PUBLIC url. The DID-Connect
42
+ // authenticator's wallet callback URL is built by did-connect-js
43
+ // `prepareBaseUrl`, which reads `x-path-prefix`; without it the wallet is
44
+ // told to call <origin>/api/did/payment/auth instead of
45
+ // <origin><basePath>/api/did/payment/auth and the connect times out. The
46
+ // Request's headers are immutable, so set it on a fresh Headers; never
47
+ // clobber a host-provided value (blocklet-server sets its own mount).
48
+ const headers = new Headers(request.headers);
49
+ if (!headers.has('x-path-prefix')) headers.set('x-path-prefix', basePath);
50
+ return app.fetch(new Request(url.toString(), { method, headers, body }));
41
51
  }
42
52
 
43
53
  // no strip needed — hand the request straight to hono (it owns body parsing).
@@ -56,13 +56,25 @@ function injectJobTenant(job: any): any {
56
56
  * Phase 5 have no instance_did: single mode falls back to the deployment
57
57
  * default, multi mode refuses permanently (non-retryable + structured alert).
58
58
  */
59
+ // Warm the tenant identity then run the handler — the queue analogue of the HTTP
60
+ // contextMiddleware warm. Signing queues (payment/refund/payout/...) access the
61
+ // business wallet synchronously; warming inside the tenant span makes `wallet`/
62
+ // `ethWallet` resolve to the job's tenant (no-op on blocklet-server).
63
+ async function warmThenRun<T>(onJob: (job: T) => Promise<any>, job: T): Promise<any> {
64
+ const { warmTenantIdentity } =
65
+ // eslint-disable-next-line global-require
66
+ require('../did-connect/tenant-identity') as typeof import('../did-connect/tenant-identity');
67
+ await warmTenantIdentity();
68
+ return onJob(job);
69
+ }
70
+
59
71
  function runJobWithTenant<T>(job: any, onJob: (job: T) => Promise<any>): Promise<any> {
60
72
  const tenant = job?.instance_did;
61
73
  if (tenant) {
62
- return withTenant(tenant, () => onJob(job));
74
+ return withTenant(tenant, () => warmThenRun(onJob, job));
63
75
  }
64
76
  if (getTenantMode() === 'single') {
65
- return withTenant(getDefaultInstanceDid(), () => onJob(job));
77
+ return withTenant(getDefaultInstanceDid(), () => warmThenRun(onJob, job));
66
78
  }
67
79
  const err = new TenantError(TENANT_CONTEXT_MISSING, 'legacy job without tenant refused in multi mode');
68
80
  (err as any).nonRetryable = true;
@@ -17,6 +17,7 @@ import { translate } from '../../locales';
17
17
  import { context } from '../../libs/context';
18
18
  import { TenantError, TENANT_HOST_UNRESOLVED } from '../../libs/tenant';
19
19
  import { resolveTenantForHost } from '../../libs/drivers/identity';
20
+ import { warmTenantIdentity } from '../../libs/did-connect/tenant-identity';
20
21
 
21
22
  export function ensureI18n(): MiddlewareHandler {
22
23
  return (c, next) => {
@@ -67,6 +68,12 @@ export function contextMiddleware(): MiddlewareHandler {
67
68
  }
68
69
 
69
70
  return context.run({ requestId, requestedBy, instanceDid }, async () => {
71
+ // Warm the tenant's signing identity (arc/CF dynamic runtime only — no-op on
72
+ // blocklet-server) so the synchronous business wallet proxies (libs/auth.ts:
73
+ // `wallet`/`ethWallet`) resolve to THIS tenant's wallet inside the handler.
74
+ // Best-effort: a request that never touches a wallet is not blocked; one that
75
+ // does fails-closed at getCachedTenantIdentity.
76
+ await warmTenantIdentity(instanceDid);
70
77
  await next();
71
78
  });
72
79
  };
@@ -13,9 +13,20 @@ import { getCookie, setCookie } from 'hono/cookie';
13
13
  import { sign, verify, getCsrfSecret } from '@blocklet/sdk/lib/util/csrf';
14
14
  // eslint-disable-next-line import/no-extraneous-dependencies
15
15
  import { isDidWalletConnect } from '@blocklet/sdk/lib/util/wallet';
16
+ import { readConfig } from '../../libs/env';
16
17
 
17
18
  const isEmpty = (v: unknown): boolean => v === undefined || v === null || v === '';
18
19
 
20
+ // The CSRF secret is HOST-GLOBAL, not per-tenant: this middleware runs BEFORE the
21
+ // tenant context is established (pipeline order cors→xss→csrf→…→context), so it
22
+ // has no instanceDid, and it binds the host-global login_token anyway. An embedded
23
+ // host (arc) that has no BLOCKLET_APP_SK env injects a dedicated secret via the
24
+ // config slot (`PAYMENT_CSRF_SECRET`); the standard blocklet server falls back to
25
+ // the SDK's env-based secret. See docs/architecture/payment-credential-sourcing.md.
26
+ function csrfSecret(): string {
27
+ return readConfig('PAYMENT_CSRF_SECRET') || getCsrfSecret();
28
+ }
29
+
19
30
  // Express SDK: shouldGenerateToken === GET; shouldVerifyToken === mutating
20
31
  // method AND an x-csrf-token cookie already exists AND path is not /mcp AND the
21
32
  // caller is not a DID Wallet connect request.
@@ -43,7 +54,7 @@ export function csrf(): MiddlewareHandler {
43
54
 
44
55
  if (method === 'GET') {
45
56
  if (loginToken) {
46
- const newCsrf = sign(getCsrfSecret(), loginToken);
57
+ const newCsrf = sign(csrfSecret(), loginToken);
47
58
  if (newCsrf !== existingCsrf) {
48
59
  setCookie(c, 'x-csrf-token', newCsrf, { sameSite: 'Strict', secure: true });
49
60
  }
@@ -59,7 +70,7 @@ export function csrf(): MiddlewareHandler {
59
70
  if (isDidWalletConnect(c.req.header())) return next();
60
71
 
61
72
  const headerCsrf = c.req.header('x-csrf-token');
62
- if (existingCsrf === headerCsrf && verify(getCsrfSecret(), existingCsrf as string, loginToken as string)) {
73
+ if (existingCsrf === headerCsrf && verify(csrfSecret(), existingCsrf as string, loginToken as string)) {
63
74
  return next();
64
75
  }
65
76
  return c.text('Invalid request: csrf token mismatch, please refresh the page try again', 403);
@@ -16,19 +16,14 @@ import type { Model } from 'sequelize';
16
16
  import { getVerifyData, verify } from '@blocklet/sdk/lib/util/verify-sign';
17
17
  // eslint-disable-next-line import/no-extraneous-dependencies
18
18
  import { verifyLoginToken } from '@blocklet/sdk/lib/util/verify-session';
19
- // eslint-disable-next-line import/no-extraneous-dependencies
20
- import { getWallet } from '@blocklet/sdk/lib/wallet';
21
19
  import { isDevelopmentEnv, enableDevFakeAuth } from '../../libs/env';
20
+ // The embed-auth wallet (verifies the embed authToken signature) is the dynamic
21
+ // business wallet (libs/auth.ts): the active tenant's wallet on arc/CF, the env
22
+ // wallet on blocklet-server. The embed branch runs inside the warmed request
23
+ // scope, so synchronous access resolves the tenant wallet.
24
+ import { wallet } from '../../libs/auth';
22
25
  import { Customer } from '../../store/models/customer';
23
26
 
24
- // Phase 13b parity: lazy wallet — getWallet() needs BLOCKLET_APP_PK which a bare
25
- // host lacks; only the embed branch uses it.
26
- let cachedWallet: ReturnType<typeof getWallet> | undefined;
27
- const wallet = () => {
28
- cachedWallet ??= getWallet();
29
- return cachedWallet;
30
- };
31
-
32
27
  type PermissionSpec<T extends Model> = {
33
28
  component?: boolean;
34
29
  roles?: string[];
@@ -132,7 +127,7 @@ export function authenticate<T extends Model>({
132
127
  // the express version, which does not await next()).
133
128
  let embedOk = false;
134
129
  try {
135
- const w = wallet();
130
+ const w = wallet;
136
131
  const verified = await w.verify(embedId, embedToken);
137
132
  if (!verified) {
138
133
  return c.json({ error: `Invalid signature for embed: ${embedId}` }, 401);
@@ -5,6 +5,7 @@ import { systemFindAll, systemFindByPk, systemFindOne } from '../store/scoped';
5
5
  import { mintNftForCheckoutSession } from '../integrations/arcblock/nft';
6
6
  import { ensurePassportIssued } from '../integrations/blocklet/passport';
7
7
  import { ensureInvoiceForCheckout } from '../routes/connect/shared';
8
+ import { withTenant } from '../libs/context';
8
9
  import dayjs from '../libs/dayjs';
9
10
  import { events } from '../libs/event';
10
11
  import logger from '../libs/logger';
@@ -89,16 +90,27 @@ export async function startCheckoutSessionQueue() {
89
90
  },
90
91
  });
91
92
 
92
- checkoutSessions.forEach(async (checkoutSession) => {
93
- const exist = await checkoutSessionQueue.get(checkoutSession.id);
94
- if (!exist) {
95
- checkoutSessionQueue.push({
96
- id: checkoutSession.id,
97
- job: { id: checkoutSession.id, action: 'expire' },
98
- runAt: checkoutSession.expires_at,
99
- });
93
+ // Per-record tenant context: queue push stamps the job tenant via getInstanceDid,
94
+ // which fails closed in multi mode without it. for-of + await (not forEach(async),
95
+ // which leaks an unhandledRejection → FATAL on boot); per-record catch isolates.
96
+ for (const checkoutSession of checkoutSessions) {
97
+ const dispatch = async () => {
98
+ const exist = await checkoutSessionQueue.get(checkoutSession.id);
99
+ if (!exist) {
100
+ checkoutSessionQueue.push({
101
+ id: checkoutSession.id,
102
+ job: { id: checkoutSession.id, action: 'expire' },
103
+ runAt: checkoutSession.expires_at,
104
+ });
105
+ }
106
+ };
107
+ try {
108
+ // eslint-disable-next-line no-await-in-loop
109
+ await (checkoutSession.instance_did ? withTenant(checkoutSession.instance_did, dispatch) : dispatch());
110
+ } catch (error) {
111
+ logger.error('startCheckoutSessionQueue: re-queue failed', { id: checkoutSession.id, error });
100
112
  }
101
- });
113
+ }
102
114
  }
103
115
 
104
116
  checkoutSessionQueue.on('failed', ({ id, job, error }) => {
@@ -2,6 +2,7 @@ import { Op } from 'sequelize';
2
2
  import { isCfWorker } from '../libs/env';
3
3
  import { systemFindAll, systemFindByPk } from '../store/scoped';
4
4
 
5
+ import { withTenant } from '../libs/context';
5
6
  import { events } from '../libs/event';
6
7
  import logger from '../libs/logger';
7
8
  import createQueue from '../libs/queue';
@@ -83,22 +84,43 @@ export const eventQueue = createQueue<EventJob>({
83
84
  });
84
85
 
85
86
  export const startEventQueue = async () => {
87
+ // Cross-tenant recovery scan: fetch instance_did too so the recovered push
88
+ // carries its tenant. In multi mode startup runs with NO tenant context, so a
89
+ // push without instance_did would hit injectJobTenant -> getInstanceDid ->
90
+ // TENANT_CONTEXT_MISSING (a FATAL unhandledRejection from the async forEach
91
+ // below). The handler re-derives the tenant from the row, but the PUSH itself
92
+ // must already be tenant-stamped to survive multi mode.
86
93
  const docs = await systemFindAll(Event, {
87
94
  where: {
88
95
  pending_webhooks: { [Op.gt]: 0 },
89
96
  },
90
- attributes: ['id'],
97
+ attributes: ['id', 'instance_did'],
91
98
  });
92
99
 
93
100
  logger.info(`Found ${docs.length} events with pending webhooks`);
94
101
 
95
- docs.forEach(async (x) => {
96
- const exist = await eventQueue.get(x.id);
97
- if (!exist) {
98
- logger.info(`Pushing event ${x.id} to queue`);
99
- eventQueue.push({ id: x.id, job: { eventId: x.id }, persist: false });
102
+ // Sequential + per-doc try/catch: one bad row must never crash startup
103
+ // recovery (await in the original forEach left rejections unhandled). The
104
+ // push runs inside withTenant(event.instance_did) so injectJobTenant stamps
105
+ // the correct tenant — in multi mode there is no ambient context at startup.
106
+ for (const x of docs) {
107
+ if (!x.instance_did) {
108
+ logger.warn('skip pending-webhook event with no tenant', { id: x.id });
109
+ } else {
110
+ try {
111
+ // eslint-disable-next-line no-await-in-loop -- bounded startup recovery
112
+ await withTenant(x.instance_did, async () => {
113
+ const exist = await eventQueue.get(x.id);
114
+ if (!exist) {
115
+ logger.info(`Pushing event ${x.id} to queue`);
116
+ eventQueue.push({ id: x.id, job: { eventId: x.id }, persist: false });
117
+ }
118
+ });
119
+ } catch (error) {
120
+ logger.error('failed to recover pending-webhook event', { id: x.id, error });
121
+ }
100
122
  }
101
- });
123
+ }
102
124
 
103
125
  logger.info('Finished starting event queue');
104
126
  };