payment-kit 1.29.8 → 1.29.9

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.
@@ -42,6 +42,7 @@ export function createNodeStaticHandler(webRoot: string): (app: Hono) => void {
42
42
  const { serveStatic } = require('@hono/node-server/serve-static');
43
43
 
44
44
  // SPA fallback FIRST — html navigations (not assets) get index.html VERBATIM.
45
+ // eslint-disable-next-line require-await -- Hono's app.use overload requires a Promise-returning handler
45
46
  app.use('*', async (c, next) => {
46
47
  const method = c.req.method.toUpperCase();
47
48
  if (
@@ -12,6 +12,7 @@
12
12
  // is the chain-to-pinned-Apple-root (native-jws.ts §5.3–5.4). A spoofed cert could
13
13
  // embed these public OID bytes, but it cannot forge the chain signature.
14
14
 
15
+ /* eslint-disable no-bitwise -- DER base-128 OID encoding requires 7-bit-group bitwise ops (ASN.1) */
15
16
  import type { X509Certificate } from 'node:crypto';
16
17
 
17
18
  /**
@@ -43,6 +43,10 @@ export function isSignatureVerificationSkipped(): boolean {
43
43
  return true;
44
44
  }
45
45
 
46
+ /* eslint-disable require-await, @typescript-eslint/no-unused-vars --
47
+ async: the skip-verify branch returns a sync decodeUnsafe<T>(), so async keeps the
48
+ signature Promise<T>; _bundleId/_environment stay in the signature for caller-layer
49
+ enforcement (client.ts) + Apple API parity. */
46
50
  export async function verifySignedTransaction(
47
51
  signedTransaction: string,
48
52
  _bundleId: string,
@@ -69,6 +73,7 @@ export async function verifySignedNotification(
69
73
  }
70
74
  return verifyAppleJws<JwsNotificationPayload>(signedPayload);
71
75
  }
76
+ /* eslint-enable require-await, @typescript-eslint/no-unused-vars */
72
77
 
73
78
  /** Decode-only fallback for when signature verification is intentionally bypassed. */
74
79
  function decodeUnsafe<T>(jws: string): T {
@@ -104,7 +109,7 @@ export type AppStoreApiCredentials = {
104
109
  * StatusResponse, whose `signedTransactionInfo` JWS strings are verified by the
105
110
  * caller (client.ts) with verifyAppleJws.
106
111
  */
107
- export async function getAllSubscriptionStatuses(
112
+ export function getAllSubscriptionStatuses(
108
113
  originalTransactionId: string,
109
114
  creds: AppStoreApiCredentials
110
115
  ): Promise<StatusResponse> {
@@ -4,7 +4,7 @@ import { env } from '@blocklet/sdk/lib/env';
4
4
 
5
5
  import Stripe from 'stripe';
6
6
  import logger from '../../libs/logger';
7
- import { STRIPE_API_VERSION, STRIPE_ENDPOINT, STRIPE_EVENTS } from '../../libs/util';
7
+ import { STRIPE_API_VERSION, stripeEndpoint, STRIPE_EVENTS } from '../../libs/util';
8
8
  import { PaymentMethod } from '../../store/models';
9
9
 
10
10
  // register stripe webhooks on start/create if there are any stripe payment methods
@@ -23,7 +23,7 @@ export async function ensureWebhookRegistered() {
23
23
  const exist = data.find((webhook) => webhook.metadata?.appPid === env.appPid);
24
24
  if (exist) {
25
25
  await stripe.webhookEndpoints.update(exist.id, {
26
- url: STRIPE_ENDPOINT,
26
+ url: stripeEndpoint(),
27
27
  description: env.appName,
28
28
  enabled_events: STRIPE_EVENTS,
29
29
  disabled: false,
@@ -31,7 +31,7 @@ export async function ensureWebhookRegistered() {
31
31
  logger.info('stripe webhook updated');
32
32
  } else {
33
33
  const result = await stripe.webhookEndpoints.create({
34
- url: STRIPE_ENDPOINT,
34
+ url: stripeEndpoint(),
35
35
  description: env.appName,
36
36
  enabled_events: STRIPE_EVENTS,
37
37
  api_version: STRIPE_API_VERSION,
@@ -0,0 +1,27 @@
1
+ import type { Transaction } from '@ocap/client';
2
+ import { encodeTx, fetchContext } from '@ocap/client/encode';
3
+
4
+ type TransferWallet = {
5
+ address: string;
6
+ publicKey?: string;
7
+ pk?: string;
8
+ };
9
+
10
+ export async function encodeArcblockTransfer({
11
+ chainHost,
12
+ tx,
13
+ wallet,
14
+ }: {
15
+ chainHost: string;
16
+ tx: Partial<Transaction>;
17
+ wallet: TransferWallet;
18
+ }) {
19
+ const context = await fetchContext(chainHost);
20
+ return encodeTx({
21
+ type: 'TransferV3Tx',
22
+ tx: tx as any,
23
+ wallet,
24
+ chainId: context.chainId,
25
+ feeConfig: context.txFee,
26
+ });
27
+ }
@@ -14,6 +14,7 @@ interface RequestContext {
14
14
  requestedBy?: string;
15
15
  requestId?: string;
16
16
  instanceDid?: string;
17
+ publicBaseUrl?: string;
17
18
  }
18
19
 
19
20
  class RequestContextManager {
@@ -74,6 +75,11 @@ class RequestContextManager {
74
75
  return this.storage.getStore()?.instanceDid;
75
76
  }
76
77
 
78
+ /** Request-scoped externally reachable payment base URL, when supplied by an embedded host. */
79
+ peekPublicBaseUrl(): string | undefined {
80
+ return this.storage.getStore()?.publicBaseUrl;
81
+ }
82
+
77
83
  /**
78
84
  * Run fn as a system operation: TenantModel scoping is bypassed for the span
79
85
  * of fn so legitimate cross-tenant reads can load rows regardless of tenant.
@@ -92,17 +98,19 @@ class RequestContextManager {
92
98
 
93
99
  run<T>(context: RequestContext, fn: () => Promise<T> | T): Promise<T> {
94
100
  const requestId = context.requestId || `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
95
- // inherit tenant from the enclosing scope unless explicitly provided
101
+ // inherit host-established request fields unless explicitly provided
96
102
  const instanceDid = context.instanceDid ?? this.storage.getStore()?.instanceDid;
103
+ const publicBaseUrl = context.publicBaseUrl ?? this.storage.getStore()?.publicBaseUrl;
97
104
 
98
105
  this.contexts.set(requestId, {
99
106
  ...context,
100
107
  instanceDid,
108
+ publicBaseUrl,
101
109
  requestId,
102
110
  });
103
111
 
104
112
  return new Promise((resolve, reject) => {
105
- this.storage.run({ ...context, instanceDid, requestId }, async () => {
113
+ this.storage.run({ ...context, instanceDid, publicBaseUrl, requestId }, async () => {
106
114
  const resource = new AsyncResource('RequestContext');
107
115
  try {
108
116
  const result = await resource.runInAsyncScope(fn);
@@ -22,6 +22,7 @@ import type { WalletObject } from '@ocap/wallet';
22
22
  import { getInstanceDid } from '../context';
23
23
  import { getIdentityDriver, type InstanceAppInfo, type BlockletDirectory } from '../drivers';
24
24
  import logger from '../logger';
25
+ import { warmupSecrets } from '../secrets';
25
26
 
26
27
  const walletType = {
27
28
  role: Mcrypto.types.RoleType.ROLE_APPLICATION,
@@ -165,10 +166,27 @@ export function getCachedTenantIdentity(instanceDidArg?: string): ResolvedTenant
165
166
  */
166
167
  export async function warmTenantIdentity(instanceDidArg?: string): Promise<void> {
167
168
  if (!hasDynamicIdentity()) return;
169
+ // The wallet signing identity (getInstanceAppIdentity) and the secrets keyring EK
170
+ // (getAppEk) are INDEPENDENT host concerns. Warm them in SEPARATE try blocks: a
171
+ // wallet-identity failure (e.g. an AUTH_SERVICE that does not implement
172
+ // getInstanceAppIdentity) must NOT also skip the secrets warm. Coupling them left
173
+ // the keyring cold whenever wallet identity was unavailable, so every PaymentMethod
174
+ // settings/Stripe decrypt 500'd on an undefined crypto key — even though the
175
+ // Stripe/USD path never needs the wallet identity.
168
176
  try {
169
177
  await resolveTenantIdentity(instanceDidArg);
170
178
  } catch (err: unknown) {
171
- logger.warn('[tenant-identity] warm failed — wallet access will fail-closed', {
179
+ logger.warn('[tenant-identity] wallet identity warm failed — wallet access will fail-closed', {
180
+ error: err instanceof Error ? err.message : String(err),
181
+ });
182
+ }
183
+ // The keyring secrets driver warms the tenant EK so PaymentMethod settings
184
+ // encrypt/decrypt + getStripeClient resolve on the sync hot path (else "secrets:
185
+ // key for tenant <did> is not warmed"). No-op for the default single-key driver.
186
+ try {
187
+ await warmupSecrets(instanceDidArg);
188
+ } catch (err: unknown) {
189
+ logger.warn('[tenant-identity] secrets warm failed — settings/Stripe decrypt will fail-closed', {
172
190
  error: err instanceof Error ? err.message : String(err),
173
191
  });
174
192
  }
@@ -178,6 +178,7 @@ export const googlePubsubAllowUnverifiedSender = (): boolean =>
178
178
  parseBool(readConfig('GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER'));
179
179
  export const googlePlayWebhookUrl = (): string | undefined => readConfig('GOOGLE_PLAY_WEBHOOK_URL');
180
180
  export const stripeWebhookSecret = (): string | undefined => readConfig('STRIPE_WEBHOOK_SECRET');
181
+ export const stripeWebhookUrl = (): string | undefined => readConfig('STRIPE_WEBHOOK_URL');
181
182
  export const iapReconcileBatchSize = (): number => Number(readConfig('IAP_RECONCILE_BATCH_SIZE') ?? '100');
182
183
 
183
184
  // -- payment params --
@@ -12,10 +12,11 @@ let blockletSdkTransportGuarded = false;
12
12
 
13
13
  /** Replace `fns` with no-ops on both the module namespace and its `default` export. */
14
14
  function noopSdkTransport(mod: any, fns: string[]): void {
15
- const noop = async (): Promise<undefined> => undefined;
15
+ const noop = (): Promise<undefined> => Promise.resolve(undefined);
16
16
  for (const target of [mod, mod?.default]) {
17
- if (!target) continue;
18
- for (const fn of fns) target[fn] = noop;
17
+ if (target) {
18
+ for (const fn of fns) target[fn] = noop;
19
+ }
19
20
  }
20
21
  }
21
22
 
@@ -13,7 +13,16 @@ import axios from 'axios';
13
13
  import { fromUnitToToken } from '@ocap/util';
14
14
  import get from 'lodash/get';
15
15
  import trimEnd from 'lodash/trimEnd';
16
- import { googlePlayWebhookUrl, blockletAppUrl, blockletMountPoints, blockletAppId, blockletAppName } from './env';
16
+ import {
17
+ googlePlayWebhookUrl,
18
+ stripeWebhookUrl,
19
+ blockletAppUrl,
20
+ blockletMountPoints,
21
+ blockletAppId,
22
+ blockletAppName,
23
+ readConfig,
24
+ } from './env';
25
+ import { context } from './context';
17
26
  import dayjs from './dayjs';
18
27
  import { blocklet, wallet } from './auth';
19
28
  import type { PaymentCurrency, PaymentMethod, Subscription } from '../store/models';
@@ -29,7 +38,19 @@ export const MIN_RETRY_MAIL = 13; // total retry time before sending first mail:
29
38
  export const CHECKOUT_SESSION_TTL = 6 * 60 * 60; // expires in 6 hours, then removed after 12 hours
30
39
 
31
40
  export const STRIPE_API_VERSION = '2023-08-16';
32
- export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
41
+ // Stripe webhook must be an ABSOLUTE https URL. In the embedded (arc-node) runtime
42
+ // getUrl() returns a relative path (no host) which Stripe rejects — registration
43
+ // then fails (and is currently swallowed) so payments never reconcile (session
44
+ // stuck unpaid). Mirror googlePlayEndpoint: prefer the STRIPE_WEBHOOK_URL override,
45
+ // fall back to getUrl. Lazy-eval (function) because dotenv loads env AFTER this
46
+ // module is imported — a module-load constant would only ever see the relative path.
47
+ export const stripeEndpoint = (): string => {
48
+ const override = stripeWebhookUrl();
49
+ if (override) return override;
50
+ const publicBaseUrl = context.peekPublicBaseUrl();
51
+ if (publicBaseUrl) return joinURL(publicBaseUrl, '/api/integrations/stripe/webhook');
52
+ return getUrl('/api/integrations/stripe/webhook');
53
+ };
33
54
  // Pub/Sub OIDC tokens carry the push-subscription endpoint as `aud` claim. When
34
55
  // the dev server is reached via a custom domain (e.g. a Cloudflare tunnel), the
35
56
  // BLOCKLET_APP_URL-derived default won't match the URL Google signs against.
@@ -95,6 +116,14 @@ export const api = axios.create({
95
116
  timeout: 10 * 1000,
96
117
  });
97
118
 
119
+ export function getPublicAssetUrl(assetUrl: string): string {
120
+ if (!assetUrl || !assetUrl.startsWith('/') || assetUrl.startsWith('//')) return assetUrl;
121
+ const basePath = readConfig('PAYMENT_PUBLIC_BASE_PATH');
122
+ if (!basePath) return getUrl(assetUrl);
123
+ if (assetUrl === basePath || assetUrl.startsWith(`${basePath}/`)) return assetUrl;
124
+ return joinURL(basePath, assetUrl);
125
+ }
126
+
98
127
  export function md5(input: string) {
99
128
  return crypto.createHash('md5').update(input).digest('hex');
100
129
  }
@@ -48,6 +48,12 @@ export function csrf(): MiddlewareHandler {
48
48
  // type to the MiddlewareHandler contract.
49
49
  // eslint-disable-next-line require-await
50
50
  return async (c, next) => {
51
+ // Escape hatch (TEST / STAGING ONLY): PAYMENT_DISABLE_CSRF=true bypasses CSRF
52
+ // verification entirely. This removes cross-site request forgery protection on
53
+ // ALL mutating payment routes — NEVER set it in production. Defaults off, so the
54
+ // standard secure behavior is unchanged unless a deployment opts out explicitly.
55
+ if (readConfig('PAYMENT_DISABLE_CSRF') === 'true') return next();
56
+
51
57
  const method = c.req.method.toUpperCase();
52
58
  const loginToken = getCookie(c, 'login_token');
53
59
  const existingCsrf = getCookie(c, 'x-csrf-token');
@@ -104,6 +104,28 @@ export function sessionMiddleware(options: SessionOptions = {}): MiddlewareHandl
104
104
  return c.json({ error: err.message }, 401);
105
105
  }
106
106
 
107
+ // Embedded / blocklet-server PRIMARY auth path. The host adapter (arc-node CF
108
+ // gate via cf-adapter.injectCaller, or blocklet-server) verifies the caller
109
+ // upstream, STRIPS any client-forged `x-user-*`, then injects the real caller as
110
+ // `x-user-*` headers — exactly what authenticate() (security.ts) already trusts.
111
+ // sessionMiddleware historically only checked the login_token cookie, so under
112
+ // embedded (where the login_token is arc's, NOT verifiable by payment's
113
+ // @blocklet/sdk verifyLoginToken) session-scoped routes like /customers/me saw
114
+ // no user and 403'd even for a logged-in caller — which left the checkout
115
+ // customer form unpopulated. Honor the injected identity as a fallback.
116
+ if (!result) {
117
+ const injectedDid = c.req.header('x-user-did');
118
+ if (injectedDid) {
119
+ result = {
120
+ did: injectedDid,
121
+ role: c.req.header('x-user-role'),
122
+ provider: c.req.header('x-user-provider'),
123
+ fullName: decodeURIComponent(c.req.header('x-user-fullname') || ''),
124
+ walletOS: c.req.header('x-user-wallet-os') || '',
125
+ };
126
+ }
127
+ }
128
+
107
129
  if (result) {
108
130
  c.set('user', result);
109
131
  }
@@ -5,6 +5,7 @@ import { fromAddress } from '@ocap/wallet';
5
5
  import { encodeTransferItx } from '../../integrations/ethereum/token';
6
6
  import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
7
7
  import { CallbackArgs, ethWallet } from '../../libs/auth';
8
+ import { encodeArcblockTransfer } from '../../libs/arcblock-transfer';
8
9
  import logger from '../../libs/logger';
9
10
  import { parseChainError } from '../../libs/chain-error';
10
11
  import { getGasPayerExtra } from '../../libs/payment';
@@ -127,25 +128,36 @@ export default {
127
128
 
128
129
  if (paymentMethod.type === 'arcblock') {
129
130
  try {
130
- await paymentIntent.update({ status: 'processing' });
131
131
  const client = paymentMethod.getOcapClient();
132
132
  const claim = claims.find((x) => x.type === 'prepareTx');
133
133
 
134
- // Warm up chain context cache to avoid setTimeout(resolve,0) hang in workerd
135
- await client.getContext();
136
-
137
134
  const tx: Partial<Transaction> = client.decodeTx(claim.finalTx);
138
135
  if (claim.delegator && claim.from) {
139
136
  tx.delegator = claim.delegator;
140
137
  tx.from = claim.from;
141
138
  }
142
139
 
143
- // @ts-ignore
144
- const { buffer } = await client.encodeTransferV3Tx({ tx });
140
+ const chainHost = paymentMethod.settings.arcblock?.api_host;
141
+ if (!chainHost) {
142
+ throw new Error('ArcBlock payment method API host is missing');
143
+ }
144
+ const signingWallet = fromAddress(userDid);
145
+ const { object: encodedTx, buffer } = await encodeArcblockTransfer({
146
+ chainHost,
147
+ tx,
148
+ wallet: signingWallet,
149
+ });
150
+ const gasPayerExtra = await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client);
151
+
152
+ // Only expose processing after all cold-start preparation has completed.
153
+ // If context fetching/encoding is canceled by workerd, the intent remains
154
+ // retryable instead of being stranded in processing without a tx hash.
155
+ await paymentIntent.update({ status: 'processing' });
145
156
  const txHash = await client.sendTransferV3Tx(
146
- // @ts-ignore
147
- { tx, wallet: fromAddress(userDid) },
148
- await getGasPayerExtra(buffer, client.pickGasPayerHeaders(request), client)
157
+ // Passing the already-encoded signature skips the client's timer-backed
158
+ // encodeTransferV3Tx/getContext path in Cloudflare Workers.
159
+ { tx: encodedTx as any, wallet: signingWallet, signature: encodedTx.signature },
160
+ gasPayerExtra
149
161
  );
150
162
 
151
163
  const quoteValidation = await validateQuoteForPayment({
@@ -26,7 +26,14 @@ import { PaymentMethod } from '../../store/models';
26
26
  const app = new Hono();
27
27
  // component+owner/admin for cross-blocklet calls; ensureLogin for end users —
28
28
  // handler then enforces that non-admin users can only query their own DID.
29
- const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
29
+ // Any signed-in user can query their OWN entitlements (the handler bodies
30
+ // below already gate cross-customer reads via isSelf(...) + isAdminUser).
31
+ // A roles-list here would block normal wallet users at the middleware (their
32
+ // role is "user", not owner/admin) before isSelf ever runs, so the IAP
33
+ // "Subscribe + check my own entitlement" loop returned 403 for every real
34
+ // end user. Keep `component: true` so server-to-server signed calls bypass
35
+ // auth as before; keep `ensureLogin` so unauthenticated requests still 401.
36
+ const auth = authenticate<PaymentMethod>({ component: true, ensureLogin: true });
30
37
 
31
38
  function isAdminUser(role?: string): boolean {
32
39
  return role === 'owner' || role === 'admin';
@@ -109,7 +109,9 @@ app.post('/', auth, async (c) => {
109
109
 
110
110
  await method.update({ default_currency_id: currency.id });
111
111
 
112
- ensureWebhookRegistered().catch(console.error);
112
+ ensureWebhookRegistered().catch((err) =>
113
+ logger.error('ensureWebhookRegistered failed (payments may not reconcile in real-time)', err)
114
+ );
113
115
 
114
116
  return c.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
115
117
  }
@@ -532,7 +534,9 @@ app.put('/:id', auth, async (c) => {
532
534
 
533
535
  const updatedMethod = await method.update(updateData);
534
536
  if (method.type === 'stripe') {
535
- ensureWebhookRegistered().catch(console.error);
537
+ ensureWebhookRegistered().catch((err) =>
538
+ logger.error('ensureWebhookRegistered failed (payments may not reconcile in real-time)', err)
539
+ );
536
540
  }
537
541
  return c.json(updatedMethod);
538
542
  } catch (err) {
@@ -275,10 +275,30 @@ export function buildConnectRoutesHono(): Hono {
275
275
  // withTenant) we pass through; otherwise we resolve Host→tenant once and wrap.
276
276
  connectApp.use('*', async (c, next) => {
277
277
  const { context: requestCtx } = require('./libs/context');
278
- if (requestCtx.peekInstanceDid()) return next();
278
+ const { warmTenantIdentity } = require('./libs/did-connect/tenant-identity');
279
+ // Warm the tenant's signing identity + secrets keyring BEFORE the DID handler so
280
+ // the synchronous secrets hot path (connect/pay → getStripeClient →
281
+ // PaymentMethod.decryptSettings) resolves — else it throws "secrets: key for
282
+ // tenant <did> is not warmed". Mirrors contextMiddleware (context.ts); the native
283
+ // resource group warms there, but connectApp is mounted ALONGSIDE it (no shared
284
+ // pipeline), so DID routes must warm themselves. Best-effort + no-op on
285
+ // blocklet-server (hasDynamicIdentity() === false).
286
+ const preResolved = requestCtx.peekInstanceDid();
287
+ if (preResolved) {
288
+ // Host already scoped this request (e.g. the CF worker wrapped /api/* in
289
+ // withTenant before the core ran) — run inside that context; warm is idempotent
290
+ // (cache hit if the host already warmed).
291
+ await warmTenantIdentity(preResolved);
292
+ return next();
293
+ }
294
+ // arc-node embedded (svc.http.fetch) never pre-wraps: resolve Host→tenant once,
295
+ // run inside the tenant context, and warm before the handler.
279
296
  const { resolveTenantForHost } = require('./libs/drivers/identity');
280
297
  const instanceDid = await resolveTenantForHost(c.req.header('host'));
281
- return requestCtx.withTenant(instanceDid, () => next());
298
+ return requestCtx.withTenant(instanceDid, async () => {
299
+ await warmTenantIdentity(instanceDid);
300
+ return next();
301
+ });
282
302
  });
283
303
 
284
304
  for (const h of connectHandlerModules()) handlers.attach(Object.assign({ app: connectApp }, h));
@@ -455,8 +475,20 @@ async function provisionTenant(instanceDid: string): Promise<void> {
455
475
  const { PaymentMethod, PaymentCurrency } = require('./store/models');
456
476
 
457
477
  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' },
478
+ {
479
+ chainId: 'main',
480
+ livemode: true,
481
+ symbol: 'ABT',
482
+ label: 'ArcBlock Main',
483
+ contract: 'z35nNRvYxBoHitx9yZ5ATS88psfShzPPBLxYD',
484
+ },
485
+ {
486
+ chainId: 'beta',
487
+ livemode: false,
488
+ symbol: 'TBA',
489
+ label: 'ArcBlock Beta',
490
+ contract: 'z35n6UoHSi9MED4uaQy6ozFgKPaZj2UKrurBG',
491
+ },
460
492
  ];
461
493
 
462
494
  await requestContext.withTenant(instanceDid, async () => {
@@ -464,6 +496,11 @@ async function provisionTenant(instanceDid: string): Promise<void> {
464
496
  const existing = await PaymentMethod.findOne({ where: { type: 'arcblock' } });
465
497
  if (existing) return;
466
498
 
499
+ // Sequential by design: only 2 chains, and within each the currency needs the
500
+ // method id and the method's default_currency_id needs the currency id — no
501
+ // intra-chain parallelism is possible, and inter-chain ordering is irrelevant
502
+ // for two one-time seed inserts.
503
+ /* eslint-disable no-await-in-loop */
467
504
  for (const chain of CHAINS) {
468
505
  const logo = '/methods/arcblock.png';
469
506
  const method = await PaymentMethod.create({
@@ -507,6 +544,7 @@ async function provisionTenant(instanceDid: string): Promise<void> {
507
544
  });
508
545
  await method.update({ default_currency_id: currency.id });
509
546
  }
547
+ /* eslint-enable no-await-in-loop */
510
548
  logger.info('provisionTenant: seeded arcblock payment methods + currencies', { instanceDid });
511
549
  });
512
550
  }
@@ -19,7 +19,7 @@ import type { LiteralUnion } from 'type-fest';
19
19
  import { fromTokenToUnit } from '@ocap/util';
20
20
  import { TenantModel } from '../tenant-model';
21
21
  import { getInstanceDid } from '../../libs/context';
22
- import { createIdGenerator } from '../../libs/util';
22
+ import { createIdGenerator, getPublicAssetUrl } from '../../libs/util';
23
23
  import { RechargeConfig, VaultConfig } from './types';
24
24
 
25
25
  const nextId = createIdGenerator('pc', 12);
@@ -57,6 +57,12 @@ export class PaymentCurrency extends TenantModel<InferAttributes<PaymentCurrency
57
57
  declare recharge_config?: RechargeConfig;
58
58
  declare token_config?: Record<string, any> | null;
59
59
 
60
+ public override toJSON(): any {
61
+ const data = super.toJSON() as any;
62
+ if (data.logo) data.logo = getPublicAssetUrl(data.logo);
63
+ return data;
64
+ }
65
+
60
66
  public static readonly GENESIS_ATTRIBUTES = {
61
67
  id: {
62
68
  type: DataTypes.STRING(15),
@@ -16,7 +16,7 @@ import { encryptSecret, decryptSecret } from '../../libs/secrets';
16
16
 
17
17
  import { AppStoreClient } from '../../integrations/app-store/client';
18
18
  import { GooglePlayClient } from '../../integrations/google-play/client';
19
- import { STRIPE_API_VERSION, createIdGenerator } from '../../libs/util';
19
+ import { STRIPE_API_VERSION, createIdGenerator, getPublicAssetUrl } from '../../libs/util';
20
20
  import { sequelize } from '../sequelize';
21
21
  import type { PaymentMethodSettings } from './types';
22
22
  import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../../libs/constants';
@@ -73,6 +73,12 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
73
73
  declare created_at: CreationOptional<Date>;
74
74
  declare updated_at: CreationOptional<Date>;
75
75
 
76
+ public override toJSON(): any {
77
+ const data = super.toJSON() as any;
78
+ if (data.logo) data.logo = getPublicAssetUrl(data.logo);
79
+ return data;
80
+ }
81
+
76
82
  public static readonly GENESIS_ATTRIBUTES = {
77
83
  id: {
78
84
  type: DataTypes.STRING(30),
@@ -170,11 +176,21 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
170
176
 
171
177
  public static encryptSettings(settings: PaymentMethodSettings) {
172
178
  const tmp = cloneDeep(settings);
173
- if (tmp.stripe) {
179
+ // Per-field truthy guards: admin UI submits *all* channel stubs (empty
180
+ // strings for the ones the user is not configuring), so a parent-level
181
+ // `if (tmp.stripe)` would gladly enter the branch and call
182
+ // encryptSecret('') for every empty stub. Under the multi-tenant
183
+ // KeyringSecretsDriver, CryptoJS.AES.encrypt(undefined, …) throws
184
+ // "Cannot read properties of undefined (reading 'encrypt')" — the
185
+ // single-tenant DefaultSecretsDriver swallowed it silently. Field-level
186
+ // guards make the function robust to either caller shape.
187
+ if (tmp.stripe?.secret_key) {
174
188
  tmp.stripe.secret_key = encryptSecret(tmp.stripe.secret_key);
189
+ }
190
+ if (tmp.stripe?.webhook_signing_secret) {
175
191
  tmp.stripe.webhook_signing_secret = encryptSecret(tmp.stripe.webhook_signing_secret);
176
192
  }
177
- if (tmp.google_play) {
193
+ if (tmp.google_play?.service_account_json) {
178
194
  tmp.google_play.service_account_json = encryptSecret(tmp.google_play.service_account_json);
179
195
  }
180
196
  if (tmp.app_store?.private_key_pem) {
@@ -189,11 +205,16 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
189
205
 
190
206
  public static decryptSettings(settings: PaymentMethodSettings) {
191
207
  const tmp = cloneDeep(settings);
192
- if (tmp.stripe) {
208
+ // Same per-field guard as encryptSettings — empty stubs from the admin
209
+ // UI roundtrip would otherwise hit decryptSecret('') and explode under
210
+ // the keyring driver.
211
+ if (tmp.stripe?.secret_key) {
193
212
  tmp.stripe.secret_key = decryptSecret(tmp.stripe.secret_key);
213
+ }
214
+ if (tmp.stripe?.webhook_signing_secret) {
194
215
  tmp.stripe.webhook_signing_secret = decryptSecret(tmp.stripe.webhook_signing_secret);
195
216
  }
196
- if (tmp.google_play) {
217
+ if (tmp.google_play?.service_account_json) {
197
218
  tmp.google_play.service_account_json = decryptSecret(tmp.google_play.service_account_json);
198
219
  }
199
220
  if (tmp.app_store?.private_key_pem) {
@@ -250,7 +271,10 @@ export class PaymentMethod extends TenantModel<InferAttributes<PaymentMethod>, I
250
271
  // wallets only decode protobuf-encoded tx bytes. Use `@ocap/client/legacy`
251
272
  // (protobuf client) so encode / decode / broadcast all stay protobuf,
252
273
  // matching the wallet signatures and what the chain node expects to hash.
253
- const created = new OcapClient(host);
274
+ // Disable the client's implicit getContext() warmup. Its zero-delay timer
275
+ // can be classified as a permanently hung request by workerd. Callers that
276
+ // need chain context fetch it explicitly through Worker-safe I/O.
277
+ const created = new OcapClient(host, false);
254
278
  ocapClients.set(host, created);
255
279
  return created;
256
280
  }
@@ -0,0 +1,58 @@
1
+ const fetchContext = jest.fn();
2
+ const encodeTx = jest.fn();
3
+
4
+ jest.mock('@ocap/client/encode', () => ({
5
+ fetchContext,
6
+ encodeTx,
7
+ }));
8
+
9
+ import { encodeArcblockTransfer } from '../../src/libs/arcblock-transfer';
10
+
11
+ describe('encodeArcblockTransfer', () => {
12
+ beforeEach(() => {
13
+ fetchContext.mockReset();
14
+ encodeTx.mockReset();
15
+ });
16
+
17
+ it('uses the worker-safe context fetcher instead of the OCAP client context timer', async () => {
18
+ fetchContext.mockResolvedValue({
19
+ chainId: 'beta',
20
+ txFee: [{ typeUrl: 'fg:t:transfer_v3', fee: '0' }],
21
+ });
22
+ const encoded = {
23
+ object: { signature: Buffer.from('signed') },
24
+ buffer: Buffer.from('encoded'),
25
+ };
26
+ encodeTx.mockReturnValue(encoded);
27
+
28
+ const client = {
29
+ getContext: jest.fn(() => {
30
+ throw new Error('must not call timer-backed getContext');
31
+ }),
32
+ encodeTransferV3Tx: jest.fn(() => {
33
+ throw new Error('must not call timer-backed client encoder');
34
+ }),
35
+ };
36
+ const tx = { chainId: '', itx: { typeUrl: 'fg:t:transfer_v3', value: { outputs: [] } } };
37
+ const wallet = { address: 'zUser', publicKey: 'zPk' };
38
+
39
+ await expect(
40
+ encodeArcblockTransfer({
41
+ chainHost: 'https://beta.abtnetwork.io/api/',
42
+ tx,
43
+ wallet,
44
+ })
45
+ ).resolves.toBe(encoded);
46
+
47
+ expect(fetchContext).toHaveBeenCalledWith('https://beta.abtnetwork.io/api/');
48
+ expect(encodeTx).toHaveBeenCalledWith({
49
+ type: 'TransferV3Tx',
50
+ tx,
51
+ wallet,
52
+ chainId: 'beta',
53
+ feeConfig: [{ typeUrl: 'fg:t:transfer_v3', fee: '0' }],
54
+ });
55
+ expect(client.getContext).not.toHaveBeenCalled();
56
+ expect(client.encodeTransferV3Tx).not.toHaveBeenCalled();
57
+ });
58
+ });
@@ -19,12 +19,63 @@ import {
19
19
  resolveAddressChainTypes,
20
20
  formatCurrencyInfo,
21
21
  getExplorerTxUrl,
22
+ getPublicAssetUrl,
23
+ stripeEndpoint,
22
24
  } from '../../src/libs/util';
25
+ import { setCoreConfig } from '../../src/libs/env';
23
26
  import type { Subscription, PaymentMethod, PaymentCurrency } from '../../src/store/models';
24
27
 
25
28
  import { blocklet } from '../../src/libs/auth';
29
+ import { context } from '../../src/libs/context';
26
30
  import logger from '../../src/libs/logger';
27
31
 
32
+ describe('stripeEndpoint', () => {
33
+ afterEach(() => setCoreConfig(undefined));
34
+
35
+ it('uses the request-scoped embedded payment public base URL', async () => {
36
+ const endpoint = await context.run(
37
+ { publicBaseUrl: 'https://cf-aside.ofind.cn/.well-known/payment' },
38
+ async () => stripeEndpoint()
39
+ );
40
+
41
+ expect(endpoint).toBe(
42
+ 'https://cf-aside.ofind.cn/.well-known/payment/api/integrations/stripe/webhook'
43
+ );
44
+ });
45
+
46
+ it('prefers the explicit webhook URL override', async () => {
47
+ setCoreConfig({ STRIPE_WEBHOOK_URL: 'https://hooks.example.com/stripe' });
48
+
49
+ const endpoint = await context.run(
50
+ { publicBaseUrl: 'https://cf-aside.ofind.cn/.well-known/payment' },
51
+ async () => stripeEndpoint()
52
+ );
53
+
54
+ expect(endpoint).toBe('https://hooks.example.com/stripe');
55
+ });
56
+ });
57
+
58
+ describe('getPublicAssetUrl', () => {
59
+ afterEach(() => setCoreConfig(undefined));
60
+
61
+ it('prefixes root-relative assets with the embedded payment mount', () => {
62
+ setCoreConfig({ PAYMENT_PUBLIC_BASE_PATH: '/.well-known/payment' });
63
+ expect(getPublicAssetUrl('/methods/arcblock.png')).toBe('/.well-known/payment/methods/arcblock.png');
64
+ });
65
+
66
+ it('keeps external asset URLs unchanged', () => {
67
+ setCoreConfig({ PAYMENT_PUBLIC_BASE_PATH: '/.well-known/payment' });
68
+ expect(getPublicAssetUrl('https://cdn.example.com/arcblock.png')).toBe('https://cdn.example.com/arcblock.png');
69
+ });
70
+
71
+ it('does not duplicate an existing embedded payment mount', () => {
72
+ setCoreConfig({ PAYMENT_PUBLIC_BASE_PATH: '/.well-known/payment' });
73
+ expect(getPublicAssetUrl('/.well-known/payment/methods/arcblock.png')).toBe(
74
+ '/.well-known/payment/methods/arcblock.png'
75
+ );
76
+ });
77
+ });
78
+
28
79
  describe('createIdGenerator', () => {
29
80
  it('should return a function that generates an ID with the specified prefix and size', () => {
30
81
  const generateId = createIdGenerator('test', 10);
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.29.8
17
+ version: 1.29.9
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -47,7 +47,7 @@ import {
47
47
  createCronRegistry,
48
48
  applyPaymentCoreMigrations,
49
49
  } from '../api/src/libs/drivers';
50
- import type { IdentityDriver } from '../api/src/libs/drivers';
50
+ import type { IdentityDriver, InstanceAppIdentity } from '../api/src/libs/drivers';
51
51
  import { flushQueueWork, dispatchDueJobs, getQueueHandler } from '../api/src/libs/queue/runtime';
52
52
  import { Sequelize } from './shims/sequelize-d1/sequelize-class';
53
53
  import { setDB } from './shims/sequelize-d1/model';
@@ -111,6 +111,14 @@ export interface CloudflarePaymentAdapterOptions {
111
111
  /** Per-tenant EK source (semantics aligned with arc-node: connect-service `app:ek` else derive). */
112
112
  identity: {
113
113
  getAppEk: (instanceDid: string) => Promise<string> | string;
114
+ /**
115
+ * Per-instance app signing identity (S3-CF). When the host provides it — arc
116
+ * reads connect-service settings `app:sk`/`app:psk`, the SAME authority as
117
+ * getAppEk — it is the authoritative DID-Connect signer source. Optional: when
118
+ * the host doesn't wire it, the adapter falls back to the AUTH_SERVICE-backed
119
+ * CF driver (which fails closed if AUTH_SERVICE is also absent).
120
+ */
121
+ getInstanceAppIdentity?: (instanceDid: string) => Promise<InstanceAppIdentity> | InstanceAppIdentity;
114
122
  };
115
123
  /**
116
124
  * Host-resolved caller (CF API gate). The adapter injects it as `x-user-did`
@@ -210,7 +218,11 @@ export function buildFetch(deps: FetchDeps) {
210
218
  if (instanceDid) await warmTenantIdentity(instanceDid);
211
219
  return deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
212
220
  };
213
- const res = instanceDid ? await requestContext.withTenant(instanceDid, run) : await run();
221
+ const requestOrigin = new URL(request.url).origin;
222
+ const publicBaseUrl = new URL(deps.basePath, `${requestOrigin}/`).href.replace(/\/$/, '');
223
+ const res = await requestContext.run({ publicBaseUrl }, () =>
224
+ instanceDid ? requestContext.withTenant(instanceDid, run) : run()
225
+ );
214
226
 
215
227
  await deps.flush(); // drain workerd deferred queue work before responding
216
228
  return res;
@@ -323,7 +335,13 @@ export async function createCloudflarePaymentAdapter(
323
335
  const identity: IdentityDriver = {
324
336
  resolveInstanceDidForHost: () => null,
325
337
  getAppEk: (instanceDid: string) => options.identity.getAppEk(instanceDid),
326
- getInstanceAppIdentity: cfDidConnectIdentity.getInstanceAppIdentity?.bind(cfDidConnectIdentity),
338
+ // Prefer the host-supplied per-instance identity (arc reads connect-service
339
+ // settings app:sk/psk — same authority as getAppEk); fall back to the
340
+ // AUTH_SERVICE-backed CF driver when the host doesn't wire it (the Phase 6
341
+ // binding). This is what lets arc-embedded CF resolve the DID-Connect signer
342
+ // without an AUTH_SERVICE service binding.
343
+ getInstanceAppIdentity:
344
+ options.identity.getInstanceAppIdentity ?? cfDidConnectIdentity.getInstanceAppIdentity?.bind(cfDidConnectIdentity),
327
345
  // per-tenant business wallet (single seam); directory stays the CF alias shim
328
346
  getBusinessWallet: cfDidConnectIdentity.getBusinessWallet,
329
347
  };
@@ -331,7 +349,11 @@ export async function createCloudflarePaymentAdapter(
331
349
  const svc: EmbeddedPaymentService = createEmbeddedPaymentService({
332
350
  // Host-global csrf secret via the config boundary (payment-core's csrf reads
333
351
  // PAYMENT_CSRF_SECRET through readConfig). NO sessionSecret (decision #5).
334
- config: { ...(options.config ?? {}), ...(options.csrfSecret ? { PAYMENT_CSRF_SECRET: options.csrfSecret } : {}) },
352
+ config: {
353
+ ...(options.config ?? {}),
354
+ PAYMENT_PUBLIC_BASE_PATH: basePath,
355
+ ...(options.csrfSecret ? { PAYMENT_CSRF_SECRET: options.csrfSecret } : {}),
356
+ },
335
357
  db: { sequelize },
336
358
  tenancy: { mode: 'multi' },
337
359
  identity,
@@ -353,6 +375,15 @@ export async function createCloudflarePaymentAdapter(
353
375
  if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
354
376
  (globalThis as any).__flushDeferredTimers();
355
377
  }
378
+ // Embedded binding alias: the standalone worker exposes the payment D1 as `DB`,
379
+ // but under arc the binding is `PAYMENT_DB`. Paths that read `__CF_ENV__.DB`
380
+ // directly — notably the DID-Connect token store (did-connect-token-storage.ts),
381
+ // which mints the wallet-pay QR auth token — would otherwise find no `DB` binding
382
+ // and fail token creation, leaving the QR blank. Alias it to the same payment
383
+ // database (where `_did_connect_tokens` lives) so those paths match standalone.
384
+ if (env && !env.DB && env.PAYMENT_DB) {
385
+ env.DB = env.PAYMENT_DB;
386
+ }
356
387
  (globalThis as any).__CF_ENV__ = env;
357
388
  (globalThis as any).__cfHttpContext__ = httpContext;
358
389
  (globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => ctx.waitUntil(p);
@@ -78,13 +78,51 @@ export function createCloudflareIdentityDriver(): IdentityDriver {
78
78
  const cached = cache.get(instanceDid);
79
79
  if (cached && cached.expires > Date.now()) return cached.value;
80
80
  const svc = authService();
81
- if (!svc || typeof svc.getInstanceAppIdentity !== 'function') {
82
- throw new Error('[did-connect] AUTH_SERVICE.getInstanceAppIdentity unavailable fail-closed');
81
+ // Preferred: the AUTH_SERVICE RPC (multi-tenant / newer blocklet-service resolves
82
+ // the per-instance app:sk). Fall back to the worker's OWN APP_SK/APP_PSK when the
83
+ // binding does not implement the method — which is exactly what blocklet-service's
84
+ // own getInstanceAppIdentity returns for the deployment app (sign.js:
85
+ // appSk = env.BLOCKLET_APP_SK). In single-tenant mode the instance IS the
86
+ // deployment app (instanceDid == APP_PID), so the worker's APP_SK is the correct
87
+ // signer. This makes the standalone worker (and embedded arc CF, which has no
88
+ // getInstanceAppIdentity RPC) self-sufficient for DID-Connect QR signing.
89
+ // Try the AUTH_SERVICE RPC first. NOTE: `typeof svc.getInstanceAppIdentity`
90
+ // is 'function' even on OLD blocklet-service builds (the RPC stub exists) — it
91
+ // only throws "does not implement the method" at CALL time. So we must catch the
92
+ // call itself and fall back, not gate on typeof.
93
+ if (svc && typeof svc.getInstanceAppIdentity === 'function') {
94
+ try {
95
+ const value: InstanceAppIdentity = await svc.getInstanceAppIdentity(instanceDid);
96
+ if (value && value.appSk) {
97
+ cache.set(instanceDid, { value, expires: Date.now() + TTL_MS });
98
+ return value;
99
+ }
100
+ // RPC reachable but returned nothing usable → fall through to local APP_SK.
101
+ } catch (err: any) {
102
+ // RPC not implemented on this AUTH_SERVICE version (or transport error) →
103
+ // fall through to the local APP_SK identity below.
104
+ console.warn(
105
+ '[did-connect] AUTH_SERVICE.getInstanceAppIdentity failed, falling back to local APP_SK:',
106
+ err?.message || err
107
+ );
108
+ }
83
109
  }
84
- const value: InstanceAppIdentity = await svc.getInstanceAppIdentity(instanceDid);
85
- if (!value || !value.appSk) {
86
- throw new Error(`[did-connect] getInstanceAppIdentity returned no appSk for "${instanceDid}" — fail-closed`);
110
+ // Fallback: the worker's OWN APP_SK/APP_PSK — exactly what blocklet-service's
111
+ // getInstanceAppIdentity returns for the deployment app (sign.js: appSk =
112
+ // env.BLOCKLET_APP_SK). In single-tenant mode the instance IS the deployment app
113
+ // (instanceDid == APP_PID), so this is the correct DID-Connect signer. Makes the
114
+ // standalone worker + embedded arc CF (no getInstanceAppIdentity RPC) self-sufficient.
115
+ const env = (globalThis as any).__CF_ENV__ || {};
116
+ const appSk: string | undefined = env.APP_SK;
117
+ if (!appSk) {
118
+ throw new Error(
119
+ '[did-connect] getInstanceAppIdentity unavailable — AUTH_SERVICE lacks the RPC and no local APP_SK to fall back on — fail-closed'
120
+ );
87
121
  }
122
+ const value: InstanceAppIdentity = {
123
+ appSk,
124
+ ...(env.APP_PSK ? { appPsk: env.APP_PSK as string } : {}),
125
+ };
88
126
  cache.set(instanceDid, { value, expires: Date.now() + TTL_MS });
89
127
  return value;
90
128
  },
@@ -280,13 +280,18 @@ const alias = {
280
280
  'mime-types': s('shims/mime-types.ts'), // 150KB — lightweight shim with common MIME types
281
281
  'mime-db': s('shims/noop.ts'),
282
282
  ws: s('shims/ws-lite.ts'), // 66KB — native WebSocket wrapper for CF Workers
283
- 'crypto-js': s('shims/crypto-js-warn.ts'), // 115KB AES legacy not used, warns if called
284
- 'crypto-js/aes': s('shims/crypto-js-warn.ts'),
285
- 'crypto-js/enc-base64': s('shims/noop.ts'),
286
- 'crypto-js/enc-hex': s('shims/noop.ts'),
287
- 'crypto-js/enc-latin1': s('shims/noop.ts'),
288
- 'crypto-js/enc-utf8': s('shims/noop.ts'),
289
- 'crypto-js/enc-utf16': s('shims/noop.ts'),
283
+ // crypto-js previously shimmed under the assumption "AES legacy not used".
284
+ // FALSE since Phase 11 multi-tenant: KeyringSecretsDriver
285
+ // (api/src/libs/drivers/secrets.ts) uses CryptoJS.AES.encrypt/decrypt for
286
+ // per-tenant PaymentMethod settings encryption — shimmed-to-warn made
287
+ // `CryptoJS.AES` undefined at runtime, and admin-side PaymentMethod
288
+ // create/update crashed with "Cannot read properties of undefined (reading
289
+ // 'encrypt')". The real bundle is ~115 KB (pure JS, Workers compatible);
290
+ // shipping the real module is the only correct fix because the same
291
+ // ciphertext must remain decryptable across node and CF tenants. Sub-path
292
+ // imports are not used by the payment graph, so we don't need to re-alias
293
+ // each `crypto-js/...` entry — they'll just no-op-resolve through the main
294
+ // module if anything ever pulls them.
290
295
 
291
296
  // Force ESM-only to avoid CJS+ESM double-bundling. These packages ship an
292
297
  // exports map with separate import/require conditions, so esbuild picks the
@@ -88,7 +88,14 @@ describe('createTenantProvisioner — lazy first-request provisioning, in-flight
88
88
  // A fake embedded service: captures what the adapter forwards (headers, raw body,
89
89
  // basePath) and the tenant context active at fetch time.
90
90
  function fakeSvc() {
91
- const seen: { did?: string; role?: string; tenant?: string; basePath?: string; body?: string } = {};
91
+ const seen: {
92
+ did?: string;
93
+ role?: string;
94
+ tenant?: string;
95
+ publicBaseUrl?: string;
96
+ basePath?: string;
97
+ body?: string;
98
+ } = {};
92
99
  return {
93
100
  seen,
94
101
  http: {
@@ -96,6 +103,7 @@ function fakeSvc() {
96
103
  seen.did = req.headers.get('x-user-did') ?? undefined;
97
104
  seen.role = req.headers.get('x-user-role') ?? undefined;
98
105
  seen.tenant = requestContext.peekInstanceDid();
106
+ seen.publicBaseUrl = requestContext.peekPublicBaseUrl();
99
107
  seen.basePath = opts?.basePath;
100
108
  seen.body = await req.text();
101
109
  return new Response('ok', { status: 200 });
@@ -128,6 +136,7 @@ describe('buildFetch — drives the single http.fetch under the tenant context',
128
136
  expect(res.status).toBe(200);
129
137
  expect(svc.seen.did).toBe('did:abt:zREAL'); // forged stripped, real injected
130
138
  expect(svc.seen.tenant).toBe('did:abt:zTENANT'); // ran under withTenant
139
+ expect(svc.seen.publicBaseUrl).toBe('https://x/.well-known/payment');
131
140
  expect(svc.seen.basePath).toBe('/.well-known/payment');
132
141
  expect(svc.seen.body).toBe('raw-bytes'); // data-damage: raw body preserved
133
142
  expect(flushed).toBe(1);
@@ -10,6 +10,11 @@ import { Client as PgClient } from 'pg';
10
10
  import postgres from 'postgres';
11
11
  import { Hono } from 'hono';
12
12
  import { cors } from 'hono/cors';
13
+ import { getCookie } from 'hono/cookie';
14
+ // Worker-safe CSRF crypto (the SAME module the csrf middleware verifies with —
15
+ // util/csrf passes through to the real package in the CF bundle, see build.ts).
16
+ // eslint-disable-next-line import/no-extraneous-dependencies
17
+ import { sign as signCsrf, getCsrfSecret } from '@blocklet/sdk/lib/util/csrf';
13
18
  import { setDB } from './shims/sequelize-d1/model';
14
19
  import { initialize } from '../api/src/store/models';
15
20
  import { Sequelize } from './shims/sequelize-d1/sequelize-class';
@@ -386,6 +391,20 @@ function buildApp(env: Env): Hono<HonoEnv> {
386
391
  // Flag: HTTP request context. createEvent uses waitUntil (non-blocking).
387
392
  // In queue consumer/cron, this flag is absent — createEvent uses __cfPendingJobs__ (blocking).
388
393
  (globalThis as any).__cfHttpContext__ = true;
394
+ // The @blocklet/sdk CSRF util reads its signing secret DIRECTLY from
395
+ // process.env (`BLOCKLET_APP_ASK || BLOCKLET_APP_SK`), not the config slot. The
396
+ // Phase-12a removal of the per-request process.env mirror left it undefined, so
397
+ // every CSRF-signed response hit `createHmac(<undefined key>)` → 500 (e.g.
398
+ // GET /api/settings). Restore just that one var from the APP_SK binding (the
399
+ // standalone worker's app secret key). Idempotent within the isolate.
400
+ if (c.env.APP_SK && !process.env.BLOCKLET_APP_SK) {
401
+ process.env.BLOCKLET_APP_SK = c.env.APP_SK;
402
+ }
403
+ // Test/staging escape hatch: forward PAYMENT_DISABLE_CSRF to the config boundary
404
+ // so the csrf middleware (readConfig) can honor it. Default-absent → CSRF stays on.
405
+ if ((c.env as any).PAYMENT_DISABLE_CSRF && !process.env.PAYMENT_DISABLE_CSRF) {
406
+ process.env.PAYMENT_DISABLE_CSRF = (c.env as any).PAYMENT_DISABLE_CSRF;
407
+ }
389
408
  setDB(withD1Retry(c.env.DB.withSession('first-primary')));
390
409
  ensureModelsInit();
391
410
 
@@ -496,6 +515,28 @@ function buildApp(env: Env): Hono<HonoEnv> {
496
515
  // Health check
497
516
  app.get('/health', (c) => c.json({ status: 'ok' }));
498
517
 
518
+ // did/csrfToken — the @blocklet/js-sdk axios adapter fetches its CSRF header from
519
+ // THIS endpoint (getCSRFTokenByLoginToken), not from the cookie. The shared
520
+ // blocklet-service (AUTH_SERVICE) only implements it on newer versions, so the
521
+ // catch-all proxy below 404s on older AUTH_SERVICE — leaving the SDK with no CSRF
522
+ // header and every mutating request 403'ing ("csrf token mismatch"). Sign it
523
+ // LOCALLY with the SAME secret + algorithm the payment csrf middleware verifies
524
+ // with (util/csrf sign over login_token), so token == cookie == what verify()
525
+ // expects. Must be registered BEFORE the /.well-known/service/* catch-all. This
526
+ // also makes the embedded arc-node CF runtime self-sufficient (no AUTH_SERVICE
527
+ // csrfToken endpoint required).
528
+ app.get('/.well-known/service/api/did/csrfToken', (c) => {
529
+ const loginToken = getCookie(c, 'login_token');
530
+ if (!loginToken) return c.json({ loginToken: null, csrfToken: null });
531
+ // Same secret resolution as the csrf middleware's csrfSecret() (PAYMENT_CSRF_SECRET
532
+ // override else the app secret key). Read APP_SK from the binding directly so we
533
+ // do not depend on the process.env.BLOCKLET_APP_SK mirror timing; getCsrfSecret()
534
+ // is the final fallback.
535
+ const secret = (c.env as any).PAYMENT_CSRF_SECRET || c.env.APP_SK || getCsrfSecret();
536
+ if (!secret) return c.json({ loginToken, csrfToken: null });
537
+ return c.json({ loginToken, csrfToken: signCsrf(secret, loginToken) });
538
+ });
539
+
499
540
  // user-session under /.well-known/service — proxy to blocklet-service session API
500
541
  // Returns full session data including connectedAccounts (needed by @arcblock/ux SessionUser)
501
542
  app.get('/.well-known/service/api/user-session', async (c) => {
@@ -691,7 +732,6 @@ function buildApp(env: Env): Hono<HonoEnv> {
691
732
  startsWithZ: sk.startsWith('z'),
692
733
  });
693
734
  });
694
-
695
735
  // === DID Auth Login routes ===
696
736
  // Only proxy login-related /api/did/* paths to blocklet-service.
697
737
  // Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's own
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.29.8",
3
+ "version": "1.29.9",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "prelint": "npm run types",
@@ -48,7 +48,7 @@
48
48
  "dependencies": {
49
49
  "@abtnode/cron": "^1.17.13-beta-20260613-094425-b81920c8",
50
50
  "@arcblock/did": "^1.30.24",
51
- "@arcblock/did-connect-js": "^4.1.0",
51
+ "@arcblock/did-connect-js": "^4.1.2",
52
52
  "@arcblock/did-connect-react": "^3.5.4",
53
53
  "@arcblock/did-connect-storage-nedb": "^1.8.0",
54
54
  "@arcblock/did-util": "^1.30.24",
@@ -60,13 +60,13 @@
60
60
  "@blocklet/error": "^0.3.5",
61
61
  "@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
62
62
  "@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
63
- "@blocklet/payment-broker-client": "1.29.8",
64
- "@blocklet/payment-react": "1.29.8",
65
- "@blocklet/payment-vendor": "1.29.8",
63
+ "@blocklet/payment-broker-client": "1.29.9",
64
+ "@blocklet/payment-react": "1.29.9",
65
+ "@blocklet/payment-vendor": "1.29.9",
66
66
  "@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
67
67
  "@blocklet/ui-react": "^3.5.4",
68
- "@blocklet/uploader": "^0.3.20",
69
- "@blocklet/xss": "^0.3.16",
68
+ "@blocklet/uploader": "^0.15.4",
69
+ "@blocklet/xss": "^0.15.4",
70
70
  "@hono/node-server": "2.0.4",
71
71
  "@mui/icons-material": "^7.1.2",
72
72
  "@mui/lab": "7.0.0-beta.14",
@@ -133,7 +133,7 @@
133
133
  "devDependencies": {
134
134
  "@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
135
135
  "@arcblock/eslint-config-ts": "^0.3.3",
136
- "@blocklet/payment-types": "1.29.8",
136
+ "@blocklet/payment-types": "1.29.9",
137
137
  "@types/connect": "^3.4.38",
138
138
  "@types/debug": "^4.1.12",
139
139
  "@types/dotenv-flow": "^3.3.3",
@@ -180,5 +180,5 @@
180
180
  "parser": "typescript"
181
181
  }
182
182
  },
183
- "gitHead": "547c0614f1f83abad80ea08452d2f5a9f4f5aa07"
183
+ "gitHead": "249bd9bf3c6cc54f77c260edb4b4d3c0be60f126"
184
184
  }
@@ -34,7 +34,13 @@ export default function UserLayout(props: any) {
34
34
  return (
35
35
  <PaymentProvider session={session} connect={connectApi}>
36
36
  <UserCenter
37
- currentTab={`${window.blocklet.prefix}customer`}
37
+ // Must match the userCenter nav link declared in blocklet.yml (`link: /customer`).
38
+ // UserCenter resolves both this prop and the nav link via `new URL(x, origin).pathname`,
39
+ // which strips any mount prefix — so the nav tab always resolves to the bare `/customer`.
40
+ // Prefixing here (e.g. `${prefix}customer`) makes the active tab fail to match, which
41
+ // triggers UserCenter's auto-redirect to the bare nav link and 404s the embedded mount
42
+ // (#1394). The bare path also works in the non-embedded form, where prefix is `/`.
43
+ currentTab="/customer"
38
44
  hideFooter
39
45
  embed={embed === '1'}
40
46
  notLoginContent="undefined">
@@ -29,6 +29,25 @@ export default defineConfig({
29
29
  root: coreDir,
30
30
  base: `${UI_PREFIX}/`,
31
31
  plugins: [
32
+ // Inject the Buffer polyfill at the top of the entry — the did-connect
33
+ // SessionProvider.refresh path serializes request bodies with Buffer, which is
34
+ // undefined in the browser, so without this the arc-embedded payment SPA throws
35
+ // "ReferenceError: Buffer is not defined" on session refresh and never renders.
36
+ // The standalone cloudflare/vite.config.ts already does this (cf-buffer-polyfill);
37
+ // the arc build was missing it. Reuses the same shim.
38
+ {
39
+ name: 'arc-buffer-polyfill',
40
+ enforce: 'pre' as const,
41
+ transform(code: string, id: string) {
42
+ if (id.endsWith('/src/index.tsx')) {
43
+ const shim = path
44
+ .resolve(coreDir, 'cloudflare/frontend-shims/buffer-polyfill.ts')
45
+ .replace(/\\/g, '/');
46
+ return `import '${shim}';\n` + code;
47
+ }
48
+ return undefined;
49
+ },
50
+ },
32
51
  tsconfigPaths({ root: coreDir }),
33
52
  react(),
34
53
  // Build-time window.blocklet bootstrap (P2 helper, single source). The