payment-kit 1.27.2 → 1.28.0

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 (184) hide show
  1. package/__blocklet__.js +37 -0
  2. package/api/ocap-1.30-subpath-shims.d.ts +35 -0
  3. package/api/src/crons/index.ts +10 -0
  4. package/api/src/crons/metering-subscription-detection.ts +12 -14
  5. package/api/src/crons/overdue-detection.ts +51 -74
  6. package/api/src/integrations/arcblock/nft.ts +6 -2
  7. package/api/src/integrations/arcblock/stake.ts +3 -2
  8. package/api/src/integrations/arcblock/token.ts +4 -4
  9. package/api/src/integrations/blocklet/notification.ts +1 -1
  10. package/api/src/integrations/ethereum/tx.ts +29 -0
  11. package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
  12. package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
  13. package/api/src/integrations/stripe/resource.ts +8 -0
  14. package/api/src/libs/audit.ts +32 -16
  15. package/api/src/libs/auth.ts +49 -2
  16. package/api/src/libs/chain-error.ts +31 -0
  17. package/api/src/libs/error.ts +15 -0
  18. package/api/src/libs/event.ts +42 -1
  19. package/api/src/libs/invoice.ts +69 -34
  20. package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
  21. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
  22. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
  23. package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
  24. package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
  25. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
  26. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
  27. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
  28. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
  29. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
  30. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
  31. package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
  32. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
  33. package/api/src/libs/pagination.ts +14 -9
  34. package/api/src/libs/payment.ts +25 -10
  35. package/api/src/libs/session.ts +1 -1
  36. package/api/src/libs/timing.ts +35 -0
  37. package/api/src/libs/util.ts +16 -15
  38. package/api/src/libs/wallet-migration.ts +72 -53
  39. package/api/src/queues/auto-recharge.ts +1 -1
  40. package/api/src/queues/credit-consume.ts +94 -12
  41. package/api/src/queues/credit-grant.ts +4 -0
  42. package/api/src/queues/event.ts +14 -2
  43. package/api/src/queues/invoice.ts +1 -0
  44. package/api/src/queues/payment.ts +83 -15
  45. package/api/src/queues/refund.ts +84 -71
  46. package/api/src/queues/subscription.ts +1 -0
  47. package/api/src/routes/checkout-sessions.ts +82 -43
  48. package/api/src/routes/connect/change-payment.ts +2 -0
  49. package/api/src/routes/connect/change-plan.ts +2 -0
  50. package/api/src/routes/connect/pay.ts +12 -3
  51. package/api/src/routes/connect/setup.ts +3 -1
  52. package/api/src/routes/connect/shared.ts +52 -39
  53. package/api/src/routes/connect/subscribe.ts +4 -1
  54. package/api/src/routes/credit-grants.ts +25 -17
  55. package/api/src/routes/donations.ts +2 -2
  56. package/api/src/routes/meter-events.ts +16 -6
  57. package/api/src/routes/payment-links.ts +1 -1
  58. package/api/src/routes/payment-methods.ts +1 -1
  59. package/api/src/routes/settings.ts +1 -1
  60. package/api/src/routes/tax-rates.ts +1 -1
  61. package/api/src/store/models/customer.ts +23 -1
  62. package/api/src/store/models/payment-method.ts +4 -0
  63. package/api/src/store/models/price.ts +23 -14
  64. package/api/tests/libs/wallet-migration.spec.ts +4 -4
  65. package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
  66. package/api/tests/queues/credit-consume.spec.ts +8 -4
  67. package/api/tests/routes/credit-grants.spec.ts +1 -0
  68. package/blocklet.yml +1 -1
  69. package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
  70. package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
  71. package/cloudflare/README.md +499 -0
  72. package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
  73. package/cloudflare/build.ts +151 -0
  74. package/cloudflare/did-connect-auth.ts +527 -0
  75. package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
  76. package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
  77. package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
  78. package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
  79. package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
  80. package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
  81. package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
  82. package/cloudflare/frontend-shims/js-sdk.ts +43 -0
  83. package/cloudflare/frontend-shims/mime-types.ts +46 -0
  84. package/cloudflare/frontend-shims/session.ts +24 -0
  85. package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
  86. package/cloudflare/index.html +40 -0
  87. package/cloudflare/migrate-to-d1.js +252 -0
  88. package/cloudflare/migrations/0001_initial_schema.sql +82 -0
  89. package/cloudflare/migrations/0002_indexes.sql +75 -0
  90. package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
  91. package/cloudflare/run-build.js +390 -0
  92. package/cloudflare/scripts/test-decrypt.js +102 -0
  93. package/cloudflare/shims/arcblock-ws.ts +20 -0
  94. package/cloudflare/shims/axios-http-adapter.ts +4 -0
  95. package/cloudflare/shims/axios-lite.ts +117 -0
  96. package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
  97. package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
  98. package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
  99. package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
  100. package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
  101. package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
  102. package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
  103. package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
  104. package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
  105. package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
  106. package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
  107. package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
  108. package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
  109. package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
  110. package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
  111. package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
  112. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
  113. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
  114. package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
  115. package/cloudflare/shims/cookie-parser.ts +3 -0
  116. package/cloudflare/shims/cors.ts +21 -0
  117. package/cloudflare/shims/cron.ts +189 -0
  118. package/cloudflare/shims/crypto-js-warn.ts +7 -0
  119. package/cloudflare/shims/did-space-js.ts +17 -0
  120. package/cloudflare/shims/did-space.ts +11 -0
  121. package/cloudflare/shims/error.ts +18 -0
  122. package/cloudflare/shims/express-compat/index.ts +80 -0
  123. package/cloudflare/shims/express-compat/types.ts +41 -0
  124. package/cloudflare/shims/fastq.ts +105 -0
  125. package/cloudflare/shims/lock.ts +115 -0
  126. package/cloudflare/shims/mime-types.ts +56 -0
  127. package/cloudflare/shims/nedb-storage.ts +9 -0
  128. package/cloudflare/shims/node-child-process.ts +9 -0
  129. package/cloudflare/shims/node-fs.ts +20 -0
  130. package/cloudflare/shims/node-http.ts +13 -0
  131. package/cloudflare/shims/node-https.ts +4 -0
  132. package/cloudflare/shims/node-misc.ts +15 -0
  133. package/cloudflare/shims/node-net.ts +8 -0
  134. package/cloudflare/shims/node-os.ts +14 -0
  135. package/cloudflare/shims/node-tty.ts +8 -0
  136. package/cloudflare/shims/node-zlib.ts +17 -0
  137. package/cloudflare/shims/noop.ts +26 -0
  138. package/cloudflare/shims/payment-vendor.ts +14 -0
  139. package/cloudflare/shims/querystring.ts +12 -0
  140. package/cloudflare/shims/queue.ts +585 -0
  141. package/cloudflare/shims/rolldown-runtime.ts +43 -0
  142. package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
  143. package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
  144. package/cloudflare/shims/sequelize-d1/index.ts +34 -0
  145. package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
  146. package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
  147. package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
  148. package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
  149. package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
  150. package/cloudflare/shims/sequelize-d1/types.ts +35 -0
  151. package/cloudflare/shims/stripe-cf.ts +29 -0
  152. package/cloudflare/shims/ws-lite.ts +103 -0
  153. package/cloudflare/shims/xss.ts +3 -0
  154. package/cloudflare/tests/shims/cron.spec.ts +210 -0
  155. package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
  156. package/cloudflare/vite.config.ts +162 -0
  157. package/cloudflare/worker.ts +1553 -0
  158. package/cloudflare/wrangler.json +63 -0
  159. package/cloudflare/wrangler.jsonc +69 -0
  160. package/cloudflare/wrangler.staging.json +66 -0
  161. package/cloudflare/wrangler.toml +28 -0
  162. package/jest.config.js +4 -12
  163. package/package.json +26 -22
  164. package/src/app.tsx +62 -4
  165. package/src/components/customer/link.tsx +9 -13
  166. package/src/components/customer/notification-preference.tsx +3 -2
  167. package/src/components/filter-toolbar.tsx +4 -0
  168. package/src/components/invoice/list.tsx +9 -1
  169. package/src/components/invoice-pdf/utils.ts +2 -1
  170. package/src/components/layout/admin.tsx +39 -5
  171. package/src/components/layout/user-cf.tsx +77 -0
  172. package/src/components/payment-intent/actions.tsx +23 -3
  173. package/src/components/safe-did-address.tsx +75 -0
  174. package/src/libs/patch-user-card.ts +25 -0
  175. package/src/libs/util.ts +5 -7
  176. package/src/pages/admin/billing/meter-events/index.tsx +4 -0
  177. package/src/pages/admin/customers/customers/detail.tsx +2 -2
  178. package/src/pages/admin/customers/customers/index.tsx +2 -2
  179. package/src/pages/admin/overview.tsx +3 -1
  180. package/src/pages/customer/subscription/detail.tsx +4 -4
  181. package/tsconfig.api.json +1 -6
  182. package/tsconfig.json +3 -4
  183. package/tsconfig.types.json +2 -1
  184. package/vite.config.ts +6 -1
@@ -0,0 +1,1553 @@
1
+ // Payment Kit — Cloudflare Workers entry point
2
+ // Uses Hono for routing + imports original Express routes via shim adapter
3
+
4
+ // Register ethers.js CF Workers fetch adapter BEFORE any ethers imports.
5
+ // ethers v6 uses node:http by default, which is not available in CF Workers.
6
+ // This replaces the HTTP transport with native CF Workers fetch.
7
+ import { FetchRequest } from 'ethers';
8
+
9
+ import { Client as PgClient } from 'pg';
10
+ import postgres from 'postgres';
11
+ import { Hono } from 'hono';
12
+ import { cors } from 'hono/cors';
13
+ import { setDB } from './shims/sequelize-d1/model';
14
+ import { initialize } from '../api/src/store/models';
15
+ import { Sequelize } from './shims/sequelize-d1/sequelize-class';
16
+
17
+ // Import the original Express routes (esbuild aliases handle all deps)
18
+ import expressRoutes from '../api/src/routes/index';
19
+ import type { RouteEntry } from './shims/express-compat/index';
20
+
21
+ // Import cron instance for scheduled handler
22
+ import { cronInstance } from './shims/cron';
23
+
24
+ // Import queue utilities
25
+ import { setWaitUntil, setCFQueue, flushPendingJobs, runAllScheduledJobs, getHandler } from './shims/queue';
26
+
27
+ // Import crons init to register all cron jobs
28
+ import crons from '../api/src/crons/index';
29
+
30
+ // Import queue modules that are NOT transitively imported by routes/crons
31
+ // so their handlers register in the CF queue shim and events listeners attach.
32
+ import '../api/src/queues/refund';
33
+ import '../api/src/queues/checkout-session';
34
+ import '../api/src/queues/discount-status';
35
+ import '../api/src/queues/exchange-rate-health';
36
+
37
+ // Import security shim — initFromAuthService fetches EK from AUTH_SERVICE
38
+ import { initFromAuthService } from './shims/blocklet-sdk/security';
39
+
40
+ // D1 query timing — per-request accumulator for Server-Timing header
41
+ import { resetD1Timing, getD1Timing } from './shims/sequelize-d1/timing';
42
+
43
+ // D1 auto-retry wrapper — handles transient CF D1 errors (e.g. "Network connection lost")
44
+ import { withD1Retry } from './shims/sequelize-d1/retry';
45
+
46
+ // DID Connect: login routes proxied to blocklet-service, business actions (pay/subscribe) handled locally
47
+ import { attachDIDConnectRoutes } from './did-connect-auth';
48
+
49
+ FetchRequest.registerGetUrl(async (req: FetchRequest) => {
50
+ const resp = await fetch(req.url, {
51
+ method: req.method || 'GET',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: req.hasBody() ? req.body : undefined,
54
+ });
55
+ const body = new Uint8Array(await resp.arrayBuffer());
56
+ const headers: Record<string, string> = {};
57
+ resp.headers.forEach((v: string, k: string) => {
58
+ headers[k] = v;
59
+ });
60
+ return { statusCode: resp.status, statusMessage: resp.statusText, headers, body };
61
+ });
62
+
63
+ // Caller identity resolved via AUTH_SERVICE RPC (blocklet-service)
64
+ interface CallerIdentityDTO {
65
+ did: string;
66
+ pk: string;
67
+ displayName: string;
68
+ avatar: string;
69
+ role: 'owner' | 'admin' | 'member' | 'guest';
70
+ authMethod: 'passkey' | 'did-connect' | 'access-key' | 'oauth' | 'email';
71
+ accessKeyId?: string;
72
+ approved: boolean;
73
+ }
74
+
75
+ interface Env {
76
+ DB: D1Database;
77
+ DID_CONNECT_KV: KVNamespace;
78
+ JOB_QUEUE: Queue;
79
+ ASSETS: { fetch: (request: Request | string) => Promise<Response> };
80
+ APP_SK: string;
81
+ APP_URL: string;
82
+ APP_NAME: string;
83
+ APP_PID: string;
84
+ COMPONENT_DID: string;
85
+ MEDIA_KIT_URL: string;
86
+ MEDIA_KIT: { fetch: (request: Request | string) => Promise<Response> };
87
+ AUTH_SERVICE: {
88
+ fetch: (request: Request | string) => Promise<Response>;
89
+ resolveIdentity: (
90
+ jwt: string | null,
91
+ authorizationHeader: string | null,
92
+ instanceDid?: string
93
+ ) => Promise<CallerIdentityDTO | null>;
94
+ verify: (jwt: string) => Promise<CallerIdentityDTO | null>;
95
+ verifyFull: (jwt: string) => Promise<CallerIdentityDTO | null>;
96
+ getUserByDid: (did: string) => Promise<{
97
+ did: string;
98
+ pk: string;
99
+ fullName?: string;
100
+ email?: string;
101
+ avatar?: string;
102
+ role?: string;
103
+ approved?: number;
104
+ } | null>;
105
+ };
106
+ HYPERDRIVE: { connectionString: string };
107
+ [key: string]: any;
108
+ }
109
+
110
+ // === JWT identity cache — avoid repeated AUTH_SERVICE RPC for the same token ===
111
+ const JWT_CACHE_MAX_SIZE = 1000;
112
+ const JWT_CACHE_DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes fallback
113
+ const jwtIdentityCache = new Map<string, { identity: CallerIdentityDTO; expiresAt: number }>();
114
+
115
+ function getJwtExpiry(jwt: string): number | null {
116
+ try {
117
+ const parts = jwt.split('.');
118
+ if (parts.length !== 3) return null;
119
+ // Base64url decode the payload
120
+ const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
121
+ const decoded = JSON.parse(atob(payload));
122
+ if (typeof decoded.exp === 'number') {
123
+ return decoded.exp * 1000; // convert seconds to ms
124
+ }
125
+ } catch {
126
+ // Fall through — use default TTL
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function getCachedIdentity(jwt: string): CallerIdentityDTO | null {
132
+ const entry = jwtIdentityCache.get(jwt);
133
+ if (!entry) return null;
134
+ if (Date.now() >= entry.expiresAt) {
135
+ jwtIdentityCache.delete(jwt);
136
+ return null;
137
+ }
138
+ return entry.identity;
139
+ }
140
+
141
+ function cacheIdentity(jwt: string, identity: CallerIdentityDTO): void {
142
+ // Evict oldest entries if at capacity
143
+ if (jwtIdentityCache.size >= JWT_CACHE_MAX_SIZE) {
144
+ // Delete the first (oldest inserted) entry
145
+ const firstKey = jwtIdentityCache.keys().next().value;
146
+ if (firstKey) jwtIdentityCache.delete(firstKey);
147
+ }
148
+ const expiresAt = getJwtExpiry(jwt) ?? Date.now() + JWT_CACHE_DEFAULT_TTL_MS;
149
+ jwtIdentityCache.set(jwt, { identity, expiresAt });
150
+ }
151
+
152
+ // === User-profile-by-DID cache — list pages hit this per row ===
153
+ // NOTE: never include email / phone / role / approved here. The endpoint that consumes this
154
+ // cache is unauthenticated and public — emitting PII would let anyone with a DID enumerate it.
155
+ interface UserProfilePayload {
156
+ did: string;
157
+ fullName: string;
158
+ avatar: string;
159
+ }
160
+ const USER_PROFILE_CACHE_MAX = 500;
161
+ const POSITIVE_USER_TTL_MS = 5 * 60 * 1000;
162
+ const NEGATIVE_USER_TTL_MS = 60 * 1000;
163
+ const userProfileCache = new Map<string, { payload: UserProfilePayload; expiresAt: number }>();
164
+
165
+ function getCachedUserProfile(did: string): UserProfilePayload | null {
166
+ const entry = userProfileCache.get(did);
167
+ if (!entry) return null;
168
+ if (Date.now() >= entry.expiresAt) {
169
+ userProfileCache.delete(did);
170
+ return null;
171
+ }
172
+ return entry.payload;
173
+ }
174
+
175
+ function cacheUserProfile(did: string, payload: UserProfilePayload, ttlMs: number): void {
176
+ if (userProfileCache.size >= USER_PROFILE_CACHE_MAX) {
177
+ const firstKey = userProfileCache.keys().next().value;
178
+ if (firstKey) userProfileCache.delete(firstKey);
179
+ }
180
+ userProfileCache.set(did, { payload, expiresAt: Date.now() + ttlMs });
181
+ }
182
+
183
+ // Initialize D1 + env + models on every request
184
+ let modelsInitialized = false;
185
+ let cronsInitialized = false;
186
+
187
+ function ensureModelsInit() {
188
+ if (!modelsInitialized) {
189
+ try {
190
+ const seq = new Sequelize();
191
+ initialize(seq);
192
+ modelsInitialized = true;
193
+ } catch (e) {
194
+ console.error('Model init error (non-fatal):', e);
195
+ modelsInitialized = true; // don't retry
196
+ }
197
+ }
198
+ }
199
+
200
+ function ensureCronsInit() {
201
+ if (!cronsInitialized) {
202
+ try {
203
+ crons.init();
204
+ cronsInitialized = true;
205
+ } catch (e) {
206
+ console.error('Cron init error (non-fatal):', e);
207
+ cronsInitialized = true;
208
+ }
209
+ }
210
+ }
211
+
212
+ // Security initialization: fetch EK from AUTH_SERVICE, pre-decrypt all encrypted DB values
213
+ let securityInitialized = false;
214
+
215
+ // === Build Hono app ===
216
+ // We use a factory function so DID Connect routes (which need env bindings)
217
+ // are registered in the correct order, before catch-all routes.
218
+
219
+ type HonoEnv = { Bindings: Env; Variables: { caller: CallerIdentityDTO | null } };
220
+
221
+ let cachedApp: Hono<HonoEnv> | null = null;
222
+ let cachedAppSK: string | null = null;
223
+
224
+ function buildApp(env: Env): Hono<HonoEnv> {
225
+ // Reuse cached app within the same isolate if env hasn't changed
226
+ if (cachedApp && cachedAppSK === (env.APP_SK || '')) {
227
+ return cachedApp;
228
+ }
229
+
230
+ const app = new Hono<HonoEnv>();
231
+
232
+ // CORS
233
+ app.use('/api/*', cors());
234
+ app.use('/.well-known/*', cors());
235
+ // /__blocklet__.js is fetched by external wallets (e.g. abtwallet.io, localhost
236
+ // dev wallets) to resolve app metadata + chain info before starting DID Connect.
237
+ // Without CORS the browser preflight fails and DID Connect cannot proceed.
238
+ app.use('/__blocklet__.js', cors());
239
+ app.use('*/__blocklet__.js', cors());
240
+
241
+ // Set up env + DB + queue on every request
242
+ app.use('*', async (c, next) => {
243
+ if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
244
+ (globalThis as any).__flushDeferredTimers();
245
+ }
246
+ (globalThis as any).__CF_ENV__ = c.env;
247
+ // Flag: HTTP request context. createEvent uses waitUntil (non-blocking).
248
+ // In queue consumer/cron, this flag is absent — createEvent uses __cfPendingJobs__ (blocking).
249
+ (globalThis as any).__cfHttpContext__ = true;
250
+ setDB(withD1Retry(c.env.DB.withSession('first-primary')));
251
+ if (c.env.JOB_QUEUE) setCFQueue(c.env.JOB_QUEUE);
252
+ ensureModelsInit();
253
+
254
+ // Sync CF env vars to process.env for source code compatibility
255
+ if (typeof process !== 'undefined' && process.env) {
256
+ // Stripe webhook secret: kept as env var override for webhook signature verification
257
+ // (DB value may be empty if not configured in original Blocklet Server)
258
+ if (c.env.STRIPE_WEBHOOK_SECRET) process.env.STRIPE_WEBHOOK_SECRET = c.env.STRIPE_WEBHOOK_SECRET;
259
+ if (c.env.APP_URL) {
260
+ process.env.APP_URL = c.env.APP_URL;
261
+ process.env.BLOCKLET_APP_URL = c.env.APP_URL;
262
+ }
263
+ if (c.env.APP_PID) {
264
+ process.env.BLOCKLET_APP_PID = c.env.APP_PID;
265
+ process.env.BLOCKLET_APP_ID = c.env.APP_PID;
266
+ }
267
+ if (c.env.APP_NAME) process.env.BLOCKLET_APP_NAME = c.env.APP_NAME;
268
+ if (c.env.PAYMENT_CHANGE_LOCKED_PRICE) process.env.PAYMENT_CHANGE_LOCKED_PRICE = c.env.PAYMENT_CHANGE_LOCKED_PRICE;
269
+ if (c.env.SHORT_URL_DOMAIN) process.env.SHORT_URL_DOMAIN = c.env.SHORT_URL_DOMAIN;
270
+ process.env.BLOCKLET_MODE = 'production';
271
+ }
272
+
273
+ // Register Stripe key decrypt overrides from env vars
274
+ // Fetch EK from AUTH_SERVICE and initialize decrypt capability (first request only)
275
+ if (!securityInitialized) {
276
+ securityInitialized = true;
277
+ await initFromAuthService(c.env);
278
+ }
279
+
280
+ await next();
281
+ });
282
+
283
+ // === Server-Timing: track total API time + auth time ===
284
+ app.use('/api/*', async (c, next) => {
285
+ const t0 = performance.now();
286
+
287
+ // --- Auth ---
288
+ const authT0 = performance.now();
289
+ let authSource = 'none';
290
+ const authService = c.env.AUTH_SERVICE;
291
+ if (authService && typeof authService.resolveIdentity === 'function') {
292
+ try {
293
+ const cookieHeader = c.req.header('Cookie') || '';
294
+ const match = cookieHeader.match(/(?:^|;\s*)login_token=([^;]*)/);
295
+ const jwt = match ? decodeURIComponent(match[1]) : null;
296
+ const authHeader = c.req.header('Authorization') || null;
297
+
298
+ const cacheKey = jwt || authHeader;
299
+ let caller: CallerIdentityDTO | null = null;
300
+ if (cacheKey) {
301
+ caller = getCachedIdentity(cacheKey);
302
+ }
303
+
304
+ if (caller) {
305
+ authSource = 'cache';
306
+ } else {
307
+ caller = await authService.resolveIdentity(jwt, authHeader, c.env.APP_PID);
308
+ authSource = 'rpc';
309
+ if (caller && cacheKey) {
310
+ cacheIdentity(cacheKey, caller);
311
+ }
312
+ }
313
+
314
+ c.set('caller', caller);
315
+ } catch (e: any) {
316
+ console.error('[Auth] resolveIdentity error:', e?.message || e);
317
+ c.set('caller', null);
318
+ }
319
+ } else {
320
+ c.set('caller', null);
321
+ }
322
+ const authDur = Math.round(performance.now() - authT0);
323
+
324
+ // --- Route handler ---
325
+ resetD1Timing();
326
+ await next();
327
+
328
+ // --- Append Server-Timing header ---
329
+ const totalDur = Math.round(performance.now() - t0);
330
+ const d1 = getD1Timing();
331
+ const timings = [
332
+ `total;dur=${totalDur}`,
333
+ `auth;dur=${authDur};desc="${authSource}"`,
334
+ ];
335
+ if (d1.queries > 0) {
336
+ timings.push(`db;dur=${Math.round(d1.wallMs)};desc="${d1.queries}q ${d1.rowsRead}r"`);
337
+ if (d1.sqlMs > 0) timings.push(`db_sql;dur=${Math.round(d1.sqlMs)}`);
338
+ }
339
+ // Named phases (e.g. chain, cache, ext_rpc) from measurePhase() calls
340
+ for (const [name, dur] of Object.entries(d1.phases || {})) {
341
+ timings.push(`${name};dur=${Math.round(dur)}`);
342
+ }
343
+ c.res.headers.append('Server-Timing', timings.join(', '));
344
+ });
345
+
346
+ // Health check
347
+ app.get('/health', (c) => c.json({ status: 'ok' }));
348
+
349
+ // user-session under /.well-known/service — proxy to blocklet-service session API
350
+ // Returns full session data including connectedAccounts (needed by @arcblock/ux SessionUser)
351
+ app.get('/.well-known/service/api/user-session', async (c) => {
352
+ if (!c.env.AUTH_SERVICE) return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
353
+ const req = new Request(new URL('/.well-known/service/api/did/session', c.req.url).toString(), c.req.raw);
354
+ if (c.env.APP_PID) req.headers.set('X-Instance-Did', c.env.APP_PID);
355
+ const resp = await c.env.AUTH_SERVICE.fetch(req);
356
+ return new Response(resp.body, { status: resp.status, headers: new Headers(resp.headers) });
357
+ });
358
+
359
+ // user-sessions (plural) — multi-device session list, not implemented in blocklet-service
360
+ app.get('/.well-known/service/api/user-sessions', (c) => c.json([]));
361
+
362
+ // Public user info by DID — resolves via AUTH_SERVICE.getUserByDid RPC.
363
+ // PRIVACY: this endpoint is unauthenticated (anyone who knows a DID can call it),
364
+ // so the response MUST NOT contain email / phone / role / approved or any other PII.
365
+ // Admin-only pages that need email should fetch it via authenticated customer APIs.
366
+ // UserCard renders rows by DID and also caches in sessionStorage on the client;
367
+ // a small isolate-level cache absorbs burst traffic within the same request wave.
368
+ app.get('/.well-known/service/api/user', async (c) => {
369
+ const did = c.req.query('did') || '';
370
+ if (!did) return c.json({ did: '', fullName: '', avatar: '' });
371
+
372
+ const emptyResponse = () => c.json({ did, fullName: '', avatar: '' });
373
+
374
+ const cached = getCachedUserProfile(did);
375
+ if (cached) return c.json(cached);
376
+
377
+ const authService = c.env.AUTH_SERVICE;
378
+ if (!authService || typeof authService.getUserByDid !== 'function') {
379
+ return emptyResponse();
380
+ }
381
+
382
+ try {
383
+ const user = await authService.getUserByDid(did);
384
+ if (!user) {
385
+ cacheUserProfile(did, { did, fullName: '', avatar: '' }, NEGATIVE_USER_TTL_MS);
386
+ return emptyResponse();
387
+ }
388
+ // Normalize avatar: keep inline data URIs (browsers render them directly), drop raw external
389
+ // URLs (lh3.googleusercontent.com etc. fail under our CSP/hotlink rules, and UserCard's
390
+ // useProxyFallback relies on a `/.well-known/service/proxy` route that isn't implemented
391
+ // in the Workers build). Default to the same-origin avatar endpoint, which always returns
392
+ // a deterministic SVG fallback.
393
+ const rawAvatar = user.avatar || '';
394
+ const normalizedAvatar = rawAvatar.startsWith('data:')
395
+ ? rawAvatar
396
+ : `/.well-known/service/user/avatar/${user.did}`;
397
+ const payload = {
398
+ did: user.did,
399
+ fullName: user.fullName || '',
400
+ avatar: normalizedAvatar,
401
+ };
402
+ cacheUserProfile(did, payload, POSITIVE_USER_TTL_MS);
403
+ return c.json(payload);
404
+ } catch (e: any) {
405
+ console.error('[user-by-did] RPC error:', e?.message || e);
406
+ return emptyResponse();
407
+ }
408
+ });
409
+
410
+ // === DID Auth Service — forward all /.well-known/service/* to AUTH_SERVICE ===
411
+ // This gives payment-kit: real login (passkey/DID wallet), dynamic branding,
412
+ // theme, admin panel, user management — all from the shared DID service.
413
+ app.all('/.well-known/service/*', async (c) => {
414
+ if (c.env.AUTH_SERVICE) {
415
+ // Add X-Instance-Did header so blocklet-service uses registered instance keys
416
+ const req = new Request(c.req.raw);
417
+ if (c.env.APP_PID) {
418
+ req.headers.set('X-Instance-Did', c.env.APP_PID);
419
+ }
420
+ // Pass external tabs for admin/user page integration
421
+ req.headers.set(
422
+ 'X-External-Tabs',
423
+ JSON.stringify([{ id: 'billing', label: 'Billing', labels: { en: 'Billing', zh: '账单' }, url: '/customer' }])
424
+ );
425
+ const resp = await c.env.AUTH_SERVICE.fetch(req);
426
+ // Re-create Response to ensure Set-Cookie and other headers are forwarded to the browser
427
+ return new Response(resp.body, {
428
+ status: resp.status,
429
+ statusText: resp.statusText,
430
+ headers: new Headers(resp.headers),
431
+ });
432
+ }
433
+ // Fallback: no AUTH_SERVICE binding (local dev without service binding)
434
+ return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
435
+ });
436
+
437
+ // __blocklet__.js — forward to AUTH_SERVICE, then override payment-kit specific fields
438
+ // Supports ?type=json for JSON output, respects Cache-Control: private, no-store
439
+ // Register for both root /__blocklet__.js and prefixed /x/__blocklet__.js
440
+ for (const pattern of ['/__blocklet__.js', '*/__blocklet__.js'] as const) {
441
+ app.get(pattern, async (c) => {
442
+ const isJson = new URL(c.req.url).searchParams.get('type') === 'json';
443
+
444
+ if (c.env.AUTH_SERVICE) {
445
+ const url = new URL(c.req.url);
446
+ // Always fetch JSON from AUTH_SERVICE for reliable parsing
447
+ url.pathname = '/__blocklet__.js';
448
+ url.searchParams.set('type', 'json');
449
+ const blockletReq = new Request(url.toString(), c.req.raw);
450
+ if (c.env.APP_PID) {
451
+ blockletReq.headers.set('X-Instance-Did', c.env.APP_PID);
452
+ }
453
+ const resp = await c.env.AUTH_SERVICE.fetch(blockletReq);
454
+ try {
455
+ // AUTH_SERVICE may return JSON or JavaScript (`window.blocklet = {...};`)
456
+ // depending on whether the ?type=json param is honored. Handle both by
457
+ // extracting the JSON object literal from the response text.
458
+ const respText = await resp.text();
459
+ let data: Record<string, unknown>;
460
+ try {
461
+ data = JSON.parse(respText);
462
+ } catch {
463
+ const start = respText.indexOf('{');
464
+ const end = respText.lastIndexOf('}') + 1;
465
+ if (start < 0 || end <= start) {
466
+ throw new Error('AUTH_SERVICE __blocklet__.js response has no JSON payload');
467
+ }
468
+ data = JSON.parse(respText.slice(start, end));
469
+ }
470
+ // Override payment-kit specific fields
471
+ data.appPid = c.env.APP_PID || data.appPid;
472
+ data.componentId = c.env.COMPONENT_DID || data.componentId;
473
+ data.appUrl = c.env.APP_URL || new URL(c.req.url).origin;
474
+ data.cloudflareWorker = true;
475
+
476
+ // Mount prefix support: gateway passes X-Mount-Prefix header
477
+ const mountPrefix = c.req.header('X-Mount-Prefix');
478
+ if (mountPrefix) {
479
+ data.prefix = mountPrefix;
480
+ data.groupPrefix = mountPrefix;
481
+ }
482
+
483
+ // Append Payment Kit component mount points for admin panel integration
484
+ const existingMounts = Array.isArray(data.componentMountPoints) ? (data.componentMountPoints as any[]) : [];
485
+ data.componentMountPoints = [
486
+ ...existingMounts,
487
+ {
488
+ did: c.env.COMPONENT_DID || 'payment-kit',
489
+ title: 'My Account',
490
+ name: 'customer',
491
+ mountPoint: '/customer',
492
+ },
493
+ ];
494
+
495
+ if (isJson) {
496
+ return new Response(JSON.stringify(data), {
497
+ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store' },
498
+ });
499
+ }
500
+ return new Response(`window.blocklet = ${JSON.stringify(data)};`, {
501
+ headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'private, no-store' },
502
+ });
503
+ } catch {
504
+ // Fallback: return AUTH_SERVICE response as-is
505
+ return resp;
506
+ }
507
+ }
508
+ const script = `window.blocklet = { appName: '${c.env.APP_NAME || 'Payment Kit'}', appUrl: '${c.env.APP_URL || ''}', appPid: '${c.env.APP_PID || ''}', theme: {prefer:'light'}, cloudflareWorker: true };`;
509
+ return new Response(script, {
510
+ headers: { 'Content-Type': 'application/javascript', 'Cache-Control': 'private, no-store' },
511
+ });
512
+ });
513
+ }
514
+
515
+ // favicon.ico — forward to AUTH_SERVICE (serves from R2 or default SVG)
516
+ app.get('/favicon.ico', async (c) => {
517
+ if (c.env.AUTH_SERVICE) {
518
+ return c.env.AUTH_SERVICE.fetch(c.req.raw);
519
+ }
520
+ return new Response(null, { status: 404 });
521
+ });
522
+
523
+ // Session endpoints — resolve via AUTH_SERVICE RPC
524
+ // /api/user-session — proxy to blocklet-service session API (same as /.well-known/service/api/user-session)
525
+ app.get('/api/user-session', async (c) => {
526
+ if (!c.env.AUTH_SERVICE) return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
527
+ const req = new Request(new URL('/.well-known/service/api/did/session', c.req.url).toString(), c.req.raw);
528
+ if (c.env.APP_PID) req.headers.set('X-Instance-Did', c.env.APP_PID);
529
+ const resp = await c.env.AUTH_SERVICE.fetch(req);
530
+ return new Response(resp.body, { status: resp.status, headers: new Headers(resp.headers) });
531
+ });
532
+
533
+ // Debug endpoints
534
+ app.get('/api/__debug__/time', (c) => c.json({ now: Date.now(), floor: Math.floor(Date.now() / 1000) }));
535
+ app.get('/api/__debug__/sk-check', (c) => {
536
+ const sk = c.env.APP_SK || '';
537
+ return c.json({
538
+ length: sk.length,
539
+ prefix: sk.substring(0, 6),
540
+ isHex: /^[0-9a-fA-F]+$/.test(sk),
541
+ startsWithZ: sk.startsWith('z'),
542
+ });
543
+ });
544
+
545
+ // === DID Auth Login routes ===
546
+ // Only proxy login-related /api/did/* paths to blocklet-service.
547
+ // Other /api/did/* paths (subscription, pay, collect, etc.) are Payment Kit's
548
+ // own DID Connect actions handled by attachDIDConnectRoutes below.
549
+ const DID_AUTH_PROXY_PATHS = [
550
+ '/api/did/login/',
551
+ '/api/did/session',
552
+ '/api/did/refreshSession',
553
+ '/api/did/connect/',
554
+ '/api/did/logout',
555
+ ];
556
+ app.all('/api/did/*', async (c, next) => {
557
+ const path = new URL(c.req.url).pathname;
558
+ const shouldProxy = DID_AUTH_PROXY_PATHS.some((p) => path.startsWith(p) || path === p);
559
+ if (!shouldProxy) return next(); // Fall through to attachDIDConnectRoutes or Express routes
560
+
561
+ if (!c.env.AUTH_SERVICE) {
562
+ return c.json({ error: 'AUTH_SERVICE not configured' }, 503);
563
+ }
564
+ const url = new URL(c.req.url);
565
+ url.pathname = `/.well-known/service${url.pathname}`;
566
+ const req = new Request(url.toString(), c.req.raw);
567
+ if (c.env.APP_PID) {
568
+ req.headers.set('X-Instance-Did', c.env.APP_PID);
569
+ }
570
+ const resp = await c.env.AUTH_SERVICE.fetch(req);
571
+ const headers = new Headers(resp.headers);
572
+ return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers });
573
+ });
574
+
575
+ // === DID Connect business actions (subscription, pay, collect, etc.) ===
576
+ if (env.APP_SK && env.DID_CONNECT_KV) {
577
+ try {
578
+ attachDIDConnectRoutes(app, env.DID_CONNECT_KV, env.APP_SK);
579
+ console.log('[CF Worker] DID Connect routes attached');
580
+ } catch (e: any) {
581
+ console.error('DID Connect init error:', e?.message || e);
582
+ }
583
+ }
584
+
585
+ // Notification unread count
586
+ app.get('/api/notifications/unread-count', (c) => c.json({ unReadCount: 0 }));
587
+
588
+ // Manually trigger job dispatch (same as cron's runAllScheduledJobs)
589
+ app.post('/api/__dev__/dispatch-jobs', async (c) => {
590
+ const names = getAllHandlerNames();
591
+ const result = await runAllScheduledJobs();
592
+ return c.json({ handlers: names, ...result });
593
+ });
594
+
595
+ // === Express-to-Hono Route Adapter ===
596
+ mountExpressRoutes(app, '/api', expressRoutes);
597
+
598
+ // Dev endpoint: D1 admin operations
599
+ // Test CF Queue send directly
600
+ app.get('/api/__dev__/queue-test', async (c) => {
601
+ const queue = c.env.JOB_QUEUE;
602
+ if (!queue) return c.json({ error: 'JOB_QUEUE not bound' }, 500);
603
+ try {
604
+ const t = Date.now();
605
+ await queue.send({ test: true, ts: t });
606
+ return c.json({ success: true, ms: Date.now() - t });
607
+ } catch (err: any) {
608
+ return c.json({ error: err?.message, name: err?.name, code: err?.code }, 500);
609
+ }
610
+ });
611
+
612
+ app.post('/api/__dev__/d1-exec', async (c) => {
613
+ const { sql } = (await c.req.json()) as { sql: string };
614
+ if (!sql) return c.json({ error: 'sql required' }, 400);
615
+ const db = c.env.DB;
616
+ const result = await db.prepare(sql).all();
617
+ return c.json({ success: true, results: result.results, meta: result.meta });
618
+ });
619
+
620
+ // Dev endpoint: comprehensive DB benchmark (D1 + remote PostgreSQL RTT)
621
+ app.get('/api/__dev__/benchmark-db', async (c) => {
622
+ const db = c.env.DB;
623
+ const results: Record<string, any> = { d1: {}, postgres: {}, comparison: {} };
624
+
625
+ // === D1 Benchmark ===
626
+ // Warm-up
627
+ await db.prepare('SELECT 1').first();
628
+
629
+ // 1. Single SELECT
630
+ let t = Date.now();
631
+ await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
632
+ results.d1.single_select_ms = Date.now() - t;
633
+
634
+ // 2. Single INSERT + SELECT batch (simulate Model.create)
635
+ const testId = `bench_${Date.now()}`;
636
+ t = Date.now();
637
+ await db.batch([
638
+ db.prepare('INSERT INTO _locks (name, owner, expires_at) VALUES (?, ?, ?)').bind(testId, 'bench', 0),
639
+ db.prepare('SELECT * FROM _locks WHERE name = ?').bind(testId),
640
+ ]);
641
+ results.d1.insert_select_batch_ms = Date.now() - t;
642
+ await db.prepare('DELETE FROM _locks WHERE name = ?').bind(testId).run();
643
+
644
+ // 3. 5x sequential SELECTs (simulate N+1 queries)
645
+ t = Date.now();
646
+ for (let i = 0; i < 5; i++) {
647
+ await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
648
+ }
649
+ results.d1.sequential_5x_ms = Date.now() - t;
650
+ results.d1.sequential_5x_avg_ms = Math.round(results.d1.sequential_5x_ms / 5);
651
+
652
+ // 4. 5x batch (one D1 round-trip)
653
+ t = Date.now();
654
+ await db.batch([
655
+ db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1'),
656
+ db.prepare('SELECT id, status FROM payment_intents LIMIT 1'),
657
+ db.prepare('SELECT id, status FROM invoices LIMIT 1'),
658
+ db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1'),
659
+ db.prepare('SELECT id, type FROM payment_methods LIMIT 1'),
660
+ ]);
661
+ results.d1.batch_5x_ms = Date.now() - t;
662
+
663
+ // 5. 10x sequential
664
+ t = Date.now();
665
+ for (let i = 0; i < 10; i++) {
666
+ await db.prepare('SELECT id FROM checkout_sessions LIMIT 1').first();
667
+ }
668
+ results.d1.sequential_10x_ms = Date.now() - t;
669
+ results.d1.sequential_10x_avg_ms = Math.round(results.d1.sequential_10x_ms / 10);
670
+
671
+ // 6. 10x batch
672
+ t = Date.now();
673
+ await db.batch(Array.from({ length: 10 }, () => db.prepare('SELECT id FROM checkout_sessions LIMIT 1')));
674
+ results.d1.batch_10x_ms = Date.now() - t;
675
+
676
+ // 7. UPDATE RETURNING (simulate optimized getInvoiceNumber)
677
+ t = Date.now();
678
+ await db
679
+ .prepare(
680
+ 'UPDATE _locks SET expires_at = expires_at + 1 WHERE name = (SELECT name FROM _locks LIMIT 1) RETURNING *'
681
+ )
682
+ .run();
683
+ results.d1.update_returning_ms = Date.now() - t;
684
+
685
+ // 8. Simulate full getInvoiceNumber (old): reload + increment(UPDATE + reload)
686
+ // Using _locks as proxy table
687
+ const lockRow = await db.prepare('SELECT name FROM _locks LIMIT 1').first();
688
+ if (lockRow) {
689
+ t = Date.now();
690
+ await db
691
+ .prepare('SELECT * FROM _locks WHERE name = ?')
692
+ .bind((lockRow as any).name)
693
+ .first(); // reload
694
+ await db
695
+ .prepare('UPDATE _locks SET expires_at = expires_at + 1 WHERE name = ?')
696
+ .bind((lockRow as any).name)
697
+ .run(); // update
698
+ await db
699
+ .prepare('SELECT * FROM _locks WHERE name = ?')
700
+ .bind((lockRow as any).name)
701
+ .first(); // reload after increment
702
+ results.d1.getInvoiceNumber_old_3RT_ms = Date.now() - t;
703
+
704
+ t = Date.now();
705
+ await db
706
+ .prepare('UPDATE _locks SET expires_at = expires_at + 1 WHERE name = ? RETURNING expires_at - 1 as prev')
707
+ .bind((lockRow as any).name)
708
+ .first();
709
+ results.d1.getInvoiceNumber_new_1RT_ms = Date.now() - t;
710
+ }
711
+
712
+ // === PostgreSQL via Hyperdrive benchmark ===
713
+ // Try Hyperdrive first, then direct DATABASE_URL
714
+ const pgConnections = [
715
+ { name: 'hyperdrive', connStr: c.env.HYPERDRIVE?.connectionString },
716
+ { name: 'direct', connStr: c.env.DATABASE_URL },
717
+ ].filter((x) => x.connStr);
718
+
719
+ for (const pg of pgConnections) {
720
+ const pgResult: Record<string, any> = {};
721
+ results.postgres[pg.name] = pgResult;
722
+ pgResult.connection_string = pg.connStr!.replace(/:[^:@]+@/, ':***@');
723
+
724
+ try {
725
+ // Connect with timeout
726
+ t = Date.now();
727
+ const client = new PgClient({
728
+ connectionString: pg.connStr,
729
+ connectionTimeoutMillis: 5000,
730
+ ssl: pg.name === 'direct' ? { rejectUnauthorized: false } : undefined,
731
+ });
732
+ await client.connect();
733
+ pgResult.connect_ms = Date.now() - t;
734
+
735
+ // Warm-up
736
+ await client.query('SELECT 1');
737
+
738
+ // 1. Single SELECT
739
+ t = Date.now();
740
+ const r1 = await client.query('SELECT 1 as test');
741
+ pgResult.single_select_ms = Date.now() - t;
742
+ pgResult.single_select_result = r1.rows?.[0];
743
+
744
+ // 2. 5x sequential SELECTs
745
+ t = Date.now();
746
+ for (let i = 0; i < 5; i++) {
747
+ await client.query('SELECT 1 as test');
748
+ }
749
+ pgResult.sequential_5x_ms = Date.now() - t;
750
+ pgResult.sequential_5x_avg_ms = Math.round(pgResult.sequential_5x_ms / 5);
751
+
752
+ // 3. 10x sequential SELECTs
753
+ t = Date.now();
754
+ for (let i = 0; i < 10; i++) {
755
+ await client.query('SELECT 1 as test');
756
+ }
757
+ pgResult.sequential_10x_ms = Date.now() - t;
758
+ pgResult.sequential_10x_avg_ms = Math.round(pgResult.sequential_10x_ms / 10);
759
+
760
+ await client.end();
761
+ } catch (err: any) {
762
+ pgResult.error = err?.message || 'Failed';
763
+ pgResult.stack = err?.stack?.split('\n').slice(0, 3).join(' | ');
764
+ }
765
+ }
766
+
767
+ // === postgres.js driver test (alternative to pg) ===
768
+ const pgJsConnStr = c.env.HYPERDRIVE?.connectionString || c.env.DATABASE_URL;
769
+ if (pgJsConnStr) {
770
+ const pgJsResult: Record<string, any> = {};
771
+ results.postgres.postgres_js = pgJsResult;
772
+ try {
773
+ t = Date.now();
774
+ const sql = postgres(pgJsConnStr, { ssl: 'require', connect_timeout: 5, idle_timeout: 5 });
775
+ await sql`SELECT 1 as test`;
776
+ pgJsResult.connect_and_warmup_ms = Date.now() - t;
777
+
778
+ t = Date.now();
779
+ const r = await sql`SELECT 1 as test`;
780
+ pgJsResult.single_select_ms = Date.now() - t;
781
+ pgJsResult.single_select_result = r[0];
782
+
783
+ t = Date.now();
784
+ for (let i = 0; i < 5; i++) await sql`SELECT 1 as test`;
785
+ pgJsResult.sequential_5x_ms = Date.now() - t;
786
+ pgJsResult.sequential_5x_avg_ms = Math.round(pgJsResult.sequential_5x_ms / 5);
787
+
788
+ t = Date.now();
789
+ for (let i = 0; i < 10; i++) await sql`SELECT 1 as test`;
790
+ pgJsResult.sequential_10x_ms = Date.now() - t;
791
+ pgJsResult.sequential_10x_avg_ms = Math.round(pgJsResult.sequential_10x_ms / 10);
792
+
793
+ await sql.end();
794
+ } catch (err: any) {
795
+ pgJsResult.error = err?.message || 'Failed';
796
+ pgJsResult.stack = err?.stack?.split('\n').slice(0, 3).join(' | ');
797
+ }
798
+ }
799
+
800
+ // === Comparison ===
801
+ const hdResult = results.postgres.hyperdrive || {};
802
+ const directResult = results.postgres.direct || {};
803
+ const pgJsR = results.postgres.postgres_js || {};
804
+ const pgSingle = hdResult.single_select_ms || directResult.single_select_ms || pgJsR.single_select_ms;
805
+ const d1Single = results.d1.single_select_ms;
806
+ results.comparison = {
807
+ d1_single_ms: d1Single,
808
+ d1_batch_5x_ms: results.d1.batch_5x_ms,
809
+ d1_batch_5x_avg_ms: Math.round(results.d1.batch_5x_ms / 5),
810
+ pg_single_ms: pgSingle || 'N/A',
811
+ pg_5x_avg_ms: results.postgres.sequential_5x_avg_ms || 'N/A',
812
+ speedup: pgSingle ? `${(d1Single / pgSingle).toFixed(1)}x` : 'N/A',
813
+ conclusion: pgSingle
814
+ ? d1Single > pgSingle
815
+ ? `PG is ${(d1Single / pgSingle).toFixed(1)}x faster per query (${pgSingle}ms vs ${d1Single}ms). For 15 sequential queries: PG ~${pgSingle * 15}ms vs D1 ~${d1Single * 15}ms`
816
+ : `D1 is faster or comparable (${d1Single}ms vs ${pgSingle}ms)`
817
+ : 'PG benchmark failed — check Hyperdrive config',
818
+ };
819
+
820
+ return c.json(results);
821
+ });
822
+
823
+ // Legacy D1-only benchmark (kept for backward compatibility)
824
+ app.get('/api/__dev__/d1-benchmark', async (c) => {
825
+ const db = c.env.DB;
826
+ const results: Record<string, any> = {};
827
+
828
+ // 1. Single query (warm-up)
829
+ await db.prepare('SELECT 1').first();
830
+
831
+ // 2. Single SELECT RTT
832
+ const t1 = Date.now();
833
+ await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
834
+ results['1_single_select'] = Date.now() - t1;
835
+
836
+ // 3. 5x sequential SELECTs
837
+ const t2 = Date.now();
838
+ for (let i = 0; i < 5; i++) {
839
+ await db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first();
840
+ }
841
+ results['2_sequential_5x'] = Date.now() - t2;
842
+ results['2_sequential_avg'] = Math.round(results['2_sequential_5x'] / 5);
843
+
844
+ // 4. 5x batch (one round-trip)
845
+ const t3 = Date.now();
846
+ await db.batch([
847
+ db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1'),
848
+ db.prepare('SELECT id, status FROM payment_intents LIMIT 1'),
849
+ db.prepare('SELECT id, status FROM invoices LIMIT 1'),
850
+ db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1'),
851
+ db.prepare('SELECT id, type FROM payment_methods LIMIT 1'),
852
+ ]);
853
+ results['3_batch_5x'] = Date.now() - t3;
854
+
855
+ // 5. 5x parallel (Promise.all, separate round-trips)
856
+ const t4 = Date.now();
857
+ await Promise.all([
858
+ db.prepare('SELECT id, status FROM checkout_sessions LIMIT 1').first(),
859
+ db.prepare('SELECT id, status FROM payment_intents LIMIT 1').first(),
860
+ db.prepare('SELECT id, status FROM invoices LIMIT 1').first(),
861
+ db.prepare('SELECT id, symbol FROM payment_currencies LIMIT 1').first(),
862
+ db.prepare('SELECT id, type FROM payment_methods LIMIT 1').first(),
863
+ ]);
864
+ results['4_parallel_5x'] = Date.now() - t4;
865
+
866
+ // 6. INSERT + SELECT batch (simulate create)
867
+ const testId = `bench_${Date.now()}`;
868
+ const t5 = Date.now();
869
+ await db.batch([
870
+ db.prepare('INSERT INTO _locks (name, owner, expires_at) VALUES (?, ?, ?)').bind(testId, 'bench', 0),
871
+ db.prepare('SELECT * FROM _locks WHERE name = ?').bind(testId),
872
+ ]);
873
+ results['5_insert_select_batch'] = Date.now() - t5;
874
+ await db.prepare('DELETE FROM _locks WHERE name = ?').bind(testId).run();
875
+
876
+ // 7. 10x sequential (simulate ensureInvoiceForCheckout)
877
+ const t6 = Date.now();
878
+ for (let i = 0; i < 10; i++) {
879
+ await db.prepare('SELECT id FROM checkout_sessions LIMIT 1').first();
880
+ }
881
+ results['6_sequential_10x'] = Date.now() - t6;
882
+ results['6_sequential_10x_avg'] = Math.round(results['6_sequential_10x'] / 10);
883
+
884
+ // 8. 10x batch
885
+ const t7 = Date.now();
886
+ await db.batch(Array.from({ length: 10 }, () => db.prepare('SELECT id FROM checkout_sessions LIMIT 1')));
887
+ results['7_batch_10x'] = Date.now() - t7;
888
+
889
+ results.summary = {
890
+ single_rtt: results['1_single_select'],
891
+ batch_saves: `${results['2_sequential_5x'] - results['3_batch_5x']}ms for 5 queries`,
892
+ parallel_saves: `${results['2_sequential_5x'] - results['4_parallel_5x']}ms for 5 queries`,
893
+ batch_10x_saves: `${results['6_sequential_10x'] - results['7_batch_10x']}ms for 10 queries`,
894
+ };
895
+
896
+ return c.json(results);
897
+ });
898
+
899
+ // Dev endpoint to manually trigger cron jobs
900
+ app.post('/api/__dev__/cron/run', async (c) => {
901
+ ensureCronsInit();
902
+ const body = await c.req.json().catch(() => ({}));
903
+ const jobName = (body as any)?.job;
904
+ if (jobName) {
905
+ await cronInstance.runJob(jobName);
906
+ return c.json({ ok: true, ran: jobName });
907
+ }
908
+ await cronInstance.runAll();
909
+ return c.json({ ok: true, ran: 'all', jobs: cronInstance.getJobNames() });
910
+ });
911
+
912
+ app.get('/api/__dev__/cron/jobs', (c) => {
913
+ ensureCronsInit();
914
+ return c.json({ jobs: cronInstance.getJobNames() });
915
+ });
916
+
917
+ // Dev endpoint to run raw SQL (for migrations)
918
+ app.get('/api/__dev__/d1/exec', async (c) => {
919
+ const sql = c.req.query('sql');
920
+ if (!sql) return c.json({ error: 'sql query param required' }, 400);
921
+ try {
922
+ const result = await c.env.DB.exec(sql);
923
+ return c.json({ ok: true, result });
924
+ } catch (err: any) {
925
+ return c.json({ error: err?.message || 'exec failed' }, 500);
926
+ }
927
+ });
928
+
929
+ // Dev endpoint to query D1 (returns rows)
930
+ app.get('/api/__dev__/d1/query', async (c) => {
931
+ const sql = c.req.query('sql');
932
+ if (!sql) return c.json({ error: 'sql query param required' }, 400);
933
+ try {
934
+ const result = await c.env.DB.prepare(sql).all();
935
+ return c.json({ ok: true, results: result.results, meta: result.meta });
936
+ } catch (err: any) {
937
+ return c.json({ error: err?.message || 'query failed' }, 500);
938
+ }
939
+ });
940
+
941
+ // Catch-all for unimplemented API routes
942
+ app.all('/api/*', (c) => {
943
+ return c.json({ error: `Not yet implemented: ${c.req.method} ${c.req.path}` }, 501);
944
+ });
945
+
946
+ // === Media Kit Proxy ===
947
+ // CORS preflight for media-kit (must be before app.all)
948
+ app.options('/media-kit/*', (_c) => {
949
+ return new Response(null, {
950
+ status: 204,
951
+ headers: {
952
+ 'Access-Control-Allow-Origin': '*',
953
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
954
+ 'Access-Control-Allow-Headers':
955
+ 'Content-Type, Authorization, x-component-did, x-uploader-base-url, x-uploader-endpoint-url',
956
+ 'Access-Control-Max-Age': '86400',
957
+ },
958
+ });
959
+ });
960
+
961
+ // Forward /media-kit/* requests to the Media Kit CF Worker
962
+ app.all('/media-kit/*', async (c) => {
963
+ const url = new URL(c.req.url);
964
+ const targetPath = url.pathname.replace(/^\/media-kit/, '') || '/';
965
+ const mediaKitUrl = c.env.MEDIA_KIT_URL || 'https://media-kit.yexiaofang.workers.dev';
966
+
967
+ try {
968
+ const targetUrl = `${mediaKitUrl}${targetPath}${url.search}`;
969
+
970
+ // Forward relevant headers
971
+ const reqHeaders = new Headers();
972
+ for (const h of ['content-type', 'accept', 'authorization', 'x-component-did', 'x-user-did', 'x-csrf-token']) {
973
+ const v = c.req.header(h);
974
+ if (v) reqHeaders.set(h, v);
975
+ }
976
+
977
+ // Use Service Binding if available (avoids 1042 same-zone error)
978
+ const mediaKit = c.env.MEDIA_KIT;
979
+ let resp: Response;
980
+ if (mediaKit?.fetch) {
981
+ resp = await mediaKit.fetch(
982
+ new Request(targetUrl, {
983
+ method: c.req.method,
984
+ headers: reqHeaders,
985
+ body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body,
986
+ })
987
+ );
988
+ } else {
989
+ // Fallback to direct fetch (may get 1042 on workers.dev)
990
+ resp = await fetch(targetUrl, {
991
+ method: c.req.method,
992
+ headers: reqHeaders,
993
+ body: ['GET', 'HEAD'].includes(c.req.method) ? undefined : c.req.raw.body,
994
+ });
995
+ }
996
+
997
+ const respHeaders = new Headers(resp.headers);
998
+ respHeaders.set('Access-Control-Allow-Origin', '*');
999
+
1000
+ // Rewrite relative URLs in JSON responses to absolute URLs with /media-kit prefix
1001
+ const ct = resp.headers.get('content-type') || '';
1002
+ if (ct.includes('application/json') && resp.status >= 200 && resp.status < 300) {
1003
+ try {
1004
+ const body = (await resp.json()) as Record<string, any>;
1005
+ const { origin } = url;
1006
+ // Rewrite presignedUrl and any other relative paths
1007
+ if (body.presignedUrl && body.presignedUrl.startsWith('/')) {
1008
+ body.presignedUrl = `${origin}/media-kit${body.presignedUrl}`;
1009
+ }
1010
+ if (body.url && typeof body.url === 'string' && body.url.startsWith('/')) {
1011
+ body.url = `${origin}/media-kit${body.url}`;
1012
+ }
1013
+ if (body.fileUrl && typeof body.fileUrl === 'string' && body.fileUrl.startsWith('/')) {
1014
+ body.fileUrl = `${origin}/media-kit${body.fileUrl}`;
1015
+ }
1016
+ return new Response(JSON.stringify(body), { status: resp.status, headers: respHeaders });
1017
+ } catch {
1018
+ // If JSON parse fails, return as-is
1019
+ }
1020
+ }
1021
+
1022
+ return new Response(resp.body, { status: resp.status, headers: respHeaders });
1023
+ } catch (err: any) {
1024
+ console.error('[CF Worker] Media Kit proxy error:', err?.message);
1025
+ return c.json({ error: `Media Kit proxy error: ${err?.message || 'unknown'}` }, 502);
1026
+ }
1027
+ });
1028
+
1029
+ // SPA fallback — serve static assets or index.html for client-side routing
1030
+ app.all('*', async (c) => {
1031
+ const assets = c.env.ASSETS;
1032
+ if (!assets) {
1033
+ return c.text('Not found', 404);
1034
+ }
1035
+
1036
+ // Helper: rewrite HTML for mount prefix support.
1037
+ //
1038
+ // The inline bootstrap in public/index.html already pulls /__blocklet__.js?type=json
1039
+ // synchronously and overlays it into window.blocklet with localOnly protection.
1040
+ // For the root-mount case we do NOT inject an extra <script src="/__blocklet__.js">,
1041
+ // because that script runs a plain `window.blocklet = {...}` assignment and wipes
1042
+ // out navigation/componentMountPoints that the bootstrap just set.
1043
+ //
1044
+ // The mountPrefix case still needs to inject a script, because the inline bootstrap
1045
+ // hardcodes `pfx + '__blocklet__.js'` based on window.blocklet.prefix which — at
1046
+ // bootstrap time — defaults to '/' and doesn't know about the gateway-provided
1047
+ // mount prefix. The injected script provides a prefix-aware fallback.
1048
+ const rewriteHtml = async (htmlResponse: Response) => {
1049
+ if (!htmlResponse.headers.get('content-type')?.includes('text/html')) return htmlResponse;
1050
+ let html = await htmlResponse.text();
1051
+ const mountPrefix = c.req.header('X-Mount-Prefix');
1052
+ if (mountPrefix && mountPrefix !== '/') {
1053
+ const pfx = mountPrefix.endsWith('/') ? mountPrefix.slice(0, -1) : mountPrefix;
1054
+ // Rewrite absolute asset paths: src="/assets/..." → src="/payment/assets/..."
1055
+ html = html.replace(/((?:src|href)=["'])\/assets\//g, `$1${pfx}/assets/`);
1056
+ // NOTE: Do NOT inject <script src="/__blocklet__.js"> here.
1057
+ // The inline bootstrap already fetches __blocklet__.js?type=json via XHR and
1058
+ // merges the remote config while protecting localOnly fields (navigation,
1059
+ // componentMountPoints). The script tag does a plain `window.blocklet = {...}`
1060
+ // assignment which OVERWRITES those fields with empty values from AUTH_SERVICE,
1061
+ // breaking sidebar navigation and causing redirect-to-home on page refresh.
1062
+ }
1063
+
1064
+ // Build a fresh headers object. Copying from htmlResponse.headers keeps the
1065
+ // original Cache-Control (public, max-age=...) from the asset binding, and
1066
+ // Cloudflare's edge cache then stores the HTML — leaving users stuck on
1067
+ // stale hashed bundle references after every deploy. HTML must never be
1068
+ // edge-cached; asset files (with hashed names) are immutable and can be.
1069
+ const headers = new Headers();
1070
+ const contentType = htmlResponse.headers.get('content-type');
1071
+ if (contentType) headers.set('Content-Type', contentType);
1072
+ // no-store beats CF edge cache (no-cache only means "revalidate",
1073
+ // which CF still treats as cacheable for GET/HEAD).
1074
+ headers.set('Cache-Control', 'no-store, must-revalidate, max-age=0');
1075
+ // Cloudflare-specific override — belt-and-braces against any edge cache policy.
1076
+ headers.set('CDN-Cache-Control', 'no-store');
1077
+ headers.set('Cloudflare-CDN-Cache-Control', 'no-store');
1078
+
1079
+ return new Response(html, {
1080
+ status: htmlResponse.status,
1081
+ headers,
1082
+ });
1083
+ };
1084
+
1085
+ try {
1086
+ const assetResponse = await assets.fetch(c.req.raw);
1087
+ if (assetResponse.status !== 404) {
1088
+ // HTML from assets (e.g. /index.html) also needs rewriting
1089
+ if (assetResponse.headers.get('content-type')?.includes('text/html')) {
1090
+ return rewriteHtml(assetResponse);
1091
+ }
1092
+ return assetResponse;
1093
+ }
1094
+ } catch {
1095
+ // Fall through to SPA fallback
1096
+ }
1097
+
1098
+ try {
1099
+ const url = new URL(c.req.url);
1100
+ url.pathname = '/index.html';
1101
+ const htmlResponse = await assets.fetch(new Request(url.toString(), c.req.raw));
1102
+ return rewriteHtml(htmlResponse);
1103
+ } catch {
1104
+ return c.text('Not found', 404);
1105
+ }
1106
+ });
1107
+
1108
+ cachedApp = app;
1109
+ cachedAppSK = env.APP_SK || '';
1110
+ return app;
1111
+ }
1112
+
1113
+ // === Express-to-Hono Route Adapter ===
1114
+
1115
+ function normalizeRoutePath(prefix: string, routePath: string): string {
1116
+ let full = (prefix + routePath).replace(/\/+/g, '/');
1117
+ if (!full.startsWith('/')) full = `/${full}`;
1118
+ if (full.length > 1 && full.endsWith('/')) full = full.slice(0, -1);
1119
+ return full;
1120
+ }
1121
+
1122
+ function createExpressReq(c: any, routeParams: Record<string, string>): any {
1123
+ const url = new URL(c.req.url);
1124
+ const query: Record<string, any> = {};
1125
+ url.searchParams.forEach((v, k) => {
1126
+ query[k] = v;
1127
+ });
1128
+
1129
+ const headers: Record<string, string> = {};
1130
+ c.req.raw.headers.forEach((v: string, k: string) => {
1131
+ headers[k.toLowerCase()] = v;
1132
+ });
1133
+
1134
+ const req: any = {
1135
+ method: c.req.method,
1136
+ url: url.pathname + url.search,
1137
+ path: url.pathname,
1138
+ originalUrl: url.pathname + url.search,
1139
+ query,
1140
+ params: { ...routeParams },
1141
+ body: null,
1142
+ headers,
1143
+ user: null,
1144
+ livemode: true,
1145
+ baseCurrency: null,
1146
+ ip: headers['cf-connecting-ip'] || headers['x-forwarded-for'] || '127.0.0.1',
1147
+ get(name: string) {
1148
+ return headers[name.toLowerCase()];
1149
+ },
1150
+ header(name: string) {
1151
+ return headers[name.toLowerCase()];
1152
+ },
1153
+ };
1154
+
1155
+ return req;
1156
+ }
1157
+
1158
+ function createExpressRes(): any {
1159
+ const res: any = {
1160
+ _statusCode: 200,
1161
+ _headers: {} as Record<string, string>,
1162
+ _body: null as any,
1163
+ _sent: false,
1164
+ _redirectUrl: null as string | null,
1165
+ headersSent: false,
1166
+
1167
+ status(code: number) {
1168
+ res._statusCode = code;
1169
+ return res;
1170
+ },
1171
+ json(data: any) {
1172
+ if (res._sent) return res;
1173
+ res._sent = true;
1174
+ res.headersSent = true;
1175
+ res._body = data;
1176
+ res._headers['content-type'] = 'application/json';
1177
+ return res;
1178
+ },
1179
+ send(data: any) {
1180
+ if (res._sent) return res;
1181
+ res._sent = true;
1182
+ res.headersSent = true;
1183
+ res._body = data;
1184
+ return res;
1185
+ },
1186
+ redirect(urlOrStatus: any, url?: string) {
1187
+ res._sent = true;
1188
+ res.headersSent = true;
1189
+ if (typeof urlOrStatus === 'number') {
1190
+ res._statusCode = urlOrStatus;
1191
+ res._redirectUrl = url;
1192
+ } else {
1193
+ res._statusCode = 302;
1194
+ res._redirectUrl = urlOrStatus;
1195
+ }
1196
+ return res;
1197
+ },
1198
+ set(key: string, value: string) {
1199
+ res._headers[key.toLowerCase()] = value;
1200
+ return res;
1201
+ },
1202
+ setHeader(key: string, value: string) {
1203
+ res._headers[key.toLowerCase()] = value;
1204
+ return res;
1205
+ },
1206
+ cookie(_name: string, _value: string, _options?: any) {
1207
+ return res;
1208
+ },
1209
+ end() {
1210
+ if (!res._sent) {
1211
+ res._sent = true;
1212
+ res.headersSent = true;
1213
+ }
1214
+ },
1215
+ type(t: string) {
1216
+ res._headers['content-type'] = t;
1217
+ return res;
1218
+ },
1219
+ };
1220
+
1221
+ Object.defineProperty(res, 'statusCode', {
1222
+ get() {
1223
+ return res._statusCode;
1224
+ },
1225
+ set(v: number) {
1226
+ res._statusCode = v;
1227
+ },
1228
+ });
1229
+
1230
+ return res;
1231
+ }
1232
+
1233
+ function expressResToResponse(res: any): Response {
1234
+ if (res._redirectUrl) {
1235
+ return Response.redirect(res._redirectUrl, res._statusCode || 302);
1236
+ }
1237
+
1238
+ const headers = new Headers(res._headers);
1239
+
1240
+ if (res._body === null || res._body === undefined) {
1241
+ return new Response(null, { status: res._statusCode, headers });
1242
+ }
1243
+
1244
+ if (typeof res._body === 'string') {
1245
+ return new Response(res._body, { status: res._statusCode, headers });
1246
+ }
1247
+
1248
+ if (res._body instanceof ArrayBuffer || res._body instanceof Uint8Array) {
1249
+ return new Response(res._body, { status: res._statusCode, headers });
1250
+ }
1251
+
1252
+ if (!headers.has('content-type')) {
1253
+ headers.set('content-type', 'application/json');
1254
+ }
1255
+ let jsonStr = JSON.stringify(res._body);
1256
+ // Rewrite legacy blocklet server URLs to CF Workers domain
1257
+ // Old format: https://old-domain/payment/methods/x.png -> https://cf-domain/methods/x.png
1258
+ const cfAppUrl = ((globalThis as any).__CF_ENV__?.APP_URL || '').replace(/\/$/, '');
1259
+ if (cfAppUrl) {
1260
+ jsonStr = jsonStr
1261
+ .split('https://bbqa7swuuaze4l2y5salvngyjyohlhq5fs5j42eokni.did.abtnet.io/payment/')
1262
+ .join(`${cfAppUrl}/`);
1263
+ jsonStr = jsonStr.split('https://bbqa7swuuaze4l2y5salvngyjyohlhq5fs5j42eokni.did.abtnet.io').join(cfAppUrl);
1264
+ }
1265
+ return new Response(jsonStr, { status: res._statusCode, headers });
1266
+ }
1267
+
1268
+ async function runExpressHandlers(handlers: Function[], req: any, res: any): Promise<void> {
1269
+ let idx = 0;
1270
+
1271
+ async function runNext(err?: any): Promise<void> {
1272
+ if (err) {
1273
+ console.error('[CF Worker] Express middleware error:', err?.message || err);
1274
+ if (!res._sent) {
1275
+ res.status(500).json({ error: err?.message || 'Internal Server Error' });
1276
+ }
1277
+ return;
1278
+ }
1279
+ if (idx >= handlers.length || res._sent) return;
1280
+
1281
+ const handler = handlers[idx++];
1282
+ if (!handler) return runNext();
1283
+
1284
+ if (handler.length === 4) {
1285
+ return runNext();
1286
+ }
1287
+
1288
+ return new Promise<void>((resolve) => {
1289
+ let nextCalled = false;
1290
+
1291
+ try {
1292
+ const result = handler(req, res, (nextErr?: any) => {
1293
+ nextCalled = true;
1294
+ runNext(nextErr)
1295
+ .then(resolve)
1296
+ .catch((e: any) => {
1297
+ if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
1298
+ resolve();
1299
+ });
1300
+ });
1301
+
1302
+ if (result && typeof result.then === 'function') {
1303
+ result
1304
+ .then(() => {
1305
+ if (!nextCalled) {
1306
+ resolve();
1307
+ }
1308
+ })
1309
+ .catch((e: any) => {
1310
+ console.error('[CF Worker] Async handler error:', e?.message || e);
1311
+ if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
1312
+ resolve();
1313
+ });
1314
+ } else if (!nextCalled) {
1315
+ resolve();
1316
+ }
1317
+ } catch (e: any) {
1318
+ console.error('[CF Worker] Sync handler error:', e?.message || e);
1319
+ if (!res._sent) res.status(500).json({ error: e?.message || 'Internal Server Error' });
1320
+ resolve();
1321
+ }
1322
+ });
1323
+ }
1324
+
1325
+ await runNext();
1326
+ }
1327
+
1328
+ function mountExpressRoutes(honoApp: Hono<HonoEnv>, prefix: string, expressRouter: any) {
1329
+ const routes: RouteEntry[] = expressRouter._routes || [];
1330
+
1331
+ console.log(`[CF Worker] Mounting ${routes.length} Express routes under ${prefix}`);
1332
+
1333
+ for (const route of routes) {
1334
+ const fullPath = normalizeRoutePath(prefix, route.path);
1335
+ const method = route.method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete';
1336
+
1337
+ if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
1338
+ console.warn(`[CF Worker] Skipping unsupported method: ${route.method} ${fullPath}`);
1339
+ continue;
1340
+ }
1341
+
1342
+ honoApp[method](fullPath, async (c) => {
1343
+ const req = createExpressReq(c, c.req.param());
1344
+ const res = createExpressRes();
1345
+
1346
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(c.req.method)) {
1347
+ try {
1348
+ const contentType = c.req.header('content-type') || '';
1349
+ const isStripeWebhook = fullPath.includes('/integrations/stripe/webhook');
1350
+
1351
+ if (isStripeWebhook) {
1352
+ const rawBody = await c.req.arrayBuffer();
1353
+ req.body = Buffer.from(rawBody);
1354
+ req.rawBody = req.body;
1355
+ } else if (contentType.includes('application/json')) {
1356
+ req.body = await c.req.json();
1357
+ } else if (contentType.includes('application/x-www-form-urlencoded')) {
1358
+ const text = await c.req.text();
1359
+ req.body = Object.fromEntries(new URLSearchParams(text));
1360
+ } else if (contentType.includes('text/')) {
1361
+ req.body = await c.req.text();
1362
+ } else {
1363
+ try {
1364
+ req.body = await c.req.json();
1365
+ } catch {
1366
+ try {
1367
+ req.body = await c.req.text();
1368
+ } catch {
1369
+ req.body = null;
1370
+ }
1371
+ }
1372
+ }
1373
+ } catch {
1374
+ req.body = {};
1375
+ }
1376
+ }
1377
+
1378
+ // Debug logging for webhook
1379
+ if (fullPath.includes('stripe/webhook')) {
1380
+ console.log('[CF Worker] Stripe webhook request received:', {
1381
+ method: c.req.method,
1382
+ path: fullPath,
1383
+ hasSignature: !!req.headers['stripe-signature'],
1384
+ bodyType: typeof req.body,
1385
+ bodyLength: req.body?.length || 0,
1386
+ isBuffer: Buffer.isBuffer(req.body),
1387
+ handlersCount: route.handlers.length,
1388
+ });
1389
+ }
1390
+
1391
+ // Inject caller identity resolved by AUTH_SERVICE RPC (or mock fallback)
1392
+ const caller: CallerIdentityDTO | null = c.get('caller');
1393
+ if (caller) {
1394
+ req.user = {
1395
+ did: caller.did,
1396
+ role: caller.role || 'guest',
1397
+ provider: caller.authMethod === 'access-key' ? 'access-key' : 'wallet',
1398
+ fullName: caller.displayName || '',
1399
+ walletOS: '',
1400
+ via: 'dashboard',
1401
+ };
1402
+ req.headers['x-user-did'] = caller.did;
1403
+ req.headers['x-user-role'] = `blocklet-${caller.role || 'guest'}`;
1404
+ req.headers['x-user-provider'] = caller.authMethod || 'wallet';
1405
+ req.headers['x-user-fullname'] = encodeURIComponent(caller.displayName || '');
1406
+ req.headers['x-user-wallet-os'] = '';
1407
+ } else {
1408
+ req.user = { did: '', role: 'guest', provider: '', fullName: '', walletOS: '', via: '' };
1409
+ }
1410
+
1411
+ try {
1412
+ await runExpressHandlers(route.handlers, req, res);
1413
+ } catch (e: any) {
1414
+ console.error(
1415
+ `[CF Worker] Unhandled error in ${route.method} ${fullPath}:`,
1416
+ e?.message || e,
1417
+ '\n',
1418
+ e?.stack?.split('\n').slice(0, 8).join('\n')
1419
+ );
1420
+ if (!res._sent) {
1421
+ res.status(500).json({ error: e?.message || 'Internal Server Error' });
1422
+ }
1423
+ }
1424
+
1425
+ // Debug logging for webhook response
1426
+ if (fullPath.includes('stripe/webhook')) {
1427
+ console.log('[CF Worker] Stripe webhook handler result:', {
1428
+ sent: res._sent,
1429
+ statusCode: res._statusCode,
1430
+ body:
1431
+ typeof res._body === 'string' ? res._body.substring(0, 200) : JSON.stringify(res._body)?.substring(0, 200),
1432
+ });
1433
+ }
1434
+
1435
+ // Ensure all async push() jobs complete before returning
1436
+ await flushPendingJobs();
1437
+
1438
+ if (!res._sent) {
1439
+ console.warn(`[CF Worker] No response sent for ${route.method} ${fullPath}`);
1440
+ return c.json({ error: 'No response from handler' }, 500);
1441
+ }
1442
+
1443
+ return expressResToResponse(res);
1444
+ });
1445
+ }
1446
+ }
1447
+
1448
+ // === Shared env setup for scheduled/queue handlers ===
1449
+ function setupEnv(env: Env) {
1450
+ if (typeof (globalThis as any).__flushDeferredTimers === 'function') {
1451
+ (globalThis as any).__flushDeferredTimers();
1452
+ }
1453
+ (globalThis as any).__CF_ENV__ = env;
1454
+ // Queue consumer and cron: NOT HTTP context — createEvent must block (listeners complete before ack/return)
1455
+ (globalThis as any).__cfHttpContext__ = false;
1456
+ setDB(env.DB.withSession('first-primary'));
1457
+ if (env.JOB_QUEUE) setCFQueue(env.JOB_QUEUE);
1458
+ ensureModelsInit();
1459
+
1460
+ if (typeof process !== 'undefined' && process.env) {
1461
+ if (env.APP_URL) {
1462
+ process.env.APP_URL = env.APP_URL;
1463
+ process.env.BLOCKLET_APP_URL = env.APP_URL;
1464
+ }
1465
+ if (env.APP_PID) {
1466
+ process.env.BLOCKLET_APP_PID = env.APP_PID;
1467
+ process.env.BLOCKLET_APP_ID = env.APP_PID;
1468
+ }
1469
+ if (env.APP_NAME) process.env.BLOCKLET_APP_NAME = env.APP_NAME;
1470
+ process.env.BLOCKLET_MODE = 'production';
1471
+ }
1472
+
1473
+ // Security init is handled in the per-request middleware (first request only)
1474
+ }
1475
+
1476
+ // === Export ===
1477
+ export default {
1478
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
1479
+ setWaitUntil((p) => ctx.waitUntil(p));
1480
+ // Expose waitUntil globally for createEvent to use in HTTP context
1481
+ (globalThis as any).__cfWaitUntil__ = (p: Promise<any>) => ctx.waitUntil(p);
1482
+
1483
+ const app = buildApp(env);
1484
+ return app.fetch(request, env, ctx);
1485
+ },
1486
+
1487
+ async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
1488
+ setupEnv(env);
1489
+ ensureCronsInit();
1490
+ setWaitUntil((p) => ctx.waitUntil(p));
1491
+
1492
+ console.log('Scheduled event:', event.cron, 'scheduledTime:', event.scheduledTime);
1493
+
1494
+ // Refund recovery (startRefundQueue) is registered as a proper cron job in
1495
+ // api/src/crons/index.ts (refund.recovery, */5 min), so it is invoked via
1496
+ // cronInstance.runAll() below rather than on every scheduled tick.
1497
+ //
1498
+ // Pass the event's scheduledTime (the INTENDED minute) instead of letting
1499
+ // runAll() use new Date() internally. If CF delivers this event late and
1500
+ // execution crosses a minute boundary, matching on wall-clock would miss
1501
+ // exact-minute crons like "0 1 * * * *".
1502
+ await cronInstance.runAll(new Date(event.scheduledTime));
1503
+ await runAllScheduledJobs();
1504
+ await flushPendingJobs();
1505
+ },
1506
+
1507
+ // CF Queue consumer — processes jobs sent by push() in the queue shim
1508
+ async queue(
1509
+ batch: MessageBatch<{ queueName: string; jobId: string; job: any; persist?: boolean }>,
1510
+ env: Env,
1511
+ ctx: ExecutionContext,
1512
+ ) {
1513
+ setupEnv(env);
1514
+ setWaitUntil((p) => ctx.waitUntil(p));
1515
+
1516
+ console.log(`[queue:consumer] Received batch of ${batch.messages.length} messages`);
1517
+
1518
+ for (const msg of batch.messages) {
1519
+ const { queueName, jobId, job, persist } = msg.body;
1520
+
1521
+ const handler = getHandler(queueName);
1522
+
1523
+ if (!handler) {
1524
+ console.error(`[queue:consumer] No handler registered for queue "${queueName}", acking message`);
1525
+ msg.ack();
1526
+ continue;
1527
+ }
1528
+
1529
+ // persist defaults to true for backward compatibility and for direct
1530
+ // push() immediate jobs (where addJob wrote the row, so we must delete
1531
+ // it after onJob succeeds).
1532
+ //
1533
+ // Scheduled dispatches from runAllScheduledJobs set persist=false —
1534
+ // the dispatcher already deleted the D1 row before sending, and
1535
+ // onJob may have re-pushed a fresh row with the same id; deleting
1536
+ // again would wipe out that new row.
1537
+ const shouldPersist = persist !== false;
1538
+
1539
+ try {
1540
+ console.log(`[queue:consumer] Processing ${queueName}:${jobId} (persist=${shouldPersist})`);
1541
+ await handler.executeJob(jobId, job, shouldPersist);
1542
+ console.log(`[queue:consumer] Completed ${queueName}:${jobId}`);
1543
+ msg.ack();
1544
+ } catch (err: any) {
1545
+ console.error(`[queue:consumer] Failed ${queueName}:${jobId}:`, err?.message || err);
1546
+ // Don't retry via CF Queue — job is in D1, cron will re-dispatch
1547
+ msg.ack();
1548
+ }
1549
+ }
1550
+
1551
+ await flushPendingJobs();
1552
+ },
1553
+ };