payment-kit 1.29.0 → 1.29.2

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 (312) hide show
  1. package/api/dev.ts +41 -2
  2. package/api/hono.d.ts +42 -0
  3. package/api/node-sqlite.d.ts +12 -0
  4. package/api/src/bootstrap.ts +36 -0
  5. package/api/src/crons/base.ts +3 -3
  6. package/api/src/crons/currency.ts +1 -1
  7. package/api/src/crons/index.ts +27 -24
  8. package/api/src/crons/metering-subscription-detection.ts +1 -1
  9. package/api/src/crons/overdue-detection.ts +2 -2
  10. package/api/src/crons/retry-pending-events.ts +6 -0
  11. package/api/src/index.ts +22 -161
  12. package/api/src/integrations/app-store/client.ts +3 -4
  13. package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
  14. package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
  15. package/api/src/integrations/arcblock/token.ts +21 -7
  16. package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
  17. package/api/src/integrations/google-play/handlers/voided.ts +2 -2
  18. package/api/src/integrations/google-play/verify.ts +3 -2
  19. package/api/src/integrations/iap-reconcile.ts +3 -5
  20. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  21. package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
  22. package/api/src/libs/archive/query.ts +19 -0
  23. package/api/src/libs/audit.ts +61 -4
  24. package/api/src/libs/auth.ts +99 -38
  25. package/api/src/libs/context.ts +78 -1
  26. package/api/src/libs/currency.ts +2 -2
  27. package/api/src/libs/dayjs.ts +8 -2
  28. package/api/src/libs/drivers/auth-storage.ts +118 -0
  29. package/api/src/libs/drivers/cron.ts +264 -0
  30. package/api/src/libs/drivers/db.ts +170 -0
  31. package/api/src/libs/drivers/identity.ts +81 -0
  32. package/api/src/libs/drivers/index.ts +40 -0
  33. package/api/src/libs/drivers/locks.ts +226 -0
  34. package/api/src/libs/drivers/migrate-runner.ts +70 -0
  35. package/api/src/libs/drivers/queue.ts +104 -0
  36. package/api/src/libs/drivers/secrets.ts +194 -0
  37. package/api/src/libs/env.ts +170 -54
  38. package/api/src/libs/exchange-rate/service.ts +7 -6
  39. package/api/src/libs/http-fetch-adapter.ts +50 -0
  40. package/api/src/libs/invoice.ts +1 -1
  41. package/api/src/libs/lock.ts +51 -47
  42. package/api/src/libs/logger.ts +48 -8
  43. package/api/src/libs/notification/index.ts +1 -1
  44. package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
  45. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
  46. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
  47. package/api/src/libs/overdraft-protection.ts +1 -1
  48. package/api/src/libs/payout.ts +1 -1
  49. package/api/src/libs/queue/index.ts +259 -52
  50. package/api/src/libs/queue/runtime.ts +175 -0
  51. package/api/src/libs/resource.ts +3 -3
  52. package/api/src/libs/secrets.ts +38 -0
  53. package/api/src/libs/session.ts +3 -2
  54. package/api/src/libs/subscription.ts +5 -5
  55. package/api/src/libs/tenant.ts +92 -0
  56. package/api/src/libs/url.ts +3 -3
  57. package/api/src/libs/util.ts +21 -13
  58. package/api/src/middlewares/hono/cdn.ts +63 -0
  59. package/api/src/middlewares/hono/context.ts +73 -0
  60. package/api/src/middlewares/hono/csrf.ts +72 -0
  61. package/api/src/middlewares/hono/fallback.ts +194 -0
  62. package/api/src/middlewares/hono/pipeline.ts +73 -0
  63. package/api/src/middlewares/hono/resource-mount.ts +42 -0
  64. package/api/src/middlewares/hono/resource.ts +63 -0
  65. package/api/src/middlewares/hono/security.ts +214 -0
  66. package/api/src/middlewares/hono/session.ts +114 -0
  67. package/api/src/middlewares/hono/xss.ts +61 -0
  68. package/api/src/queues/auto-recharge.ts +12 -10
  69. package/api/src/queues/checkout-session.ts +17 -12
  70. package/api/src/queues/credit-consume.ts +40 -36
  71. package/api/src/queues/credit-grant.ts +25 -18
  72. package/api/src/queues/credit-reconciliation.ts +7 -5
  73. package/api/src/queues/discount-status.ts +9 -6
  74. package/api/src/queues/event.ts +12 -4
  75. package/api/src/queues/exchange-rate-health.ts +49 -30
  76. package/api/src/queues/invoice.ts +18 -15
  77. package/api/src/queues/notification.ts +14 -7
  78. package/api/src/queues/payment.ts +41 -28
  79. package/api/src/queues/payout.ts +9 -5
  80. package/api/src/queues/refund.ts +18 -12
  81. package/api/src/queues/subscription.ts +83 -53
  82. package/api/src/queues/token-transfer.ts +15 -10
  83. package/api/src/queues/usage-record.ts +8 -5
  84. package/api/src/queues/vendors/commission.ts +7 -5
  85. package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
  86. package/api/src/queues/vendors/fulfillment.ts +4 -2
  87. package/api/src/queues/vendors/return-processor.ts +5 -3
  88. package/api/src/queues/vendors/return-scanner.ts +5 -4
  89. package/api/src/queues/vendors/status-check.ts +10 -7
  90. package/api/src/queues/webhook.ts +60 -32
  91. package/api/src/routes/connect/shared.ts +1 -2
  92. package/api/src/routes/connect/subscribe.ts +3 -3
  93. package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
  94. package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
  95. package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
  96. package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
  97. package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
  98. package/api/src/routes/hono/credit-tokens.ts +43 -0
  99. package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
  100. package/api/src/routes/{customers.ts → hono/customers.ts} +193 -223
  101. package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
  102. package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
  103. package/api/src/routes/{events.ts → hono/events.ts} +107 -71
  104. package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
  105. package/api/src/routes/hono/exchange-rates.ts +77 -0
  106. package/api/src/routes/hono/index.ts +115 -0
  107. package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
  108. package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
  109. package/api/src/routes/hono/integrations/stripe.ts +74 -0
  110. package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
  111. package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
  112. package/api/src/routes/hono/meters.ts +288 -0
  113. package/api/src/routes/hono/passports.ts +73 -0
  114. package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
  115. package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
  116. package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
  117. package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
  118. package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
  119. package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
  120. package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
  121. package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
  122. package/api/src/routes/{products.ts → hono/products.ts} +172 -159
  123. package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
  124. package/api/src/routes/hono/redirect.ts +24 -0
  125. package/api/src/routes/{refunds.ts → hono/refunds.ts} +96 -80
  126. package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
  127. package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
  128. package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
  129. package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
  130. package/api/src/routes/hono/tool.ts +69 -0
  131. package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
  132. package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
  133. package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
  134. package/api/src/routes/hono/webhook-endpoints.ts +126 -0
  135. package/api/src/service.ts +667 -0
  136. package/api/src/store/migrations/20230911-seeding.ts +2 -1
  137. package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
  138. package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
  139. package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
  140. package/api/src/store/models/auto-recharge-config.ts +22 -10
  141. package/api/src/store/models/checkout-session.ts +15 -14
  142. package/api/src/store/models/coupon.ts +29 -20
  143. package/api/src/store/models/credit-grant.ts +38 -29
  144. package/api/src/store/models/credit-transaction.ts +32 -21
  145. package/api/src/store/models/customer.ts +19 -17
  146. package/api/src/store/models/discount.ts +11 -2
  147. package/api/src/store/models/entitlement-grant.ts +21 -9
  148. package/api/src/store/models/entitlement-product.ts +21 -9
  149. package/api/src/store/models/entitlement.ts +19 -10
  150. package/api/src/store/models/event.ts +18 -9
  151. package/api/src/store/models/exchange-rate-provider.ts +17 -4
  152. package/api/src/store/models/invoice-item.ts +18 -9
  153. package/api/src/store/models/invoice.ts +16 -8
  154. package/api/src/store/models/meter-event.ts +27 -9
  155. package/api/src/store/models/meter.ts +31 -22
  156. package/api/src/store/models/payment-currency.ts +25 -8
  157. package/api/src/store/models/payment-intent.ts +15 -6
  158. package/api/src/store/models/payment-link.ts +15 -6
  159. package/api/src/store/models/payment-method.ts +38 -22
  160. package/api/src/store/models/payment-stat.ts +18 -9
  161. package/api/src/store/models/payout.ts +15 -6
  162. package/api/src/store/models/price-quote.ts +17 -8
  163. package/api/src/store/models/price.ts +24 -12
  164. package/api/src/store/models/pricing-table.ts +29 -20
  165. package/api/src/store/models/product-vendor.ts +20 -10
  166. package/api/src/store/models/product.ts +15 -6
  167. package/api/src/store/models/promotion-code.ts +14 -6
  168. package/api/src/store/models/refund.ts +15 -6
  169. package/api/src/store/models/revenue-snapshot.ts +21 -9
  170. package/api/src/store/models/setting.ts +18 -9
  171. package/api/src/store/models/setup-intent.ts +36 -27
  172. package/api/src/store/models/subscription-item.ts +21 -9
  173. package/api/src/store/models/subscription-schedule.ts +21 -9
  174. package/api/src/store/models/subscription.ts +21 -10
  175. package/api/src/store/models/tax-rate.ts +29 -21
  176. package/api/src/store/models/usage-record.ts +11 -2
  177. package/api/src/store/models/webhook-attempt.ts +18 -9
  178. package/api/src/store/models/webhook-endpoint.ts +18 -9
  179. package/api/src/store/scoped-core.ts +55 -0
  180. package/api/src/store/scoped.ts +247 -0
  181. package/api/src/store/sequelize.ts +66 -22
  182. package/api/src/store/sql-migrations.ts +20 -0
  183. package/api/src/store/tenant-backfill.ts +260 -0
  184. package/api/src/store/tenant-model.ts +124 -0
  185. package/api/src/store/tenant-tables.ts +50 -0
  186. package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
  187. package/api/tests/fixtures/bare-query-violation.ts +13 -0
  188. package/api/tests/fixtures/core-env-violation.ts +10 -0
  189. package/api/tests/fixtures/host-read-violation.ts +19 -0
  190. package/api/tests/fixtures/tenants.ts +4 -0
  191. package/api/tests/integrations/iap-tenant.spec.ts +284 -0
  192. package/api/tests/libs/archive-query.spec.ts +26 -0
  193. package/api/tests/libs/audit-tenant.spec.ts +153 -0
  194. package/api/tests/libs/context.spec.ts +204 -0
  195. package/api/tests/libs/core-config.spec.ts +115 -0
  196. package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
  197. package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
  198. package/api/tests/libs/lock-tenant.spec.ts +66 -0
  199. package/api/tests/libs/scoped.spec.ts +222 -0
  200. package/api/tests/libs/secrets-facade.spec.ts +52 -0
  201. package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
  202. package/api/tests/libs/tenant-middleware.spec.ts +42 -0
  203. package/api/tests/libs/tenant-scanner.spec.ts +120 -0
  204. package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
  205. package/api/tests/middlewares/hono/context.spec.ts +113 -0
  206. package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
  207. package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
  208. package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
  209. package/api/tests/middlewares/hono/security.spec.ts +181 -0
  210. package/api/tests/middlewares/hono/session.spec.ts +42 -0
  211. package/api/tests/middlewares/hono/xss.spec.ts +81 -0
  212. package/api/tests/models/tenant-backfill.spec.ts +287 -0
  213. package/api/tests/models/tenant-columns-model.spec.ts +46 -0
  214. package/api/tests/models/tenant-columns.spec.ts +161 -0
  215. package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
  216. package/api/tests/queues/credit-consume.spec.ts +8 -1
  217. package/api/tests/queues/event-tenant.spec.ts +236 -0
  218. package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
  219. package/api/tests/queues/queue-parity.spec.ts +249 -0
  220. package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
  221. package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
  222. package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
  223. package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
  224. package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
  225. package/api/tests/service/collapse.spec.ts +96 -0
  226. package/api/tests/store/tenant-crosscut.spec.ts +202 -0
  227. package/api/tests/store/tenant-model-spike.spec.ts +177 -0
  228. package/api/tests/store/tenant-model.spec.ts +162 -0
  229. package/api/tests/store/tenant-residual.spec.ts +196 -0
  230. package/api/third.d.ts +4 -0
  231. package/blocklet.yml +1 -1
  232. package/cloudflare/README.md +26 -6
  233. package/cloudflare/build.ts +28 -13
  234. package/cloudflare/did-connect-auth.ts +0 -217
  235. package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
  236. package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
  237. package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
  238. package/cloudflare/migrations/0008_schema_parity.sql +16 -0
  239. package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
  240. package/cloudflare/queue-runtime-mode.ts +13 -0
  241. package/cloudflare/run-build.js +31 -56
  242. package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
  243. package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
  244. package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
  245. package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
  246. package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
  247. package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
  248. package/cloudflare/shims/blocklet-sdk/util-csrf.ts +13 -0
  249. package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
  250. package/cloudflare/shims/cron.ts +38 -158
  251. package/cloudflare/shims/events.ts +124 -0
  252. package/cloudflare/shims/fastq.ts +15 -1
  253. package/cloudflare/shims/nedb-storage.ts +16 -8
  254. package/cloudflare/shims/node-fetch.ts +35 -0
  255. package/cloudflare/shims/xss.ts +8 -0
  256. package/cloudflare/tenant-middleware.ts +36 -0
  257. package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
  258. package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
  259. package/cloudflare/worker.ts +204 -433
  260. package/cloudflare/wrangler.local-e2e.jsonc +26 -0
  261. package/jest.config.js +3 -1
  262. package/package.json +33 -38
  263. package/scripts/core-env-whitelist.json +1 -0
  264. package/scripts/e2e-12b-runtime.ts +149 -0
  265. package/scripts/e2e-core-config.ts +125 -0
  266. package/scripts/e2e-d1-tenancy.ts +116 -0
  267. package/scripts/e2e-d2-cron-queue.ts +139 -0
  268. package/scripts/e2e-d3-embedded-multi.ts +171 -0
  269. package/scripts/e2e-hono-s2.ts +125 -0
  270. package/scripts/e2e-hono-s3e.ts +135 -0
  271. package/scripts/e2e-hono-s4.ts +114 -0
  272. package/scripts/e2e-migration-contract.ts +100 -0
  273. package/scripts/e2e-s0.ts +61 -0
  274. package/scripts/e2e-s1.ts +107 -0
  275. package/scripts/e2e-s2.ts +178 -0
  276. package/scripts/e2e-s3.ts +110 -0
  277. package/scripts/e2e-s4.ts +191 -0
  278. package/scripts/e2e-s5.ts +139 -0
  279. package/scripts/e2e-s6.ts +127 -0
  280. package/scripts/e2e-tenant-model.ts +119 -0
  281. package/scripts/e2e-tenant-worker.ts +199 -0
  282. package/scripts/gen-sql-migrations.js +46 -0
  283. package/scripts/phase8-codemod.js +219 -0
  284. package/scripts/phase9a-env-getters-codemod.js +82 -0
  285. package/scripts/scan-core-env.js +109 -0
  286. package/scripts/scan-tenant-queries.js +235 -0
  287. package/scripts/schema-drift-guard.ts +210 -0
  288. package/scripts/tenant-scan-whitelist.json +1 -0
  289. package/src/env.d.ts +13 -1
  290. package/tsconfig.json +1 -1
  291. package/api/src/libs/did-space.ts +0 -235
  292. package/api/src/libs/middleware.ts +0 -50
  293. package/api/src/libs/security.ts +0 -192
  294. package/api/src/queues/space.ts +0 -662
  295. package/api/src/routes/credit-tokens.ts +0 -38
  296. package/api/src/routes/exchange-rates.ts +0 -87
  297. package/api/src/routes/index.ts +0 -142
  298. package/api/src/routes/integrations/stripe.ts +0 -61
  299. package/api/src/routes/meters.ts +0 -274
  300. package/api/src/routes/passports.ts +0 -68
  301. package/api/src/routes/redirect.ts +0 -20
  302. package/api/src/routes/tool.ts +0 -65
  303. package/api/src/routes/webhook-endpoints.ts +0 -126
  304. package/api/tests/routes/credit-grants.spec.ts +0 -1261
  305. package/cloudflare/shims/did-space-js.ts +0 -17
  306. package/cloudflare/shims/did-space.ts +0 -11
  307. package/cloudflare/shims/express-compat/index.ts +0 -80
  308. package/cloudflare/shims/express-compat/types.ts +0 -41
  309. package/cloudflare/shims/lock.ts +0 -115
  310. package/cloudflare/shims/queue.ts +0 -611
  311. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
  312. package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
@@ -0,0 +1,236 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Sequelize } from 'sequelize';
5
+ import { SequelizeStorage, Umzug } from 'umzug';
6
+
7
+ import { withTenant } from '../../src/libs/context';
8
+ import { TENANT_CONTEXT_MISSING, TENANT_MISMATCH } from '../../src/libs/tenant';
9
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
10
+
11
+ jest.mock('../../src/libs/logger', () => ({
12
+ __esModule: true,
13
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
14
+ }));
15
+
16
+ // neutralize the queue engine: real createQueue scans the jobs table on
17
+ // import (crash-recovery), which races against this suite's temp DB lifecycle
18
+ jest.mock('../../src/libs/queue', () => ({
19
+ __esModule: true,
20
+ default: () => ({
21
+ push: jest.fn(),
22
+ pushAndWait: jest.fn(),
23
+ cancel: jest.fn(),
24
+ on: jest.fn(),
25
+ get: jest.fn().mockResolvedValue(null),
26
+ }),
27
+ }));
28
+
29
+ // the five delivery paths are exercised against mocks for transport pieces
30
+ const addWebhookJob = jest.fn().mockResolvedValue(true);
31
+ jest.mock('../../src/queues/webhook', () => {
32
+ const actual = jest.requireActual('../../src/queues/webhook');
33
+ return { ...actual, addWebhookJob: (...args: any[]) => addWebhookJob(...args) };
34
+ });
35
+
36
+ const componentRequest = jest.fn().mockResolvedValue({ status: 200, data: { ok: true } });
37
+ jest.mock('@blocklet/sdk/lib/util/component-api', () => ({
38
+ __esModule: true,
39
+ default: { request: (...args: any[]) => componentRequest(...args) },
40
+ }));
41
+
42
+ const STORE_DIR = path.join(__dirname, '../../src/store');
43
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'event-tenant-'));
44
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
45
+ const umzug = new Umzug({
46
+ migrations: {
47
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
48
+ resolve: ({ name, path: p, context }) => {
49
+ // eslint-disable-next-line import/no-dynamic-require, global-require
50
+ const migration = require(p!);
51
+ return {
52
+ name: name.replace(/\.ts$/, '.js'),
53
+ up: () => migration.up({ context }),
54
+ down: () => migration.down({ context }),
55
+ };
56
+ },
57
+ },
58
+ context: sequelize.getQueryInterface(),
59
+ storage: new SequelizeStorage({ sequelize }),
60
+ logger: undefined,
61
+ });
62
+
63
+ let models: any;
64
+ let handleEvent: any;
65
+ let handleWebhook: any;
66
+ let assertEventTenantAccessible: any;
67
+ let logger: any;
68
+
69
+ beforeAll(async () => {
70
+ await umzug.up();
71
+ // eslint-disable-next-line global-require
72
+ models = require('../../src/store/models');
73
+ models.initialize(sequelize);
74
+ // eslint-disable-next-line global-require
75
+ ({ handleEvent } = require('../../src/queues/event'));
76
+ // eslint-disable-next-line global-require
77
+ ({ handleWebhook } = jest.requireActual('../../src/queues/webhook'));
78
+ // eslint-disable-next-line global-require
79
+ ({ assertEventTenantAccessible } = require('../../src/routes/hono/events'));
80
+ // eslint-disable-next-line global-require
81
+ logger = require('../../src/libs/logger').default;
82
+ }, 120000);
83
+
84
+ afterAll(async () => {
85
+ await sequelize.close();
86
+ fs.rmSync(dir, { recursive: true, force: true });
87
+ });
88
+
89
+ const seedEvent = (tenant: string, type = 'customer.updated') =>
90
+ withTenant(tenant, () =>
91
+ models.Event.create({
92
+ type,
93
+ instance_did: tenant,
94
+ api_version: 'test',
95
+ livemode: false,
96
+ object_id: 'obj_1',
97
+ object_type: 'customer',
98
+ data: { object: { id: 'obj_1' } },
99
+ request: { id: '', idempotency_key: '', requested_by: 'test' },
100
+ metadata: {},
101
+ pending_webhooks: 99,
102
+ })
103
+ );
104
+
105
+ const seedEndpoint = (tenant: string, url: string) =>
106
+ withTenant(tenant, () =>
107
+ models.WebhookEndpoint.create({
108
+ instance_did: tenant,
109
+ livemode: false,
110
+ url,
111
+ description: 'test',
112
+ status: 'enabled',
113
+ enabled_events: ['customer.updated'],
114
+ secret: 'whsec_test',
115
+ api_version: 'test',
116
+ })
117
+ );
118
+
119
+ beforeEach(async () => {
120
+ jest.clearAllMocks();
121
+ await sequelize.query('DELETE FROM events');
122
+ await sequelize.query('DELETE FROM webhook_endpoints');
123
+ await sequelize.query('DELETE FROM webhook_attempts');
124
+ });
125
+
126
+ describe('event delivery tenant isolation (phase 4)', () => {
127
+ describe('path 1 fanout (queues/event.ts): only same-tenant endpoints scheduled', () => {
128
+ it('A event fans out to A endpoint only, B endpoint untouched', async () => {
129
+ const event = await seedEvent(TENANT_A);
130
+ const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
131
+ await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
132
+
133
+ await handleEvent({ eventId: event.id });
134
+
135
+ expect(addWebhookJob).toHaveBeenCalledTimes(1);
136
+ expect(addWebhookJob).toHaveBeenCalledWith(event.id, endpointA.id, expect.anything());
137
+ });
138
+ });
139
+
140
+ describe('path 2 delivery handler (queues/webhook.ts): tenant invariant', () => {
141
+ it('forged job pairing A event with B endpoint is refused without an attempt row', async () => {
142
+ const event = await seedEvent(TENANT_A);
143
+ const endpointB = await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
144
+
145
+ await handleWebhook({ eventId: event.id, webhookId: endpointB.id });
146
+
147
+ expect(componentRequest).not.toHaveBeenCalled();
148
+ const [attempts] = await sequelize.query('SELECT COUNT(*) AS n FROM webhook_attempts');
149
+ expect((attempts as any[])[0].n).toBe(0);
150
+ expect(logger.error).toHaveBeenCalledWith(
151
+ expect.stringContaining('tenant mismatch'),
152
+ expect.objectContaining({ code: TENANT_MISMATCH })
153
+ );
154
+ });
155
+
156
+ it('same-tenant delivery succeeds and the attempt row carries the tenant', async () => {
157
+ const event = await seedEvent(TENANT_A);
158
+ const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
159
+
160
+ await handleWebhook({ eventId: event.id, webhookId: endpointA.id });
161
+
162
+ expect(componentRequest).toHaveBeenCalledTimes(1);
163
+ const [attempts] = await sequelize.query('SELECT status, instance_did FROM webhook_attempts');
164
+ expect(attempts).toEqual([{ status: 'succeeded', instance_did: TENANT_A }]);
165
+ });
166
+ });
167
+
168
+ describe('paths 3+4 manual retry routes: caller tenant guard', () => {
169
+ it('A caller cannot retry a B event (TENANT_MISMATCH -> 4xx mapping)', async () => {
170
+ await withTenant(TENANT_A, async () => {
171
+ expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
172
+ expect.objectContaining({ code: TENANT_MISMATCH })
173
+ );
174
+ });
175
+ });
176
+
177
+ it('multi mode without caller context fails closed', () => {
178
+ process.env.PAYMENT_TENANT_MODE = 'multi';
179
+ try {
180
+ expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
181
+ expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
182
+ );
183
+ } finally {
184
+ delete process.env.PAYMENT_TENANT_MODE;
185
+ }
186
+ });
187
+
188
+ it('same-tenant caller passes the guard', async () => {
189
+ await withTenant(TENANT_B, async () => {
190
+ expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).not.toThrow();
191
+ });
192
+ });
193
+
194
+ it('retry fanout never schedules the other tenant endpoint (paths 3+4 data leak)', async () => {
195
+ // same shape the retry routes use after their tenant guard: endpoint
196
+ // query scoped by caller tenant -> B endpoint invisible to A
197
+ await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
198
+ await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
199
+ const visible = await withTenant(TENANT_A, async () =>
200
+ models.WebhookEndpoint.findAll({
201
+ where: { status: 'enabled', livemode: false, instance_did: TENANT_A },
202
+ })
203
+ );
204
+ expect(visible).toHaveLength(1);
205
+ expect(visible[0].url).toBe('http://a.example.com/hook');
206
+ });
207
+ });
208
+
209
+ describe('path 5 pending scan (crons/retry-pending-events.ts)', () => {
210
+ it('re-enqueued events still fan out tenant-filtered (transitively via handleEvent)', async () => {
211
+ // the cron only re-enqueues IDs; prove the downstream filter holds for a
212
+ // B event when both tenants have endpoints
213
+ const event = await seedEvent(TENANT_B);
214
+ await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
215
+ const endpointB = await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
216
+
217
+ await handleEvent({ eventId: event.id });
218
+
219
+ expect(addWebhookJob).toHaveBeenCalledTimes(1);
220
+ expect(addWebhookJob).toHaveBeenCalledWith(event.id, endpointB.id, expect.anything());
221
+ });
222
+ });
223
+
224
+ describe('data damage: retry keeps the original tenant', () => {
225
+ it('failed delivery writes a failed attempt under the event tenant', async () => {
226
+ componentRequest.mockRejectedValueOnce(Object.assign(new Error('boom'), { response: { status: 500 } }));
227
+ const event = await seedEvent(TENANT_A);
228
+ const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
229
+
230
+ await handleWebhook({ eventId: event.id, webhookId: endpointA.id });
231
+
232
+ const [attempts] = await sequelize.query('SELECT status, instance_did FROM webhook_attempts');
233
+ expect(attempts).toEqual([{ status: 'failed', instance_did: TENANT_A }]);
234
+ });
235
+ });
236
+ });
@@ -0,0 +1,62 @@
1
+ // D6 — the exchange-rate-health schedule carries its tenant in the job PAYLOAD,
2
+ // so the re-schedule (on 'finished', which fires OUTSIDE any withTenant scope)
3
+ // stays under the correct tenant without relying on the ALS context. In multi
4
+ // mode a tenant-less push would throw TENANT_CONTEXT_MISSING.
5
+
6
+ jest.mock('../../src/libs/logger', () => ({
7
+ __esModule: true,
8
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
9
+ }));
10
+
11
+ const pushed: any[] = [];
12
+ let finishedListener: ((data: any) => void) | undefined;
13
+
14
+ jest.mock('../../src/libs/queue', () => ({
15
+ __esModule: true,
16
+ default: () => ({
17
+ push: (p: any) => {
18
+ pushed.push(p);
19
+ return { on: jest.fn() };
20
+ },
21
+ on: (ev: string, cb: any) => {
22
+ if (ev === 'finished') finishedListener = cb;
23
+ },
24
+ pushAndWait: jest.fn(),
25
+ cancel: jest.fn(),
26
+ get: jest.fn(),
27
+ store: { addJob: jest.fn(), getScheduledJobs: jest.fn().mockResolvedValue([]) },
28
+ stop: jest.fn(),
29
+ }),
30
+ }));
31
+
32
+ import { scheduleHealthChecks } from '../../src/queues/exchange-rate-health';
33
+
34
+ describe('D6 — exchange-rate-health carries the tenant in the job payload', () => {
35
+ beforeEach(() => {
36
+ pushed.length = 0;
37
+ });
38
+
39
+ it('the initial schedule pushes a job tagged with instance_did', () => {
40
+ scheduleHealthChecks('did:abt:zHEALTHA');
41
+ expect(pushed.length).toBe(1);
42
+ expect(pushed[0].job.instance_did).toBe('did:abt:zHEALTHA');
43
+ expect(pushed[0].persist).toBe(true);
44
+ });
45
+
46
+ it('the re-schedule preserves the FINISHED job’s tenant (no ALS reliance)', () => {
47
+ scheduleHealthChecks('did:abt:zHEALTHA');
48
+ expect(finishedListener).toBeDefined();
49
+ pushed.length = 0;
50
+ // simulate a finished job that belonged to tenant B — the next schedule must
51
+ // be for B, taken from the job payload, NOT from any ambient context.
52
+ finishedListener!({ job: { type: 'health_check', timestamp: 1, instance_did: 'did:abt:zHEALTHB' } });
53
+ expect(pushed.length).toBe(1);
54
+ expect(pushed[0].job.instance_did).toBe('did:abt:zHEALTHB');
55
+ });
56
+
57
+ it('single mode (no instanceDid) pushes without a forced tenant (default-tenant fallback applies)', () => {
58
+ scheduleHealthChecks(undefined);
59
+ expect(pushed.length).toBe(1);
60
+ expect(pushed[0].job.instance_did).toBeUndefined(); // injectJobTenant uses the default tenant in single
61
+ });
62
+ });
@@ -0,0 +1,249 @@
1
+ // Phase 9 (W2-1b): queue engine + executor parity (W2 判据 4).
2
+ //
3
+ // The CF worker runs the SAME queue engine as Blocklet Server
4
+ // (api/src/libs/queue) — only the fastq EXECUTOR primitive is swapped (real
5
+ // fastq on Node, cloudflare/shims/fastq in the worker). This spec proves:
6
+ // Part A — the engine's delay/retry/cancel/pushAndWait semantics (embedded).
7
+ // Part B — the fastq shim is a faithful drop-in for real fastq on the exact
8
+ // operations the engine uses, so the worker behaves identically.
9
+ // Together that is the embedded↔worker queue parity the acceptance gate wants.
10
+
11
+ import fs from 'fs';
12
+ import os from 'os';
13
+ import path from 'path';
14
+ import { Sequelize } from 'sequelize';
15
+ import { SequelizeStorage, Umzug } from 'umzug';
16
+
17
+ import { withTenant, getInstanceDid } from '../../src/libs/context';
18
+
19
+ /* eslint-disable global-require, import/no-dynamic-require, require-await, no-promise-executor-return */
20
+
21
+ // the retry/settle cases do real per-attempt DB ops on a shared sqlite file;
22
+ // under full-suite parallel load they can exceed jest's 5s default, so give the
23
+ // suite a generous timeout (these are inherently I/O-bound, not hung). Phase 12b
24
+ // added queue-runtime-surface.spec.ts which contends for the same jest workers,
25
+ // so the margin is bumped to keep the settle cases from a transient timeout.
26
+ jest.setTimeout(60000);
27
+
28
+ jest.mock('../../src/libs/logger', () => ({
29
+ __esModule: true,
30
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
31
+ }));
32
+
33
+ const STORE_DIR = path.join(__dirname, '../../src/store');
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-parity-'));
35
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
36
+ const umzug = new Umzug({
37
+ migrations: {
38
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
39
+ resolve: ({ name, path: p, context }) => {
40
+ const migration = require(p!);
41
+ return {
42
+ name: name.replace(/\.ts$/, '.js'),
43
+ up: () => migration.up({ context }),
44
+ down: () => migration.down({ context }),
45
+ };
46
+ },
47
+ },
48
+ context: sequelize.getQueryInterface(),
49
+ storage: new SequelizeStorage({ sequelize }),
50
+ logger: undefined,
51
+ });
52
+
53
+ let createQueue: any;
54
+
55
+ beforeAll(async () => {
56
+ await umzug.up();
57
+ const models = require('../../src/store/models');
58
+ models.initialize(sequelize);
59
+ createQueue = require('../../src/libs/queue').default;
60
+ }, 120000);
61
+
62
+ afterAll(async () => {
63
+ await sequelize.close();
64
+ fs.rmSync(dir, { recursive: true, force: true });
65
+ });
66
+
67
+ const settle = (emitter: any): Promise<{ event: string; data: any }> =>
68
+ new Promise((resolve) => {
69
+ ['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
70
+ });
71
+
72
+ describe('Part A — Node queue engine contract semantics', () => {
73
+ beforeEach(async () => {
74
+ await sequelize.query('DELETE FROM jobs');
75
+ });
76
+
77
+ it('immediate job runs and the jobs row is cleared on finish', async () => {
78
+ const seen: any[] = [];
79
+ const q = createQueue({
80
+ name: `pa-imm-${Date.now()}`,
81
+ onJob: async (job: any) => {
82
+ seen.push(job.v);
83
+ return job.v * 2;
84
+ },
85
+ });
86
+ const ev = q.push({ job: { v: 21 } });
87
+ const { event, data } = await settle(ev);
88
+ expect(event).toBe('finished');
89
+ expect(data.result).toBe(42);
90
+ expect(seen).toEqual([21]);
91
+ const row = await q.get(ev.id);
92
+ expect(row).toBeNull(); // cleared
93
+ });
94
+
95
+ it('pushAndWait resolves with the handler result', async () => {
96
+ const q = createQueue({ name: `pa-paw-${Date.now()}`, onJob: async (job: any) => `ok:${job.v}` });
97
+ const res: any = await q.pushAndWait({ job: { v: 7 } });
98
+ expect(res.result).toBe('ok:7');
99
+ });
100
+
101
+ it('retries up to maxRetries then fails (no infinite retry)', async () => {
102
+ let attempts = 0;
103
+ const retries: number[] = [];
104
+ const q = createQueue({
105
+ name: `pa-retry-${Date.now()}`,
106
+ onJob: async () => {
107
+ attempts += 1;
108
+ throw new Error('always');
109
+ },
110
+ options: { maxRetries: 3, retryDelay: 1 },
111
+ });
112
+ q.on('retry', () => retries.push(1));
113
+ const ev = q.push({ job: { v: 1 } });
114
+ const { event } = await settle(ev);
115
+ expect(event).toBe('failed');
116
+ expect(attempts).toBe(3); // initial + 2 retries (retry_count starts at 1, fails at >= maxRetries)
117
+ expect(retries.length).toBeGreaterThanOrEqual(1);
118
+ });
119
+
120
+ it('nonRetryable error fails immediately (single attempt)', async () => {
121
+ let attempts = 0;
122
+ const q = createQueue({
123
+ name: `pa-nonretry-${Date.now()}`,
124
+ onJob: async () => {
125
+ attempts += 1;
126
+ const err: any = new Error('forged');
127
+ err.nonRetryable = true;
128
+ throw err;
129
+ },
130
+ options: { maxRetries: 5, retryDelay: 1 },
131
+ });
132
+ const ev = q.push({ job: { v: 1 } });
133
+ const { event } = await settle(ev);
134
+ expect(event).toBe('failed');
135
+ expect(attempts).toBe(1);
136
+ });
137
+
138
+ it('cancel marks the row and excludes it from recovery (the skip mechanism)', async () => {
139
+ const q = createQueue({ name: `pa-cancel-${Date.now()}`, onJob: async () => undefined });
140
+ await q.store.addJob('cj-1', { v: 1, instance_did: 'did:abt:zCANCEL' }, {});
141
+ // before cancel: recovery would pick the row up
142
+ expect((await q.store.getJobs()).some((j: any) => j.id === 'cj-1')).toBe(true);
143
+ await q.cancel('cj-1');
144
+ // after cancel: execution-time guard sees it, recovery query excludes it
145
+ expect(await q.store.isCancelled('cj-1')).toBe(true);
146
+ expect((await q.store.getJobs()).some((j: any) => j.id === 'cj-1')).toBe(false);
147
+ });
148
+
149
+ // Bad input
150
+ it('rejects an empty job', () => {
151
+ const q = createQueue({ name: `pa-empty-${Date.now()}`, onJob: async () => undefined });
152
+ expect(() => q.push({ job: undefined as any })).toThrow(/Can not queue empty job/);
153
+ });
154
+
155
+ // Data damage — same job id does not execute twice (duplicate guard)
156
+ it('a duplicate job id executes only once', async () => {
157
+ let runs = 0;
158
+ const q = createQueue({
159
+ name: `pa-dup-${Date.now()}`,
160
+ onJob: async () => {
161
+ runs += 1;
162
+ return runs;
163
+ },
164
+ });
165
+ q.push({ job: { v: 1 }, id: 'dup-1' });
166
+ q.push({ job: { v: 1 }, id: 'dup-1' }); // duplicate id — store rejects, no second execution
167
+ // Either push can win the concurrent addJob UNIQUE race; the LOSER's
168
+ // jobEvents never settles (its addJob rejects before queueJob), so awaiting
169
+ // one specific push is racy and starves under parallel load. Wait for the
170
+ // single execution to actually land instead — that is what we assert.
171
+ const deadline = Date.now() + 5000;
172
+ while (runs < 1 && Date.now() < deadline) {
173
+ // eslint-disable-next-line no-await-in-loop, no-promise-executor-return
174
+ await new Promise((r) => setTimeout(r, 20));
175
+ }
176
+ await new Promise((r) => setTimeout(r, 60));
177
+ expect(runs).toBe(1);
178
+ });
179
+
180
+ // Data leak — the handler runs under the PAYLOAD tenant, never another's
181
+ it('handler executes under the payload tenant (no cross-tenant leak)', async () => {
182
+ const seen: Record<string, string> = {};
183
+ const q = createQueue({
184
+ name: `pa-tenant-${Date.now()}`,
185
+ onJob: async (job: any) => {
186
+ seen[job.tag] = getInstanceDid();
187
+ },
188
+ });
189
+ // push under tenant A's context — payload is stamped with A
190
+ const evA = await withTenant('did:abt:zTENANTA', async () => q.push({ job: { tag: 'A' } }));
191
+ const evB = await withTenant('did:abt:zTENANTB', async () => q.push({ job: { tag: 'B' } }));
192
+ await Promise.all([settle(evA), settle(evB)]);
193
+ expect(seen.A).toBe('did:abt:zTENANTA');
194
+ expect(seen.B).toBe('did:abt:zTENANTB');
195
+ });
196
+ });
197
+
198
+ describe('Part B — fastq shim is a faithful drop-in for real fastq', () => {
199
+ // run the exact operations the engine uses against both executors
200
+ const realFastq = require('fastq');
201
+ const shimFastq = require('../../../cloudflare/shims/fastq').default;
202
+
203
+ const scenario = (fastqImpl: any) =>
204
+ new Promise<any>((resolve) => {
205
+ const order: string[] = [];
206
+ const results: any[] = [];
207
+ const q = fastqImpl(async (data: any, cb: Function) => {
208
+ order.push(data.id);
209
+ if (data.fail) {
210
+ cb(new Error(`fail:${data.id}`));
211
+ return;
212
+ }
213
+ cb(null, `done:${data.id}`);
214
+ }, 1);
215
+ let pending = 3;
216
+ const done = (tag: string) => (err: any, res: any) => {
217
+ results.push({ tag, err: err?.message ?? null, res: res ?? null });
218
+ pending -= 1;
219
+ if (pending === 0) resolve({ order, results });
220
+ };
221
+ q.push({ id: 'a' }, done('a'));
222
+ q.push({ id: 'b', fail: true }, done('b'));
223
+ q.unshift({ id: 'c' }, done('c')); // unshift jumps the queue (retry path)
224
+ });
225
+
226
+ it('produces identical execution order and results on both executors', async () => {
227
+ const real = await scenario(realFastq);
228
+ const shim = await scenario(shimFastq);
229
+ // same success/error callback shape per job
230
+ const norm = (r: any) => r.results.sort((x: any, y: any) => x.tag.localeCompare(y.tag));
231
+ expect(norm(shim)).toEqual(norm(real));
232
+ // job 'b' errored on both, 'a'/'c' succeeded on both
233
+ const byTag = (r: any) => Object.fromEntries(r.results.map((x: any) => [x.tag, x.err ? 'err' : 'ok']));
234
+ expect(byTag(shim)).toEqual(byTag(real));
235
+ expect(byTag(real)).toEqual({ a: 'ok', b: 'err', c: 'ok' });
236
+ });
237
+
238
+ it('the shim accepts the 2-arg fastq(worker, concurrency) form the engine uses', async () => {
239
+ // regression for the pre-existing "worker is not a function" bug
240
+ const ran: any[] = [];
241
+ const q = shimFastq(async (data: any, cb: Function) => {
242
+ ran.push(data.id);
243
+ cb(null, 'ok');
244
+ }, 1);
245
+ const res = await new Promise((r) => q.push({ id: 'x' }, (_e: any, v: any) => r(v)));
246
+ expect(ran).toEqual(['x']);
247
+ expect(res).toBe('ok');
248
+ });
249
+ });