payment-kit 1.29.7 → 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.
Files changed (32) hide show
  1. package/api/src/host-glue.ts +79 -0
  2. package/api/src/host-node/serve-static-arc.ts +1 -0
  3. package/api/src/integrations/app-store/native-asn1.ts +1 -0
  4. package/api/src/integrations/app-store/signed-data-verifier.ts +6 -1
  5. package/api/src/integrations/stripe/setup.ts +3 -3
  6. package/api/src/libs/arcblock-transfer.ts +27 -0
  7. package/api/src/libs/context.ts +10 -2
  8. package/api/src/libs/did-connect/tenant-identity.ts +19 -1
  9. package/api/src/libs/env.ts +65 -22
  10. package/api/src/libs/notification/index.ts +4 -3
  11. package/api/src/libs/util.ts +31 -2
  12. package/api/src/middlewares/hono/csrf.ts +6 -0
  13. package/api/src/middlewares/hono/session.ts +22 -0
  14. package/api/src/routes/connect/pay.ts +21 -9
  15. package/api/src/routes/hono/credit-grants.ts +1 -1
  16. package/api/src/routes/hono/entitlements.ts +8 -1
  17. package/api/src/routes/hono/payment-methods.ts +6 -2
  18. package/api/src/service.ts +42 -4
  19. package/api/src/store/models/payment-currency.ts +7 -1
  20. package/api/src/store/models/payment-method.ts +30 -6
  21. package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
  22. package/api/tests/libs/notification-guard.spec.ts +25 -8
  23. package/api/tests/libs/util.spec.ts +51 -0
  24. package/blocklet.yml +1 -1
  25. package/cloudflare/cf-adapter.ts +46 -50
  26. package/cloudflare/did-connect-runtime.ts +43 -5
  27. package/cloudflare/esbuild-cf-config.cjs +12 -7
  28. package/cloudflare/tests/cf-adapter.spec.ts +12 -2
  29. package/cloudflare/worker.ts +41 -1
  30. package/package.json +9 -9
  31. package/src/components/layout/user.tsx +7 -1
  32. package/vite.arc.config.ts +19 -0
@@ -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
+ });
@@ -5,13 +5,17 @@
5
5
  // environments" off Blocklet Server (arc-node / CF). The guard no-ops both SDK
6
6
  // transports there — at the module level, so app-side logic still runs and only
7
7
  // the send/publish is skipped — and leaves Blocklet Server untouched. Gated on the
8
- // single reusable predicate isBlockletServer() (= BLOCKLET_APP_ID present).
8
+ // single reusable predicate isBlockletServer() (= BLOCKLET_DID present; the CF
9
+ // worker injects BLOCKLET_APP_ID = APP_PID so that can NOT be the marker).
9
10
  describe('ensureBlockletSdkTransportGuard', () => {
10
- const ORIG = process.env.BLOCKLET_APP_ID;
11
+ const ORIG = process.env.BLOCKLET_DID;
12
+ const ORIG_APP_ID = process.env.BLOCKLET_APP_ID;
11
13
 
12
14
  afterEach(() => {
13
- if (ORIG === undefined) delete process.env.BLOCKLET_APP_ID;
14
- else process.env.BLOCKLET_APP_ID = ORIG;
15
+ if (ORIG === undefined) delete process.env.BLOCKLET_DID;
16
+ else process.env.BLOCKLET_DID = ORIG;
17
+ if (ORIG_APP_ID === undefined) delete process.env.BLOCKLET_APP_ID;
18
+ else process.env.BLOCKLET_APP_ID = ORIG_APP_ID;
15
19
  jest.resetModules();
16
20
  });
17
21
 
@@ -33,8 +37,8 @@ describe('ensureBlockletSdkTransportGuard', () => {
33
37
  return { notification, eventbus };
34
38
  }
35
39
 
36
- it('off Blocklet Server (no BLOCKLET_APP_ID): no-ops notification + eventbus on module + default', async () => {
37
- delete process.env.BLOCKLET_APP_ID;
40
+ it('off Blocklet Server (no BLOCKLET_DID): no-ops notification + eventbus on module + default', async () => {
41
+ delete process.env.BLOCKLET_DID;
38
42
  const { notification, eventbus } = loadAndGuard();
39
43
  for (const target of [notification, notification.default]) {
40
44
  expect(target.sendToUser).not.toBe('REAL');
@@ -50,12 +54,25 @@ describe('ensureBlockletSdkTransportGuard', () => {
50
54
  }
51
55
  });
52
56
 
53
- it('on Blocklet Server (BLOCKLET_APP_ID set): leaves both transports intact', () => {
54
- process.env.BLOCKLET_APP_ID = 'z_app_pid';
57
+ it('on Blocklet Server (BLOCKLET_DID set): leaves both transports intact', () => {
58
+ process.env.BLOCKLET_DID = 'z_node_did';
55
59
  const { notification, eventbus } = loadAndGuard();
56
60
  expect(notification.sendToUser).toBe('REAL');
57
61
  expect(notification.default.sendToUser).toBe('REAL');
58
62
  expect(eventbus.publish).toBe('REAL');
59
63
  expect(eventbus.default.publish).toBe('REAL');
60
64
  });
65
+
66
+ // Regression: the CF worker injects BLOCKLET_APP_ID = APP_PID for app identity,
67
+ // which used to make isBlockletServer() spuriously true and skip the guard — then
68
+ // the SDK transport crashed at send time. With the marker on BLOCKLET_DID, an
69
+ // APP_ID without a DID is correctly treated as off-Blocklet-Server.
70
+ it('CF worker (BLOCKLET_APP_ID set, no BLOCKLET_DID): still no-ops the transports', async () => {
71
+ delete process.env.BLOCKLET_DID;
72
+ process.env.BLOCKLET_APP_ID = 'z_app_pid';
73
+ const { notification, eventbus } = loadAndGuard();
74
+ expect(notification.sendToUser).not.toBe('REAL');
75
+ expect(eventbus.publish).not.toBe('REAL');
76
+ await expect(notification.sendToUser('zUser', { title: 't' })).resolves.toBeUndefined();
77
+ });
61
78
  });
@@ -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.7
17
+ version: 1.29.9
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
@@ -47,13 +47,18 @@ 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';
54
54
  import { withD1Retry } from './shims/sequelize-d1/retry';
55
55
  import { cronInstance } from './shims/cron';
56
56
  import { createCloudflareDidConnectRuntime, createCloudflareIdentityDriver } from './did-connect-runtime';
57
+ // Runtime-neutral host glue — single source of truth in the core source tree,
58
+ // shared with the arc-node daemon (via the package main entry's `as string`
59
+ // facade). Re-exported below so existing importers (cf-adapter.spec.ts) keep
60
+ // resolving them from this module.
61
+ import { injectCaller, createTenantProvisioner } from '../api/src/host-glue';
57
62
 
58
63
  /**
59
64
  * The reserved mount prefix for the embedded payment service. The internal
@@ -106,6 +111,14 @@ export interface CloudflarePaymentAdapterOptions {
106
111
  /** Per-tenant EK source (semantics aligned with arc-node: connect-service `app:ek` else derive). */
107
112
  identity: {
108
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;
109
122
  };
110
123
  /**
111
124
  * Host-resolved caller (CF API gate). The adapter injects it as `x-user-did`
@@ -142,52 +155,12 @@ export interface CloudflarePaymentAdapter {
142
155
  ensureSchema: () => Promise<string[]>;
143
156
  }
144
157
 
145
- /** Client-forged identity headers always stripped before the host injects the real caller. */
146
- const USER_HEADERS = ['x-user-did', 'x-user-role', 'x-user-provider', 'x-user-fullname', 'x-user-wallet-os'];
147
-
148
- /**
149
- * CF API gate caller header glue (decision #5). STRIP any client-supplied
150
- * `x-user-*` first (a forged `x-user-did` would be an auth bypass), then inject the
151
- * host-resolved caller as canonical headers the core `authenticate()` trusts. No
152
- * caller → the request runs anonymous (the strip already happened, so it is never
153
- * forged). Mirrors the standalone worker's identical strip-then-inject.
154
- */
155
- export function injectCaller(headers: Headers, caller: CloudflareCallerIdentity | null): void {
156
- for (const h of USER_HEADERS) headers.delete(h);
157
- if (!caller) return;
158
- const canonicalDid = caller.did?.startsWith('did:abt:') ? caller.did : `did:abt:${caller.did}`;
159
- headers.set('x-user-did', canonicalDid);
160
- headers.set('x-user-role', `blocklet-${caller.role || 'guest'}`);
161
- headers.set('x-user-provider', caller.authMethod === 'access-key' ? 'access-key' : caller.authMethod || 'wallet');
162
- headers.set('x-user-fullname', encodeURIComponent(caller.displayName || ''));
163
- headers.set('x-user-wallet-os', '');
164
- }
165
-
166
- /**
167
- * Lazy per-tenant provisioning with in-flight dedup. Concurrent first-requests for
168
- * the same tenant await the SAME provision() call — a Set would let a burst at
169
- * isolate start double-seed before the DB idempotency guard commits. A resolved
170
- * promise stays cached so later requests are instant; a FAILED provision is dropped
171
- * so the next request retries. Null tenant is a no-op. Copied from the node witness
172
- * (runtimes/node/src/daemon/payment/index.ts:72-105).
173
- */
174
- export function createTenantProvisioner(
175
- provision: (instanceDid: string) => Promise<void>,
176
- ): (instanceDid: string | null) => Promise<void> {
177
- const inflight = new Map<string, Promise<void>>();
178
- return (instanceDid) => {
179
- if (!instanceDid) return Promise.resolve();
180
- let p = inflight.get(instanceDid);
181
- if (!p) {
182
- p = provision(instanceDid).catch((err) => {
183
- inflight.delete(instanceDid); // drop so the next request retries
184
- throw err;
185
- });
186
- inflight.set(instanceDid, p);
187
- }
188
- return p;
189
- };
190
- }
158
+ // `injectCaller` (CF API gate caller header glue) and `createTenantProvisioner`
159
+ // (lazy-provision in-flight dedup) now live in the runtime-neutral
160
+ // `../api/src/host-glue` — a single source shared with the arc-node daemon (via
161
+ // the package main entry). Re-exported here so existing importers keep resolving
162
+ // them from this module.
163
+ export { injectCaller, createTenantProvisioner };
191
164
 
192
165
  /** Dependencies for the request handler (factored out so it is testable with a fake service). */
193
166
  export interface FetchDeps {
@@ -245,7 +218,11 @@ export function buildFetch(deps: FetchDeps) {
245
218
  if (instanceDid) await warmTenantIdentity(instanceDid);
246
219
  return deps.svc.http.fetch(forwarded, { basePath: deps.basePath });
247
220
  };
248
- 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
+ );
249
226
 
250
227
  await deps.flush(); // drain workerd deferred queue work before responding
251
228
  return res;
@@ -358,7 +335,13 @@ export async function createCloudflarePaymentAdapter(
358
335
  const identity: IdentityDriver = {
359
336
  resolveInstanceDidForHost: () => null,
360
337
  getAppEk: (instanceDid: string) => options.identity.getAppEk(instanceDid),
361
- 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),
362
345
  // per-tenant business wallet (single seam); directory stays the CF alias shim
363
346
  getBusinessWallet: cfDidConnectIdentity.getBusinessWallet,
364
347
  };
@@ -366,7 +349,11 @@ export async function createCloudflarePaymentAdapter(
366
349
  const svc: EmbeddedPaymentService = createEmbeddedPaymentService({
367
350
  // Host-global csrf secret via the config boundary (payment-core's csrf reads
368
351
  // PAYMENT_CSRF_SECRET through readConfig). NO sessionSecret (decision #5).
369
- 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
+ },
370
357
  db: { sequelize },
371
358
  tenancy: { mode: 'multi' },
372
359
  identity,
@@ -388,6 +375,15 @@ export async function createCloudflarePaymentAdapter(
388
375
  if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
389
376
  (globalThis as any).__flushDeferredTimers();
390
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
+ }
391
387
  (globalThis as any).__CF_ENV__ = env;
392
388
  (globalThis as any).__cfHttpContext__ = httpContext;
393
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
@@ -16,7 +16,8 @@ describe('injectCaller — CF API gate caller header glue (decision #5)', () =>
16
16
  const h = new Headers();
17
17
  injectCaller(h, { did: 'zUSER', role: 'admin', authMethod: 'passkey', displayName: 'Alice' });
18
18
  expect(h.get('x-user-did')).toBe('did:abt:zUSER');
19
- expect(h.get('x-user-role')).toBe('blocklet-admin');
19
+ // raw role — NO `blocklet-` prefix (the core strips it anyway; a prefixed role is wrong)
20
+ expect(h.get('x-user-role')).toBe('admin');
20
21
  expect(h.get('x-user-provider')).toBe('passkey');
21
22
  expect(h.get('x-user-fullname')).toBe(encodeURIComponent('Alice'));
22
23
  });
@@ -87,7 +88,14 @@ describe('createTenantProvisioner — lazy first-request provisioning, in-flight
87
88
  // A fake embedded service: captures what the adapter forwards (headers, raw body,
88
89
  // basePath) and the tenant context active at fetch time.
89
90
  function fakeSvc() {
90
- 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
+ } = {};
91
99
  return {
92
100
  seen,
93
101
  http: {
@@ -95,6 +103,7 @@ function fakeSvc() {
95
103
  seen.did = req.headers.get('x-user-did') ?? undefined;
96
104
  seen.role = req.headers.get('x-user-role') ?? undefined;
97
105
  seen.tenant = requestContext.peekInstanceDid();
106
+ seen.publicBaseUrl = requestContext.peekPublicBaseUrl();
98
107
  seen.basePath = opts?.basePath;
99
108
  seen.body = await req.text();
100
109
  return new Response('ok', { status: 200 });
@@ -127,6 +136,7 @@ describe('buildFetch — drives the single http.fetch under the tenant context',
127
136
  expect(res.status).toBe(200);
128
137
  expect(svc.seen.did).toBe('did:abt:zREAL'); // forged stripped, real injected
129
138
  expect(svc.seen.tenant).toBe('did:abt:zTENANT'); // ran under withTenant
139
+ expect(svc.seen.publicBaseUrl).toBe('https://x/.well-known/payment');
130
140
  expect(svc.seen.basePath).toBe('/.well-known/payment');
131
141
  expect(svc.seen.body).toBe('raw-bytes'); // data-damage: raw body preserved
132
142
  expect(flushed).toBe(1);