payment-kit 1.29.2 → 1.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/api/src/bootstrap.ts +11 -0
  2. package/api/src/crons/index.ts +14 -13
  3. package/api/src/crons/tenant-fanout.ts +82 -0
  4. package/api/src/host-node/did-connect-runtime-node.ts +33 -0
  5. package/api/src/host-node/serve-static-arc.ts +68 -0
  6. package/api/src/host-node/serve-static.ts +41 -0
  7. package/api/src/libs/auth.ts +166 -27
  8. package/api/src/libs/context.ts +11 -0
  9. package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
  10. package/api/src/libs/did-connect/tenant-identity.ts +221 -0
  11. package/api/src/libs/drivers/identity.ts +61 -0
  12. package/api/src/libs/drivers/index.ts +1 -1
  13. package/api/src/libs/http-fetch-adapter.ts +11 -1
  14. package/api/src/libs/queue/index.ts +14 -2
  15. package/api/src/middlewares/hono/context.ts +7 -0
  16. package/api/src/middlewares/hono/csrf.ts +13 -2
  17. package/api/src/middlewares/hono/security.ts +6 -11
  18. package/api/src/queues/checkout-session.ts +21 -9
  19. package/api/src/queues/event.ts +29 -7
  20. package/api/src/queues/payment.ts +23 -9
  21. package/api/src/queues/payout.ts +28 -16
  22. package/api/src/queues/refund.ts +18 -6
  23. package/api/src/routes/hono/customers.ts +6 -1
  24. package/api/src/routes/hono/refunds.ts +2 -3
  25. package/api/src/service.ts +178 -31
  26. package/api/src/store/sequelize.ts +16 -1
  27. package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
  28. package/api/tests/crons/tenant-fanout.spec.ts +158 -0
  29. package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
  30. package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
  31. package/api/tests/libs/service-host.spec.ts +37 -0
  32. package/api/tests/queues/event-tenant.spec.ts +60 -4
  33. package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
  34. package/api/tests/service/fail-closed-http.spec.ts +79 -0
  35. package/api/tests/service/static-arc-handler.spec.ts +101 -0
  36. package/api/tests/service/static-externalized.spec.ts +48 -0
  37. package/blocklet.yml +1 -1
  38. package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
  39. package/cloudflare/README.md +8 -21
  40. package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
  41. package/cloudflare/build.ts +10 -5
  42. package/cloudflare/cf-adapter.ts +419 -0
  43. package/cloudflare/did-connect-runtime.ts +96 -0
  44. package/cloudflare/did-connect-token-storage.ts +151 -0
  45. package/cloudflare/esbuild-cf-config.cjs +407 -0
  46. package/cloudflare/run-build.js +33 -357
  47. package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
  48. package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
  49. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
  50. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
  51. package/cloudflare/tests/cf-adapter.spec.ts +244 -0
  52. package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
  53. package/cloudflare/tests/worker-handler-gate.spec.ts +35 -10
  54. package/cloudflare/vite.config.ts +53 -45
  55. package/cloudflare/worker.ts +98 -56
  56. package/cloudflare/wrangler.json +0 -6
  57. package/cloudflare/wrangler.jsonc +0 -6
  58. package/cloudflare/wrangler.local-e2e.jsonc +0 -1
  59. package/cloudflare/wrangler.staging.json +0 -6
  60. package/package.json +7 -7
  61. package/scripts/bootstrap-inject.ts +166 -0
  62. package/src/app.tsx +2 -1
  63. package/src/libs/service-host.ts +13 -0
  64. package/vite.arc.config.ts +159 -0
  65. package/cloudflare/did-connect-auth.ts +0 -310
  66. package/cloudflare/shims/blocklet-sdk/util-csrf.ts +0 -13
  67. package/cloudflare/shims/blocklet-sdk/util-wallet.ts +0 -8
@@ -19,13 +19,13 @@ import { Sequelize } from './shims/sequelize-d1/sequelize-class';
19
19
  // loop). Side-effect import — keep it ahead of service/crons/queues below.
20
20
  import './queue-runtime-mode';
21
21
 
22
- // Phase 12a (Option 3 seam): the worker's /api business surface now comes from
23
- // the embedded payment service factory instead of a direct routes import. The
24
- // factory performs assembly (config slot authoritative via setCoreConfig + model
25
- // initialize), and exposes `http.resourceRoutes` the resource routers only,
26
- // no node app shell, no DID-Connect handlers (the worker registers those through
27
- // its own Hono `attachDIDConnectRoutes`). The factory's lazy `handler` getter is
28
- // never touched here, so the node-only app shell never runs under the shim.
22
+ // Phase 12a (Option 3 seam) + S3-CF Phase 1B: the worker's /api surface comes from
23
+ // the embedded payment service factory. The factory performs assembly (config slot
24
+ // authoritative via setCoreConfig + model initialize). Phase 1B: the worker forwards
25
+ // every payment /api/* (business resource routes + DID payment actions) through the
26
+ // runtime-neutral `http.fetch` (the core full app + full pipeline) a SINGLE
27
+ // surface. The factory's lazy `handler` getter is never touched here (hard-gated),
28
+ // so the worker only ever drives the core via `http.fetch`.
29
29
  import { createEmbeddedPaymentService } from '../api/src/service';
30
30
  import type { PaymentCoreService } from '../api/src/service';
31
31
 
@@ -66,11 +66,10 @@ import { resetD1Timing, getD1Timing } from './shims/sequelize-d1/timing';
66
66
  import { withD1Retry } from './shims/sequelize-d1/retry';
67
67
 
68
68
  // DID Connect: login routes proxied to blocklet-service, business actions (pay/subscribe) handled locally
69
- import { attachDIDConnectRoutes } from './did-connect-auth';
69
+ import { createCloudflareDidConnectRuntime, createCloudflareIdentityDriver } from './did-connect-runtime';
70
70
 
71
71
  // Phase 7: tenant-context middleware — resolves Host -> tenant (single point)
72
72
  // and wraps the request chain in context.withTenant (multi-mode fail-closed).
73
- import { tenantMiddleware } from './tenant-middleware';
74
73
 
75
74
  FetchRequest.registerGetUrl(async (req: FetchRequest) => {
76
75
  const resp = await fetch(req.url, {
@@ -100,7 +99,6 @@ interface CallerIdentityDTO {
100
99
 
101
100
  interface Env {
102
101
  DB: D1Database;
103
- DID_CONNECT_KV: KVNamespace;
104
102
  JOB_QUEUE: Queue;
105
103
  ASSETS: { fetch: (request: Request | string) => Promise<Response> };
106
104
  APP_SK: string;
@@ -128,6 +126,17 @@ interface Env {
128
126
  role?: string;
129
127
  approved?: number;
130
128
  } | null>;
129
+ // S3-CF (DID convergence): did-connect-service@4.0.3 — the per-instance app
130
+ // signing identity for the DID-Connect authenticator. The RPC resolves the arc
131
+ // run-mode internally (instance app:sk → instance identity; else auth-service
132
+ // root APP_SK/APP_PSK; else fail-closed), so payment-core never reimplements it.
133
+ getInstanceAppIdentity: (instanceDid: string) => Promise<{
134
+ appSk: string;
135
+ appPsk?: string;
136
+ appInfo?: { name?: string; description?: string; icon?: string; link?: string };
137
+ }>;
138
+ getAppEk?: (instanceDid: string) => Promise<string | null>;
139
+ resolveInstanceDidForHost?: (host: string) => Promise<string | null>;
131
140
  };
132
141
  HYPERDRIVE: { connectionString: string };
133
142
  [key: string]: any;
@@ -270,18 +279,26 @@ function ensurePaymentService(env: Env): PaymentCoreService {
270
279
  const svc = createEmbeddedPaymentService({
271
280
  config: envToPaymentCoreConfig(env),
272
281
  db: { sequelize },
282
+ // S3-CF (DID convergence): inject the CF DID-Connect runtime (real
283
+ // @arcblock/did-connect-js + CF chain/txEncoder/timeout + tenant-aware D1 token
284
+ // store) and the AUTH_SERVICE-backed identity driver (getInstanceAppIdentity).
285
+ // The core buildConnectRoutesHono then registers the 14 payment DID actions with
286
+ // a per-tenant signing identity — the worker no longer owns a private DID surface.
287
+ identity: createCloudflareIdentityDriver(),
288
+ didConnectRuntime: createCloudflareDidConnectRuntime(),
273
289
  });
274
- // Phase 12c HARD GATE: the CF worker is a host adapter. It mounts only
275
- // svc.http.resourceRoutes and registers DID-Connect through its own Hono
276
- // shell — it must NEVER touch svc.handler, whose lazy getter builds the
277
- // node-only Express app shell (app.set / static / connect attach) that does
278
- // not belong under workerd. Trap the access so a future regression fails loud
279
- // at runtime instead of silently constructing the node app in the isolate.
290
+ // Phase 12c HARD GATE (updated S3-CF Phase 1B): the CF worker is a host adapter
291
+ // that drives the core via the runtime-neutral `svc.http.fetch`. It must NEVER
292
+ // read `svc.handler` directly — that lazy getter is the node-host convenience
293
+ // entry (it would wire the node staticHandler if one were injected). The worker
294
+ // forwards through `svc.http.fetch`, which builds the same full hono app but is
295
+ // the sanctioned runtime-neutral seam. Trap `handler` so a future regression
296
+ // fails loud instead of silently taking the node-convenience path.
280
297
  paymentService = new Proxy(svc, {
281
298
  get(target, prop, receiver) {
282
299
  if (prop === 'handler') {
283
300
  throw new Error(
284
- '[worker hard-gate] svc.handler is forbidden in the CF worker — mount svc.http.resourceRoutes instead'
301
+ '[worker hard-gate] svc.handler is forbidden in the CF worker — use svc.http.fetch instead'
285
302
  );
286
303
  }
287
304
  return Reflect.get(target, prop, receiver);
@@ -340,16 +357,19 @@ function buildApp(env: Env): Hono<HonoEnv> {
340
357
  return cachedApp;
341
358
  }
342
359
 
343
- // Phase 12a: assemble the embedded payment service (config/db/slots) and take
344
- // its resource routes for mounting. Only `http.resourceRoutes` is read — the
345
- // node-only `handler` getter is never touched, so the express app shell + the
346
- // DID-Connect handlers it carries never run under the workerd shim.
360
+ // Phase 12a + S3-CF Phase 1B: assemble the embedded payment service
361
+ // (config/db/slots incl. the injected CF DID-Connect runtime). The worker drives
362
+ // it through `service.http.fetch` (the single runtime-neutral surface); the
363
+ // node-only `handler` getter is hard-gated and never touched.
347
364
  const service = ensurePaymentService(env);
348
365
 
349
366
  const app = new Hono<HonoEnv>();
350
367
 
351
368
  // CORS
352
- app.use('/api/*', cors());
369
+ // S3-CF Phase 1B: payment `/api/*` CORS is owned by the CORE full pipeline
370
+ // (cors/xss/csrf/i18n/cdn/context), reached via service.http.fetch. The worker no
371
+ // longer pre-applies cors() to /api/* — that would double the CORS headers on
372
+ // every payment route. Worker-owned cross-origin surfaces keep their own cors:
353
373
  app.use('/.well-known/*', cors());
354
374
  // /__blocklet__.js is fetched by external wallets (e.g. abtwallet.io, localhost
355
375
  // dev wallets) to resolve app metadata + chain info before starting DID Connect.
@@ -413,7 +433,23 @@ function buildApp(env: Env): Hono<HonoEnv> {
413
433
  if (caller) {
414
434
  authSource = 'cache';
415
435
  } else {
416
- caller = await authService.resolveIdentity(jwt, authHeader, c.env.APP_PID);
436
+ // S3-CF Phase 1B: parameterize the caller RPC with the request's actual
437
+ // tenant via the SAME Host→tenant resolver the core uses — not a fixed
438
+ // env.APP_PID — so a multi-tenant embed verifies the caller against the
439
+ // right instance. Non-throwing: an unresolved host (multi) keeps the
440
+ // APP_PID fallback. Lazily required (NOT a top-level import) to avoid
441
+ // pulling the identity module into the worker's eager startup graph. This
442
+ // does NOT wrap the business handler's ALS context (the core owns that).
443
+ let callerInstanceDid: string | undefined = c.env.APP_PID;
444
+ try {
445
+ // eslint-disable-next-line global-require
446
+ const { resolveTenantForHost } = require('../api/src/libs/drivers/identity');
447
+ const resolved = await resolveTenantForHost(c.req.header('host'));
448
+ if (resolved) callerInstanceDid = resolved;
449
+ } catch {
450
+ /* unresolved host → keep the APP_PID fallback for the caller RPC param */
451
+ }
452
+ caller = await authService.resolveIdentity(jwt, authHeader, callerInstanceDid);
417
453
  authSource = 'rpc';
418
454
  if (caller && cacheKey) {
419
455
  cacheIdentity(cacheKey, caller);
@@ -449,11 +485,13 @@ function buildApp(env: Env): Hono<HonoEnv> {
449
485
  c.res.headers.append('Server-Timing', timings.join(', '));
450
486
  });
451
487
 
452
- // Tenant context: resolve Host -> tenant (single point) and wrap the request
453
- // chain in context.withTenant so every TenantModel query is scoped. Scoped to
454
- // /api/* so /health and static assets never require a tenant. multi mode:
455
- // unknown/missing Host -> 400 fail-closed (no default-tenant fallback).
456
- app.use('/api/*', tenantMiddleware());
488
+ // S3-CF Phase 1B: the ALS tenant context for payment `/api/*` business requests
489
+ // is owned by the CORE full pipeline (contextMiddleware), reached via
490
+ // service.http.fetch. The worker no longer wraps /api/* in tenantMiddleware()
491
+ // that would double-resolve + double-wrap the tenant. The few worker-owned /api/*
492
+ // endpoints (dev/debug/proxies) run in single-mode default and never query under
493
+ // a per-request tenant. (DID payment actions get their context from the core
494
+ // buildConnectRoutesHono tenant middleware, also via http.fetch.)
457
495
 
458
496
  // Health check
459
497
  app.get('/health', (c) => c.json({ status: 'ok' }));
@@ -656,8 +694,9 @@ function buildApp(env: Env): Hono<HonoEnv> {
656
694
 
657
695
  // === DID Auth Login routes ===
658
696
  // Only proxy login-related /api/did/* paths to blocklet-service.
659
- // Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's
660
- // own DID Connect actions handled by attachDIDConnectRoutes below.
697
+ // Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's own
698
+ // DID Connect payment actions they fall through to the /api/* → service.http.fetch
699
+ // dispatcher (the core full app's DID payment actions).
661
700
  const DID_AUTH_PROXY_PATHS = [
662
701
  '/api/did/login/',
663
702
  '/api/did/session',
@@ -668,7 +707,10 @@ function buildApp(env: Env): Hono<HonoEnv> {
668
707
  app.all('/api/did/*', async (c, next) => {
669
708
  const path = new URL(c.req.url).pathname;
670
709
  const shouldProxy = DID_AUTH_PROXY_PATHS.some((p) => path.startsWith(p) || path === p);
671
- if (!shouldProxy) return next(); // Fall through to attachDIDConnectRoutes or Express routes
710
+ // S3-CF Phase 1B: non-proxy payment DID actions fall through to the /api/*
711
+ // dispatcher below → service.http.fetch (the core full app includes the DID
712
+ // payment actions via buildConnectRoutesHono).
713
+ if (!shouldProxy) return next();
672
714
 
673
715
  if (!c.env.AUTH_SERVICE) {
674
716
  return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
@@ -685,14 +727,10 @@ function buildApp(env: Env): Hono<HonoEnv> {
685
727
  });
686
728
 
687
729
  // === DID Connect business actions (subscription, pay, collect, etc.) ===
688
- if (env.APP_SK && env.DID_CONNECT_KV) {
689
- try {
690
- attachDIDConnectRoutes(app, env.DID_CONNECT_KV, env.APP_SK);
691
- console.log('[CF Worker] DID Connect routes attached');
692
- } catch (e: any) {
693
- console.error('DID Connect init error:', e?.message || e);
694
- }
695
- }
730
+ // S3-CF Phase 1B: the 14 payment DID actions are part of the CORE full app and
731
+ // are reached through the /api/* → service.http.fetch dispatcher below (they were
732
+ // converged onto buildConnectRoutesHono in Phase 1A, backed by the CF DID-Connect
733
+ // runtime injected into the service above). No separate worker mount — one surface.
696
734
 
697
735
  // Notification unread count
698
736
  app.get('/api/notifications/unread-count', (c) => c.json({ unReadCount: 0 }));
@@ -730,11 +768,9 @@ function buildApp(env: Env): Hono<HonoEnv> {
730
768
  // for creating PaymentMethods is owner-gated, and staging has no owner yet
731
769
  // (Pengfei's blocklet-service role is `member`). Gated by PAYMENT_LIVEMODE
732
770
  // === 'false' so this only ever touches testmode data.
733
- // Phase 4 (express→hono): the resource routes are a native hono app
734
- // (service.http.resourceRoutes) mounted by the /api/* dispatcher registered
735
- // AFTER the worker's own /api/__dev__ routes (see below, replacing the old
736
- // "not implemented" catch-all). The old express-compat mountExpressRoutes shim
737
- // is gone.
771
+ // S3-CF Phase 1B: payment business routes (and DID payment actions) are served by
772
+ // the core full app via the /api/* → service.http.fetch dispatcher, registered
773
+ // AFTER the worker's own /api/__dev__ routes so those match first.
738
774
 
739
775
  // Dev endpoint: D1 admin operations
740
776
  // Test CF Queue send directly
@@ -1079,17 +1115,21 @@ function buildApp(env: Env): Hono<HonoEnv> {
1079
1115
  }
1080
1116
  });
1081
1117
 
1082
- // Phase 4 (express→hono): native hono resource routes. Registered AFTER the
1083
- // worker's own /api/__dev__ routes so those match first; this dispatcher then
1084
- // handles every other /api/* path (returning resourceRoutes' own 404 for an
1085
- // unknown route). The RPC-resolved caller identity is injected as x-user-*
1086
- // request headers the native authenticate() reads those and any
1087
- // client-supplied x-user-* is overwritten/stripped so it can never be forged
1088
- // (component auth via x-component-sig is left intact: it is verified
1089
- // cryptographically downstream). The worker already provides cors + tenant, so
1090
- // resourceRoutes uses a LITE app-shell (xss only); flushQueueWork() preserves
1091
- // the workerd flush-before-response the old shim ran after each handler.
1092
- const resourceRoutes = service.http.resourceRoutes as Hono;
1118
+ // S3-CF Phase 1B: the single payment HTTP surface. Every payment `/api/*` request
1119
+ // (business resource routes + non-proxy DID payment actions) that wasn't handled
1120
+ // by a worker-owned route above is forwarded to the CORE full app via
1121
+ // service.http.fetch. The core full pipeline owns cors/xss/csrf/i18n/cdn/context
1122
+ // + the resource routes + the DID payment actionsthere is no more LITE
1123
+ // resourceRoutes dispatcher and no second surface.
1124
+ //
1125
+ // The worker stays the HOST GLUE: it injects the caller identity it resolved
1126
+ // (caller RPC) as canonical x-user-* request headers — the core authenticate()
1127
+ // reads those — and STRIPS any client-supplied x-user-* first so it can never be
1128
+ // forged (component auth via x-component-sig is verified cryptographically
1129
+ // downstream and left intact). Raw body bytes are forwarded unconsumed (the
1130
+ // worker never reads the body here), preserving Stripe webhook signature fidelity
1131
+ // through to the core webhook route. flushQueueWork() drains the workerd deferred
1132
+ // queue work before responding.
1093
1133
  const USER_HEADERS = ['x-user-did', 'x-user-role', 'x-user-provider', 'x-user-fullname', 'x-user-wallet-os'];
1094
1134
  app.all('/api/*', async (c) => {
1095
1135
  const headers = new Headers(c.req.raw.headers);
@@ -1103,7 +1143,9 @@ function buildApp(env: Env): Hono<HonoEnv> {
1103
1143
  headers.set('x-user-fullname', encodeURIComponent(caller.displayName || ''));
1104
1144
  headers.set('x-user-wallet-os', '');
1105
1145
  }
1106
- const res = await resourceRoutes.fetch(new Request(c.req.raw, { headers }), c.env, c.executionCtx);
1146
+ // No basePath: the standalone worker serves payment routes at the root /api/*,
1147
+ // so the core full app matches them directly (raw bytes/headers carried verbatim).
1148
+ const res = await service.http.fetch(new Request(c.req.raw, { headers }));
1107
1149
  await flushQueueWork(); // drain workerd deferred queue work before responding
1108
1150
  return res;
1109
1151
  });
@@ -17,12 +17,6 @@
17
17
  "migrations_dir": "migrations"
18
18
  }
19
19
  ],
20
- "kv_namespaces": [
21
- {
22
- "binding": "DID_CONNECT_KV",
23
- "id": "ca0bd29c73864115b713e1db11d97cd2"
24
- }
25
- ],
26
20
  "services": [
27
21
  {
28
22
  "binding": "MEDIA_KIT",
@@ -17,12 +17,6 @@
17
17
  "database_id": "ea6c75d0-39b8-40cd-a0ae-a2062e77c4b9"
18
18
  }
19
19
  ],
20
- "kv_namespaces": [
21
- {
22
- "binding": "DID_CONNECT_KV",
23
- "id": "ca0bd29c73864115b713e1db11d97cd2"
24
- }
25
- ],
26
20
  "hyperdrive": [
27
21
  {
28
22
  "binding": "HYPERDRIVE",
@@ -16,7 +16,6 @@
16
16
  "migrations_dir": "migrations"
17
17
  }
18
18
  ],
19
- "kv_namespaces": [{ "binding": "DID_CONNECT_KV", "id": "local-e2e-kv" }],
20
19
  "vars": {
21
20
  "APP_NAME": "Payment Kit",
22
21
  "APP_PID": "payment-kit-dev",
@@ -18,12 +18,6 @@
18
18
  "migrations_dir": "migrations"
19
19
  }
20
20
  ],
21
- "kv_namespaces": [
22
- {
23
- "binding": "DID_CONNECT_KV",
24
- "id": "a2c98f81bc974fcabc191766698d170f"
25
- }
26
- ],
27
21
  "services": [
28
22
  {
29
23
  "binding": "MEDIA_KIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.29.2",
3
+ "version": "1.29.3",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "prelint": "npm run types",
@@ -49,7 +49,7 @@
49
49
  "@abtnode/cron": "^1.17.13-beta-20260613-094425-b81920c8",
50
50
  "@apple/app-store-server-library": "^3.1.0",
51
51
  "@arcblock/did": "^1.30.24",
52
- "@arcblock/did-connect-js": "4.0.1-beta.6",
52
+ "@arcblock/did-connect-js": "^4.0.5",
53
53
  "@arcblock/did-connect-react": "^3.5.4",
54
54
  "@arcblock/did-connect-storage-nedb": "^1.8.0",
55
55
  "@arcblock/did-util": "^1.30.24",
@@ -61,9 +61,9 @@
61
61
  "@blocklet/error": "^0.3.5",
62
62
  "@blocklet/js-sdk": "^1.17.13-beta-20260613-094425-b81920c8",
63
63
  "@blocklet/logger": "^1.17.13-beta-20260613-094425-b81920c8",
64
- "@blocklet/payment-broker-client": "1.29.2",
65
- "@blocklet/payment-react": "1.29.2",
66
- "@blocklet/payment-vendor": "1.29.2",
64
+ "@blocklet/payment-broker-client": "1.29.3",
65
+ "@blocklet/payment-react": "1.29.3",
66
+ "@blocklet/payment-vendor": "1.29.3",
67
67
  "@blocklet/sdk": "^1.17.13-beta-20260613-094425-b81920c8",
68
68
  "@blocklet/ui-react": "^3.5.4",
69
69
  "@blocklet/uploader": "^0.3.20",
@@ -135,7 +135,7 @@
135
135
  "devDependencies": {
136
136
  "@abtnode/types": "^1.17.13-beta-20260613-094425-b81920c8",
137
137
  "@arcblock/eslint-config-ts": "^0.3.3",
138
- "@blocklet/payment-types": "1.29.2",
138
+ "@blocklet/payment-types": "1.29.3",
139
139
  "@types/connect": "^3.4.38",
140
140
  "@types/debug": "^4.1.12",
141
141
  "@types/dotenv-flow": "^3.3.3",
@@ -183,5 +183,5 @@
183
183
  "parser": "typescript"
184
184
  }
185
185
  },
186
- "gitHead": "b4f9a33207cb2341638efff848a900087a18e6cf"
186
+ "gitHead": "0bd49f9b9586e307b51d1f5a6b042e82c39366ef"
187
187
  }
@@ -0,0 +1,166 @@
1
+ // P2 (README D2 / F2) — runtime-neutral bootstrap helper.
2
+ //
3
+ // Parameterizes the formerly CF-only `cf-inject-blocklet` logic
4
+ // (cloudflare/vite.config.ts) so the node arc build (vite.arc.config.ts) and the
5
+ // CF build share ONE source for the build-time HTML bootstrap. The pure
6
+ // functions `buildBootstrap` / `mergeRemote` are jest-assertable
7
+ // (api/tests/bootstrap/bootstrap.spec.ts); `buildBootstrapScript` serializes
8
+ // `mergeRemote` (via .toString()) into the inline <script> so the browser runs
9
+ // the identical merge at runtime.
10
+ //
11
+ // D-3 (user-confirmed 2026-06-14) put this under blocklets/core/build/. That
12
+ // path is .gitignored (`blocklets/core/.gitignore:13` — `build` = production
13
+ // outputs), so the file could never be tracked there. It lives in scripts/
14
+ // instead — same intent (under blocklets/core, adjacent to both vite configs,
15
+ // zero cross-package dep, where build tooling already lives: jest.js,
16
+ // build-clean.js) but tracked. Extension is `.ts` (not `.mjs`) so ts-jest can
17
+ // unit-test the pure functions directly; both vite configs are `.ts` and import
18
+ // it natively.
19
+
20
+ // Source of truth: packages/react/src/libs/util.ts:33. Re-declared here to keep
21
+ // this build helper free of the heavy browser util graph (the only thing the
22
+ // build needs from it is the bare DID literal).
23
+ export const PAYMENT_KIT_DID = 'z2qaCNvKMv5GjouKdcDWexv6WqtHbpNPQDnAk';
24
+
25
+ export interface BootstrapOptions {
26
+ /** the host's UI mount prefix (node arc = '/.well-known/payment'; cf custom). */
27
+ uiPrefix: string;
28
+ /** PAYMENT_KIT_DID so getPrefix() (packages/react/src/libs/util.ts:87) takes
29
+ * the `componentId === PAYMENT_KIT_DID` branch and resolves to uiPrefix. */
30
+ componentId: string;
31
+ /** root-exact `/__blocklet__.js?type=json` — NEVER under uiPrefix (T2.3). */
32
+ remoteBlockletUrl: string;
33
+ /** session service host (node arc = arc '/.well-known/service'); CF root-deploy
34
+ * omits it so window.blocklet.serviceHost stays undefined (app.tsx falls back
35
+ * to prefix — P5 T5.1). */
36
+ serviceHost?: string;
37
+ /** baked appUrl; defaults to window.location.origin at runtime when omitted. */
38
+ appUrl?: string;
39
+ /** keys the remote __blocklet__.js must NOT clobber (G3 — `prefix` included). */
40
+ localOnly?: string[];
41
+ /** host-owned extras merged into the base bootstrap (navigation, mountpoints). */
42
+ extra?: Record<string, any>;
43
+ }
44
+
45
+ // G3: `prefix` MUST be local-only so the app-root prefix from the remote
46
+ // __blocklet__.js never overwrites the payment reserved prefix.
47
+ const DEFAULT_LOCAL_ONLY = ['prefix', 'navigation', 'componentMountPoints'];
48
+
49
+ /**
50
+ * Build the synchronous window.blocklet skeleton. Pure — no IO, no globals.
51
+ * Throws (T2.3 regression lock) when remoteBlockletUrl is not root-exact, so a
52
+ * misconfigured build fails loudly at build time instead of silently fetching
53
+ * `/.well-known/payment/__blocklet__.js` (inside the reserved prefix).
54
+ */
55
+ export function buildBootstrap(opts: BootstrapOptions): Record<string, any> {
56
+ const { uiPrefix, componentId, remoteBlockletUrl, serviceHost, appUrl, extra } = opts;
57
+ const localOnly = opts.localOnly || DEFAULT_LOCAL_ONLY;
58
+
59
+ if (!remoteBlockletUrl.startsWith('/__blocklet__')) {
60
+ throw new Error(`remoteBlockletUrl must be root-exact (start with /__blocklet__), got: ${remoteBlockletUrl}`);
61
+ }
62
+ if (uiPrefix !== '/' && remoteBlockletUrl.startsWith(uiPrefix)) {
63
+ throw new Error(`remoteBlockletUrl must not fall under uiPrefix ${uiPrefix}: ${remoteBlockletUrl}`);
64
+ }
65
+
66
+ return {
67
+ prefix: uiPrefix,
68
+ groupPrefix: uiPrefix,
69
+ componentId,
70
+ serviceHost,
71
+ appUrl: appUrl || '',
72
+ remoteBlockletUrl,
73
+ localOnly,
74
+ // @blocklet/ui-react nav/dashboard iterate these with .forEach — default to []
75
+ // so a host that doesn't override via `extra` never crashes on undefined. They
76
+ // are in localOnly, so the remote __blocklet__.js merge can't clobber them
77
+ // (AUTH_SERVICE doesn't know this blocklet's nav / mount points).
78
+ navigation: [],
79
+ componentMountPoints: [],
80
+ ...(extra || {}),
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Merge the remote per-app __blocklet__.js JSON onto the local bootstrap.
86
+ *
87
+ * SELF-CONTAINED (no module-scope refs) so `buildBootstrapScript` can embed it
88
+ * via `.toString()` and the browser runs the identical merge. Guards:
89
+ * - localOnly keys (prefix/navigation/componentMountPoints) are never clobbered
90
+ * - __proto__/constructor/prototype keys are dropped (prototype-pollution)
91
+ * - string values are angle-bracket escaped (<,> → entities) — blocks tag
92
+ * injection without corrupting URL query strings (& preserved)
93
+ * - payloads > 256KB are refused (resource exhaustion)
94
+ */
95
+ export function mergeRemote(
96
+ wb: Record<string, any>,
97
+ remote: Record<string, any>,
98
+ localOnlyArg?: string[]
99
+ ): Record<string, any> {
100
+ var localOnly = localOnlyArg || (wb && wb.localOnly) || ['prefix', 'navigation', 'componentMountPoints'];
101
+
102
+ if (remote && typeof remote === 'object') {
103
+ var size = 0;
104
+ try {
105
+ size = JSON.stringify(remote).length;
106
+ } catch (e) {
107
+ size = Infinity;
108
+ }
109
+ if (size > 256 * 1024) {
110
+ throw new Error('remote bootstrap payload too large (>256KB), refusing merge');
111
+ }
112
+ }
113
+
114
+ var out: Record<string, any> = {};
115
+ Object.keys(wb || {}).forEach(function (k) {
116
+ out[k] = wb[k];
117
+ });
118
+ Object.keys(remote || {}).forEach(function (k) {
119
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') return;
120
+ // componentId is a structural invariant (getPrefix's PAYMENT_KIT_DID branch),
121
+ // protected independently of the configurable localOnly list.
122
+ if (k === 'componentId') return;
123
+ if (localOnly.indexOf(k) !== -1) return;
124
+ var v = remote[k];
125
+ if (typeof v === 'string') {
126
+ v = v.replace(/</g, '&lt;').replace(/>/g, '&gt;');
127
+ }
128
+ out[k] = v;
129
+ });
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Produce the inline <script> for transformIndexHtml. Bakes the validated
135
+ * buildBootstrap() result as the synchronous window.blocklet, then embeds
136
+ * mergeRemote (serialized) so the runtime XHR merge is the same code jest tests.
137
+ */
138
+ export function buildBootstrapScript(opts: BootstrapOptions): string {
139
+ const wb = buildBootstrap(opts);
140
+ const initial = JSON.stringify(wb);
141
+ const remoteUrl = JSON.stringify(wb.remoteBlockletUrl);
142
+ return `<script>
143
+ window.global = globalThis;
144
+ if (!window.blocklet) {
145
+ window.blocklet = ${initial};
146
+ window.blocklet.appUrl = window.blocklet.appUrl || window.location.origin;
147
+ }
148
+ (function () {
149
+ try {
150
+ var xhr = new XMLHttpRequest();
151
+ var url = ${remoteUrl};
152
+ xhr.open('GET', url + (url.indexOf('?') === -1 ? '?' : '&') + '_t=' + Date.now(), false);
153
+ xhr.send();
154
+ if (xhr.status === 200) {
155
+ var remote = JSON.parse(xhr.responseText);
156
+ window.blocklet = (${mergeRemote.toString()})(window.blocklet, remote);
157
+ if (!window.blocklet.env) window.blocklet.env = {};
158
+ window.blocklet.env.appName = window.blocklet.appName || '';
159
+ window.blocklet.env.appDescription = window.blocklet.appDescription || '';
160
+ window.blocklet.env.appLogo = window.blocklet.appLogo || '';
161
+ window.blocklet.env.appUrl = window.blocklet.appUrl || '';
162
+ }
163
+ } catch (e) { /* ignore — fall back to sync bootstrap */ }
164
+ })();
165
+ </script>`;
166
+ }
package/src/app.tsx CHANGED
@@ -13,6 +13,7 @@ import { Navigate, Route, BrowserRouter as Router, Routes, useNavigate } from 'r
13
13
  import { joinURL } from 'ufo';
14
14
 
15
15
  import ErrorFallback from './components/error-fallback';
16
+ import { resolveServiceHost } from './libs/service-host';
16
17
  import UserLayoutDefault from './components/layout/user';
17
18
  import UserLayoutCF from './components/layout/user-cf';
18
19
  import { TransitionProvider } from './components/progress-bar';
@@ -250,7 +251,7 @@ export default function WrappedApp() {
250
251
  <ToastProvider>
251
252
  <PaymentThemeProvider>
252
253
  <SessionProvider
253
- serviceHost={prefix}
254
+ serviceHost={resolveServiceHost(prefix)}
254
255
  useSocket={!(window as any).blocklet?.cloudflareWorker}
255
256
  protectedRoutes={['/admin/*', '/customer/*', '/integrations/*'].map((item) => joinURL(prefix, item))}>
256
257
  <Router basename={prefix}>
@@ -0,0 +1,13 @@
1
+ // P5 T5.1 (README D4 / F5) — resolve the SessionProvider serviceHost.
2
+ //
3
+ // arc injects window.blocklet.serviceHost (the arc /.well-known/service endpoint)
4
+ // at build time via the P2 bootstrap. The blocklet-server form does NOT inject it,
5
+ // so we fall back to the app prefix — TM-8 regression: zero behavior change for the
6
+ // blocklet-server deploy (serviceHost stays = prefix exactly as before).
7
+ //
8
+ // Zero imports on purpose so the rule is unit-testable in a node jest env without
9
+ // pulling the frontend React graph.
10
+ export function resolveServiceHost(prefix: string): string {
11
+ const injected = (typeof window !== 'undefined' && (window as any)?.blocklet?.serviceHost) || '';
12
+ return injected || prefix;
13
+ }