payment-kit 1.29.8 → 1.29.10

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 (49) hide show
  1. package/api/src/host-node/serve-static-arc.ts +1 -0
  2. package/api/src/integrations/app-store/handlers/subscription.ts +55 -4
  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/google-play/handlers/subscription.ts +1 -1
  6. package/api/src/integrations/google-play/handlers/voided.ts +1 -1
  7. package/api/src/integrations/stripe/handlers/payment-intent.ts +4 -2
  8. package/api/src/integrations/stripe/handlers/subscription.ts +1 -1
  9. package/api/src/integrations/stripe/reconcile-checkout-core.ts +60 -0
  10. package/api/src/integrations/stripe/reconcile-checkout.ts +40 -0
  11. package/api/src/integrations/stripe/setup.ts +3 -3
  12. package/api/src/libs/arcblock-transfer.ts +27 -0
  13. package/api/src/libs/context.ts +10 -2
  14. package/api/src/libs/did-connect/tenant-identity.ts +19 -1
  15. package/api/src/libs/entitlement.ts +12 -0
  16. package/api/src/libs/env.ts +1 -0
  17. package/api/src/libs/notification/index.ts +4 -3
  18. package/api/src/libs/util.ts +31 -2
  19. package/api/src/middlewares/hono/csrf.ts +6 -0
  20. package/api/src/middlewares/hono/security.ts +47 -2
  21. package/api/src/middlewares/hono/session.ts +22 -0
  22. package/api/src/routes/connect/pay.ts +21 -9
  23. package/api/src/routes/hono/checkout-sessions.ts +71 -32
  24. package/api/src/routes/hono/entitlements.ts +8 -1
  25. package/api/src/routes/hono/payment-methods.ts +6 -2
  26. package/api/src/service.ts +42 -4
  27. package/api/src/store/models/payment-currency.ts +7 -1
  28. package/api/src/store/models/payment-method.ts +30 -6
  29. package/api/src/store/models/subscription.ts +3 -1
  30. package/api/tests/integrations/app-store/handlers.spec.ts +13 -2
  31. package/api/tests/integrations/stripe/reconcile-checkout.spec.ts +80 -0
  32. package/api/tests/libs/arcblock-transfer.spec.ts +58 -0
  33. package/api/tests/libs/util.spec.ts +51 -0
  34. package/blocklet.yml +1 -1
  35. package/cloudflare/cf-adapter.ts +35 -4
  36. package/cloudflare/did-connect-runtime.ts +43 -5
  37. package/cloudflare/esbuild-cf-config.cjs +12 -7
  38. package/cloudflare/tests/cf-adapter.spec.ts +10 -1
  39. package/cloudflare/worker.ts +41 -1
  40. package/package.json +9 -9
  41. package/src/components/layout/user.tsx +7 -1
  42. package/src/libs/did.ts +27 -0
  43. package/src/pages/customer/credit-grant/detail.tsx +2 -1
  44. package/src/pages/customer/credit-transaction/detail.tsx +2 -1
  45. package/src/pages/customer/invoice/detail.tsx +2 -1
  46. package/src/pages/customer/recharge/subscription.tsx +2 -1
  47. package/src/pages/customer/subscription/detail.tsx +2 -1
  48. package/tests/libs/did.spec.ts +43 -0
  49. package/vite.arc.config.ts +19 -0
@@ -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.10
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.10",
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.10",
64
+ "@blocklet/payment-react": "1.29.10",
65
+ "@blocklet/payment-vendor": "1.29.10",
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.10",
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": "18c831f5dbdfb271a96640befb06f589adbeeab0"
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">
@@ -0,0 +1,27 @@
1
+ // DID comparison helpers shared by the customer portal pages.
2
+ //
3
+ // A single ArcBlock identity shows up in two string shapes across the stack:
4
+ // - bare base58 address (e.g. `z1xxx`)
5
+ // - canonical URI form (e.g. `did:abt:z1xxx`)
6
+ //
7
+ // On CF Workers the host AUTH_SERVICE hands the frontend a bare address while
8
+ // the backend canonicalizes login-token DIDs to the `did:abt:` form before it
9
+ // stores `customer.did` (see api/src/middlewares/hono/security.ts and
10
+ // api/src/routes/hono/entitlements.ts `canonicalDid`/`isSelf`). A raw `!==`
11
+ // between `session.user.did` and `customer.did` therefore flags the very owner
12
+ // of the record as "another customer". Normalize both sides before comparing.
13
+
14
+ const DID_PREFIX = 'did:abt:';
15
+
16
+ export function canonicalizeDid(did: string | undefined | null): string {
17
+ if (!did) return '';
18
+ return did.startsWith(DID_PREFIX) ? did.slice(DID_PREFIX.length) : did;
19
+ }
20
+
21
+ // True when both DIDs resolve to the same identity, ignoring the `did:abt:`
22
+ // prefix difference. Empty/missing inputs are never considered a match.
23
+ export function isSameDid(a: string | undefined | null, b: string | undefined | null): boolean {
24
+ const x = canonicalizeDid(a);
25
+ const y = canonicalizeDid(b);
26
+ return !!x && x === y;
27
+ }
@@ -24,6 +24,7 @@ import CreditGrantItemList from '../../../components/customer/credit-grant-item-
24
24
  import InfoRow from '../../../components/info-row';
25
25
  import InfoRowGroup from '../../../components/info-row-group';
26
26
  import { useArcsphere } from '../../../hooks/browser';
27
+ import { isSameDid } from '../../../libs/did';
27
28
 
28
29
  const fetchData = (id: string | undefined): Promise<TCreditGrantExpanded> => {
29
30
  return api.get(`/api/credit-grants/${id}`).then((res: any) => res.data);
@@ -41,7 +42,7 @@ export default function CustomerCreditGrantDetail() {
41
42
  navigate('/customer', { replace: true });
42
43
  }, [navigate]);
43
44
 
44
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
45
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
45
46
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
46
47
  }
47
48
  if (error) {
@@ -21,6 +21,7 @@ import SectionHeader from '../../../components/section/header';
21
21
  import InfoRow from '../../../components/info-row';
22
22
  import InfoRowGroup from '../../../components/info-row-group';
23
23
  import { useArcsphere } from '../../../hooks/browser';
24
+ import { isSameDid } from '../../../libs/did';
24
25
 
25
26
  const fetchData = (id: string | undefined): Promise<TCreditTransactionExpanded> => {
26
27
  return api.get(`/api/credit-transactions/${id}`).then((res: any) => res.data);
@@ -38,7 +39,7 @@ export default function CustomerCreditTransactionDetail() {
38
39
  navigate('/customer', { replace: true });
39
40
  }, [navigate]);
40
41
 
41
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
42
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
42
43
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
43
44
  }
44
45
 
@@ -34,6 +34,7 @@ import InfoRow from '../../../components/info-row';
34
34
  import { Download } from '../../../components/invoice-pdf/pdf';
35
35
  import InvoiceTable from '../../../components/invoice/table';
36
36
  import { goBackOrFallback } from '../../../libs/util';
37
+ import { isSameDid } from '../../../libs/did';
37
38
  import CustomerRefundList from '../refund/list';
38
39
  import InfoMetric from '../../../components/info-metric';
39
40
  import InfoRowGroup from '../../../components/info-row-group';
@@ -121,7 +122,7 @@ export default function CustomerInvoiceDetail() {
121
122
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
123
  }, [error]);
123
124
 
124
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
125
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
125
126
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
126
127
  }
127
128
 
@@ -36,6 +36,7 @@ import InfoRow from '../../../components/info-row';
36
36
  import Currency from '../../../components/currency';
37
37
  import SubscriptionMetrics from '../../../components/subscription/metrics';
38
38
  import { goBackOrFallback, MAX_SAFE_AMOUNT } from '../../../libs/util';
39
+ import { isSameDid } from '../../../libs/did';
39
40
  import CustomerLink from '../../../components/customer/link';
40
41
  import { useSessionContext } from '../../../contexts/session';
41
42
  import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
@@ -227,7 +228,7 @@ export default function RechargePage() {
227
228
  return <Alert severity="info">{t('common.dataNotFound')}</Alert>;
228
229
  }
229
230
 
230
- if (subscription?.customer?.did && session?.user?.did && subscription.customer.did !== session.user.did) {
231
+ if (subscription?.customer?.did && session?.user?.did && !isSameDid(subscription.customer.did, session.user.did)) {
231
232
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
232
233
  }
233
234
  const currentBalance = formatBNStr(payerValue?.token || '0', paymentCurrency?.decimal, 6, false);
@@ -67,6 +67,7 @@ import {
67
67
  } from '../../../hooks/subscription';
68
68
  import { formatSmartDuration, TimeUnit } from '../../../libs/dayjs';
69
69
  import { canChangePaymentMethod } from '../../../libs/util';
70
+ import { isSameDid } from '../../../libs/did';
70
71
  import PaymentMethodInfo from '../../../components/subscription/payment-method-info';
71
72
  import { useArcsphere } from '../../../hooks/browser';
72
73
 
@@ -260,7 +261,7 @@ export default function CustomerSubscriptionDetail() {
260
261
  }
261
262
  };
262
263
 
263
- if (data?.customer?.did && session?.user?.did && data.customer.did !== session.user.did) {
264
+ if (data?.customer?.did && session?.user?.did && !isSameDid(data.customer.did, session.user.did)) {
264
265
  return <Alert severity="error">{t('common.accessDenied')}</Alert>;
265
266
  }
266
267
  if (error) {
@@ -0,0 +1,43 @@
1
+ import { canonicalizeDid, isSameDid } from '../../src/libs/did';
2
+
3
+ describe('canonicalizeDid', () => {
4
+ it('strips the did:abt: prefix', () => {
5
+ expect(canonicalizeDid('did:abt:z1xyz')).toBe('z1xyz');
6
+ });
7
+
8
+ it('leaves bare addresses untouched', () => {
9
+ expect(canonicalizeDid('z1xyz')).toBe('z1xyz');
10
+ });
11
+
12
+ it('returns empty string for nullish input', () => {
13
+ expect(canonicalizeDid(undefined)).toBe('');
14
+ expect(canonicalizeDid(null)).toBe('');
15
+ expect(canonicalizeDid('')).toBe('');
16
+ });
17
+ });
18
+
19
+ describe('isSameDid', () => {
20
+ // The regression: CF stores customer.did canonical while the host session
21
+ // reports a bare address. The old `data.customer.did !== session.user.did`
22
+ // check wrongly denied the owner. isSameDid must treat these as equal.
23
+ it('matches a canonical DID against the same bare address', () => {
24
+ expect(isSameDid('did:abt:z1xyz', 'z1xyz')).toBe(true);
25
+ expect(isSameDid('z1xyz', 'did:abt:z1xyz')).toBe(true);
26
+ });
27
+
28
+ it('matches identical shapes', () => {
29
+ expect(isSameDid('z1xyz', 'z1xyz')).toBe(true);
30
+ expect(isSameDid('did:abt:z1xyz', 'did:abt:z1xyz')).toBe(true);
31
+ });
32
+
33
+ it('rejects genuinely different identities', () => {
34
+ expect(isSameDid('did:abt:z1xyz', 'z1abc')).toBe(false);
35
+ expect(isSameDid('z1xyz', 'z1abc')).toBe(false);
36
+ });
37
+
38
+ it('never matches when either side is missing', () => {
39
+ expect(isSameDid('', 'z1xyz')).toBe(false);
40
+ expect(isSameDid('z1xyz', undefined)).toBe(false);
41
+ expect(isSameDid(null, null)).toBe(false);
42
+ });
43
+ });
@@ -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