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,277 @@
1
+ // Phase 12b (W2′): core queue RUNTIME surface parity probes.
2
+ //
3
+ // These lock the contract the CF worker now drives through the service/slot
4
+ // boundary instead of cloudflare/shims/queue.ts:
5
+ // - the queue REGISTRY is non-empty under the canonical (node-engine) build
6
+ // - a due delayed job dispatches + executes EXACTLY once via dispatchDueJobs()
7
+ // - a cancelled delayed job is NOT executed by dispatchDueJobs() (no
8
+ // half-execution residue — the spec's mandatory negative case)
9
+ // - retry / nonRetryable / maxRetries match the node engine
10
+ // - the handler runs under the PAYLOAD tenant; a cross-tenant object is
11
+ // fail-closed
12
+ // - in 'workerd' mode the background loop() is disabled (a frozen isolate
13
+ // must not run a timer) and an immediate push is not lost: flushQueueWork()
14
+ // drains it before the (simulated) response
15
+ // - the queue() consumer can resolve a core handler by name (no undefined ack)
16
+ //
17
+ // These supersede the deleted cloudflare/tests/shims/queue-{scheduled,delayed-
18
+ // persist}.spec.ts: those probed the removed cloudflare/shims/queue.ts duplicate
19
+ // engine (D1 + CF-Queue-send + inline-fallback). Under the canonical node engine
20
+ // the worker never sends to CF Queue (immediate jobs run in-isolate via fastq),
21
+ // so those CF-Queue-fallback scenarios no longer exist; due-delayed dispatch is
22
+ // covered here by dispatchDueJobs().
23
+
24
+ import fs from 'fs';
25
+ import os from 'os';
26
+ import path from 'path';
27
+ import { Sequelize } from 'sequelize';
28
+ import { SequelizeStorage, Umzug } from 'umzug';
29
+
30
+ import { withTenant, getInstanceDid } from '../../src/libs/context';
31
+ import {
32
+ setQueueRuntimeMode,
33
+ getQueueHandler,
34
+ getAllQueueNames,
35
+ dispatchDueJobs,
36
+ flushQueueWork,
37
+ __test__ as runtimeTest,
38
+ } from '../../src/libs/queue/runtime';
39
+
40
+ /* eslint-disable global-require, require-await, no-promise-executor-return */
41
+
42
+ // delayed/retry cases do real per-attempt DB ops on a shared sqlite file; give
43
+ // the suite a generous timeout (I/O-bound, not hung).
44
+ jest.setTimeout(30000);
45
+
46
+ jest.mock('../../src/libs/logger', () => ({
47
+ __esModule: true,
48
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
49
+ }));
50
+
51
+ const STORE_DIR = path.join(__dirname, '../../src/store');
52
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-runtime-'));
53
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
54
+ const umzug = new Umzug({
55
+ migrations: {
56
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
57
+ resolve: ({ name, path: p, context }) => {
58
+ const migration = require(p!);
59
+ return {
60
+ name: name.replace(/\.ts$/, '.js'),
61
+ up: () => migration.up({ context }),
62
+ down: () => migration.down({ context }),
63
+ };
64
+ },
65
+ },
66
+ context: sequelize.getQueryInterface(),
67
+ storage: new SequelizeStorage({ sequelize }),
68
+ logger: undefined,
69
+ });
70
+
71
+ let createQueue: any;
72
+ const TENANT_A = 'did:abt:zRUNTIMEA';
73
+ const TENANT_B = 'did:abt:zRUNTIMEB';
74
+
75
+ const settle = (emitter: any): Promise<{ event: string; data: any }> =>
76
+ new Promise((resolve) => {
77
+ ['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, (data: any) => resolve({ event: e, data })));
78
+ });
79
+
80
+ beforeAll(async () => {
81
+ await umzug.up();
82
+ const models = require('../../src/store/models');
83
+ models.initialize(sequelize);
84
+ createQueue = require('../../src/libs/queue').default;
85
+ }, 120000);
86
+
87
+ afterAll(async () => {
88
+ await sequelize.close();
89
+ fs.rmSync(dir, { recursive: true, force: true });
90
+ });
91
+
92
+ beforeEach(async () => {
93
+ await sequelize.query('DELETE FROM jobs');
94
+ setQueueRuntimeMode('node');
95
+ });
96
+
97
+ afterEach(() => {
98
+ // clears the registry + restores 'node' so each test is self-contained
99
+ runtimeTest.reset();
100
+ });
101
+
102
+ describe('registry + consumer lookup', () => {
103
+ it('createQueue registers the handle into the runtime registry (non-empty)', () => {
104
+ const name = `reg-${Date.now()}`;
105
+ const q = createQueue({ name, onJob: async () => undefined });
106
+ expect(getAllQueueNames()).toContain(name);
107
+ expect(getQueueHandler(name)).toBe(q);
108
+ });
109
+
110
+ it('the queue() consumer resolves a core handler by name and runs onJob once (no undefined ack)', async () => {
111
+ let runs = 0;
112
+ const name = `consume-${Date.now()}`;
113
+ createQueue({ name, onJob: async () => { runs += 1; return 'ok'; } });
114
+
115
+ const handle = getQueueHandler(name);
116
+ expect(handle).toBeTruthy();
117
+ const res: any = await withTenant(TENANT_A, async () =>
118
+ handle.pushAndWait({ job: { v: 1, instance_did: TENANT_A } })
119
+ );
120
+ expect(res.result).toBe('ok');
121
+ expect(runs).toBe(1);
122
+ });
123
+ });
124
+
125
+ describe('scheduled dispatch (delay) parity — host-driven dispatchDueJobs()', () => {
126
+ it("'workerd' mode disables the background loop (no auto-dispatch)", async () => {
127
+ setQueueRuntimeMode('workerd');
128
+ let ran = 0;
129
+ const name = `wd-loop-${Date.now()}`;
130
+ const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
131
+ // a due delayed row sitting in D1
132
+ await q.store.addJob('wd-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
133
+ // a node loop would have polled + run it by now; workerd must not
134
+ await new Promise((r) => setTimeout(r, 60));
135
+ expect(ran).toBe(0);
136
+ });
137
+
138
+ it('a due delayed job dispatches and executes exactly once via dispatchDueJobs()', async () => {
139
+ setQueueRuntimeMode('workerd');
140
+ let ran = 0;
141
+ const seenTenant: string[] = [];
142
+ const name = `wd-due-${Date.now()}`;
143
+ const q = createQueue({
144
+ name,
145
+ onJob: async () => { ran += 1; seenTenant.push(getInstanceDid()); },
146
+ options: { enableScheduledJob: true },
147
+ });
148
+ await q.store.addJob('due-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
149
+
150
+ const r = await dispatchDueJobs();
151
+ await flushQueueWork(); // drain the re-dispatched immediate execution
152
+
153
+ expect(r.dispatched).toBe(1);
154
+ expect(ran).toBe(1);
155
+ expect(seenTenant).toEqual([TENANT_A]); // ran under the payload tenant
156
+ // row cleared after success
157
+ expect(await q.store.getJob('due-1')).toBeNull();
158
+ });
159
+
160
+ it('a cancelled delayed job is NOT executed by dispatchDueJobs() (no half-execution residue)', async () => {
161
+ setQueueRuntimeMode('workerd');
162
+ let ran = 0;
163
+ const name = `wd-cancel-${Date.now()}`;
164
+ const q = createQueue({ name, onJob: async () => { ran += 1; }, options: { enableScheduledJob: true } });
165
+ await q.store.addJob('c-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
166
+ await q.cancel('c-1'); // marks cancelled=true BEFORE the due-dispatch tick
167
+
168
+ const r = await dispatchDueJobs();
169
+ await flushQueueWork();
170
+
171
+ expect(r.dispatched).toBe(0);
172
+ expect(ran).toBe(0);
173
+ });
174
+
175
+ it('node entry: a cancelled delayed row is excluded from due dispatch (same filter both entries use)', async () => {
176
+ setQueueRuntimeMode('node');
177
+ // no enableScheduledJob → no background loop leaks; we assert the exact
178
+ // store filter (cancelled:false) that BOTH the node loop() and the workerd
179
+ // dispatchDueJobs() select due rows with, so cancel parity is structural.
180
+ const q = createQueue({ name: `node-cancel-${Date.now()}`, onJob: async () => undefined });
181
+ await q.store.addJob('nc-1', { v: 1, instance_did: TENANT_A }, { delay: 5, will_run_at: Date.now() - 1000 });
182
+ expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(true);
183
+ await q.cancel('nc-1');
184
+ expect((await q.store.getScheduledJobs()).some((j: any) => j.id === 'nc-1')).toBe(false);
185
+ });
186
+ });
187
+
188
+ describe('retry / nonRetryable parity (node engine semantics)', () => {
189
+ it('retries up to maxRetries then fails (initial + retries, no infinite loop)', async () => {
190
+ let attempts = 0;
191
+ const q = createQueue({
192
+ name: `rt-retry-${Date.now()}`,
193
+ onJob: async () => { attempts += 1; throw new Error('always'); },
194
+ options: { maxRetries: 3, retryDelay: 1 },
195
+ });
196
+ const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
197
+ const { event } = await settle(ev);
198
+ expect(event).toBe('failed');
199
+ expect(attempts).toBe(3);
200
+ });
201
+
202
+ it('a nonRetryable error fails immediately (single attempt)', async () => {
203
+ let attempts = 0;
204
+ const q = createQueue({
205
+ name: `rt-nonretry-${Date.now()}`,
206
+ onJob: async () => {
207
+ attempts += 1;
208
+ const err: any = new Error('forged');
209
+ err.nonRetryable = true;
210
+ throw err;
211
+ },
212
+ options: { maxRetries: 5, retryDelay: 1 },
213
+ });
214
+ const ev = q.push({ job: { v: 1, instance_did: TENANT_A } });
215
+ const { event } = await settle(ev);
216
+ expect(event).toBe('failed');
217
+ expect(attempts).toBe(1);
218
+ });
219
+ });
220
+
221
+ describe('tenant safety', () => {
222
+ it('handler runs under the payload tenant; a cross-tenant object is fail-closed', async () => {
223
+ const { assertJobObjectTenant } = require('../../src/libs/queue');
224
+ let leaked = false;
225
+ let observedTenant = '';
226
+ const q = createQueue({
227
+ name: `tn-${Date.now()}`,
228
+ onJob: async (_job: any) => {
229
+ observedTenant = getInstanceDid();
230
+ // simulate an object loaded cross-tenant (payload says A, row says B)
231
+ assertJobObjectTenant({ instance_did: TENANT_B });
232
+ leaked = true; // must be unreachable — assert throws first
233
+ },
234
+ });
235
+ const ev = await withTenant(TENANT_A, async () => q.push({ job: { tag: 'x' } }));
236
+ const { event } = await settle(ev);
237
+ expect(observedTenant).toBe(TENANT_A);
238
+ expect(leaked).toBe(false);
239
+ expect(event).toBe('failed');
240
+ });
241
+ });
242
+
243
+ describe('workerd flush — immediate push not lost', () => {
244
+ it('flushQueueWork() drains an in-flight immediate push before the response', async () => {
245
+ setQueueRuntimeMode('workerd');
246
+ let ran = 0;
247
+ const q = createQueue({ name: `flush-${Date.now()}`, onJob: async () => { ran += 1; } });
248
+ // fire-and-forget immediate push (the worker does this inside a request)
249
+ q.push({ job: { v: 1, instance_did: TENANT_A } });
250
+ // simulate the host draining before the isolate freezes
251
+ await flushQueueWork();
252
+ expect(ran).toBe(1);
253
+ });
254
+
255
+ it('flushQueueWork() is a no-op on node (nothing tracked, returns immediately)', async () => {
256
+ setQueueRuntimeMode('node');
257
+ await expect(flushQueueWork()).resolves.toBeUndefined();
258
+ });
259
+
260
+ it('a duplicate immediate push does not hang flushQueueWork() (tracker released on addJob failure)', async () => {
261
+ setQueueRuntimeMode('workerd');
262
+ let runs = 0;
263
+ const q = createQueue({ name: `flush-dup-${Date.now()}`, onJob: async () => { runs += 1; } });
264
+ q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' });
265
+ q.push({ job: { v: 1, instance_did: TENANT_A }, id: 'dup-flush' }); // duplicate → never enqueues
266
+ // must resolve, not hang on the never-settling duplicate
267
+ await flushQueueWork();
268
+ expect(runs).toBe(1);
269
+ });
270
+ });
271
+
272
+ describe('test harness sanity', () => {
273
+ it('runtime __test__.reset clears the registry', () => {
274
+ createQueue({ name: `sanity-${Date.now()}`, onJob: async () => undefined });
275
+ expect(runtimeTest.registrySize()).toBeGreaterThan(0);
276
+ });
277
+ });
@@ -0,0 +1,127 @@
1
+ // D2 (S3.0) — the node queue poll loop is cancelable. On a Node host the
2
+ // per-scheduled-queue loop() polls due delayed rows on a timer; lifecycle.stop()
3
+ // calls stopAllQueues() so no poll timer survives a stop / ARC_PAYMENT toggle
4
+ // (the spec's "active handles 归零"). This proves: (1) the loop self-dispatches a
5
+ // due job on node, (2) after stopAllQueues() it no longer dispatches AND its
6
+ // sleep timer is gone.
7
+
8
+ import fs from 'fs';
9
+ import os from 'os';
10
+ import path from 'path';
11
+ import { Sequelize } from 'sequelize';
12
+ import { SequelizeStorage, Umzug } from 'umzug';
13
+
14
+ import { withTenant } from '../../src/libs/context';
15
+ import { setQueueRuntimeMode, stopAllQueues, __test__ as runtimeTest } from '../../src/libs/queue/runtime';
16
+
17
+ jest.setTimeout(30000);
18
+
19
+ jest.mock('../../src/libs/logger', () => ({
20
+ __esModule: true,
21
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
22
+ }));
23
+
24
+ const STORE_DIR = path.join(__dirname, '../../src/store');
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'queue-teardown-'));
26
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
27
+ const umzug = new Umzug({
28
+ migrations: {
29
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
30
+ resolve: ({ name, path: p, context }) => {
31
+ const migration = require(p!);
32
+ return {
33
+ name: name.replace(/\.ts$/, '.js'),
34
+ up: () => migration.up({ context }),
35
+ down: () => migration.down({ context }),
36
+ };
37
+ },
38
+ },
39
+ context: sequelize.getQueryInterface(),
40
+ storage: new SequelizeStorage({ sequelize }),
41
+ logger: undefined,
42
+ });
43
+
44
+ let createQueue: any;
45
+ const TENANT = 'did:abt:zTEARDOWNA';
46
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
47
+ const countTimers = () =>
48
+ (process as any).getActiveResourcesInfo().filter((r: string) => r === 'Timeout').length;
49
+
50
+ beforeAll(async () => {
51
+ await umzug.up();
52
+ const models = require('../../src/store/models');
53
+ models.initialize(sequelize);
54
+ createQueue = require('../../src/libs/queue').default;
55
+ }, 120000);
56
+
57
+ afterAll(async () => {
58
+ await sequelize.close();
59
+ fs.rmSync(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ beforeEach(async () => {
63
+ await sequelize.query('DELETE FROM jobs');
64
+ setQueueRuntimeMode('node');
65
+ });
66
+
67
+ afterEach(() => {
68
+ stopAllQueues();
69
+ runtimeTest.reset();
70
+ });
71
+
72
+ describe('D2 — node queue loop self-dispatches a due delayed job (positive)', () => {
73
+ it('a due delayed row is picked up by the node loop with no host tick', async () => {
74
+ let ran = 0;
75
+ const name = `loop-pos-${Date.now()}`;
76
+ const q = createQueue({
77
+ name,
78
+ onJob: async () => {
79
+ ran += 1;
80
+ },
81
+ options: { enableScheduledJob: true },
82
+ });
83
+ await q.store.addJob('due-1', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
84
+ await wait(1600); // loop polls every minDelay/2 = 1s in test env
85
+ expect(ran).toBeGreaterThanOrEqual(1);
86
+ });
87
+ });
88
+
89
+ describe('D2 — stopAllQueues() stops the loop and clears its timer (teardown)', () => {
90
+ it('after stopAllQueues() a due delayed row is NOT auto-dispatched', async () => {
91
+ let ran = 0;
92
+ const name = `loop-stop-${Date.now()}`;
93
+ const q = createQueue({
94
+ name,
95
+ onJob: async () => {
96
+ ran += 1;
97
+ },
98
+ options: { enableScheduledJob: true },
99
+ });
100
+ const withLoop = countTimers();
101
+ stopAllQueues(); // teardown before the first tick fires
102
+ const afterStop = countTimers();
103
+ // the loop's pending sleep timer is gone
104
+ expect(afterStop).toBeLessThan(withLoop);
105
+
106
+ await q.store.addJob('due-2', { v: 1, instance_did: TENANT }, { delay: 5, will_run_at: Date.now() - 1000 });
107
+ await wait(1600);
108
+ expect(ran).toBe(0); // loop is dead — nothing dispatched it
109
+ });
110
+
111
+ it('the queue handle exposes a stop() function', () => {
112
+ const q = createQueue({
113
+ name: `has-stop-${Date.now()}`,
114
+ onJob: async () => {},
115
+ options: { enableScheduledJob: true },
116
+ });
117
+ expect(typeof q.stop).toBe('function');
118
+ });
119
+
120
+ it('pushAndWait still works after a stop (immediate path unaffected by loop teardown)', async () => {
121
+ const name = `imm-${Date.now()}`;
122
+ const q = createQueue({ name, onJob: async () => 'ok', options: { enableScheduledJob: true } });
123
+ stopAllQueues();
124
+ const res: any = await withTenant(TENANT, async () => q.pushAndWait({ job: { v: 1, instance_did: TENANT } }));
125
+ expect(res.result).toBe('ok');
126
+ });
127
+ });
@@ -0,0 +1,245 @@
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 { systemFindByPk } from '../../src/store/scoped';
10
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
11
+
12
+ jest.mock('../../src/libs/logger', () => ({
13
+ __esModule: true,
14
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
15
+ }));
16
+
17
+ const STORE_DIR = path.join(__dirname, '../../src/store');
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-matrix-a-'));
19
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
20
+ const umzug = new Umzug({
21
+ migrations: {
22
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
23
+ resolve: ({ name, path: p, context }) => {
24
+ // eslint-disable-next-line import/no-dynamic-require, global-require
25
+ const migration = require(p!);
26
+ return {
27
+ name: name.replace(/\.ts$/, '.js'),
28
+ up: () => migration.up({ context }),
29
+ down: () => migration.down({ context }),
30
+ };
31
+ },
32
+ },
33
+ context: sequelize.getQueryInterface(),
34
+ storage: new SequelizeStorage({ sequelize }),
35
+ logger: undefined,
36
+ });
37
+
38
+ let models: any;
39
+ let createQueue: any;
40
+ let assertJobObjectTenant: any;
41
+
42
+ beforeAll(async () => {
43
+ await umzug.up();
44
+ // eslint-disable-next-line global-require
45
+ models = require('../../src/store/models');
46
+ models.initialize(sequelize);
47
+ // eslint-disable-next-line global-require
48
+ const queueModule = require('../../src/libs/queue');
49
+ createQueue = queueModule.default;
50
+ assertJobObjectTenant = queueModule.assertJobObjectTenant;
51
+ }, 120000);
52
+
53
+ afterAll(async () => {
54
+ await sequelize.close();
55
+ fs.rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ const waitFor = (emitter: any, events: string[]): Promise<{ event: string; data: any }> =>
59
+ new Promise((resolve) => {
60
+ for (const event of events) {
61
+ emitter.on(event, (data: any) => resolve({ event, data }));
62
+ }
63
+ });
64
+
65
+ describe('queue tenant layer — first batch (phase 5)', () => {
66
+ beforeEach(async () => {
67
+ await sequelize.query('DELETE FROM customers');
68
+ await sequelize.query('DELETE FROM jobs');
69
+ });
70
+
71
+ describe('happy path', () => {
72
+ it('push stamps the active tenant into the payload, job row included, handler runs in that tenant', async () => {
73
+ const seen: string[] = [];
74
+ const queue = createQueue({
75
+ name: `tm-happy-${Date.now()}`,
76
+ onJob: async (job: any) => {
77
+ // eslint-disable-next-line global-require
78
+ const { getInstanceDid } = require('../../src/libs/context');
79
+ seen.push(job.instance_did, getInstanceDid());
80
+ },
81
+ });
82
+
83
+ const handle = await withTenant(TENANT_A, async () => queue.push({ job: { businessId: 'x1' }, persist: true }));
84
+ await waitFor(handle, ['finished', 'failed']);
85
+ expect(seen).toEqual([TENANT_A, TENANT_A]);
86
+ });
87
+
88
+ it('handler object guard passes for matching tenants', async () => {
89
+ const customer = await withTenant(TENANT_A, () =>
90
+ models.Customer.create({ livemode: false, did: 'z-m-a', delinquent: false, instance_did: TENANT_A })
91
+ );
92
+ await withTenant(TENANT_A, async () => {
93
+ expect(() => assertJobObjectTenant(customer)).not.toThrow();
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('bad input', () => {
99
+ it('multi mode push without tenant context is rejected at the gate', async () => {
100
+ process.env.PAYMENT_TENANT_MODE = 'multi';
101
+ try {
102
+ const queue = createQueue({ name: `tm-bad-${Date.now()}`, onJob: async () => {} });
103
+ expect(() => queue.push({ job: { businessId: 'x' } })).toThrow(
104
+ expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
105
+ );
106
+ } finally {
107
+ delete process.env.PAYMENT_TENANT_MODE;
108
+ }
109
+ });
110
+
111
+ it('illegal tenant DID in the payload is refused', () => {
112
+ const queue = createQueue({ name: `tm-bad2-${Date.now()}`, onJob: async () => {} });
113
+ expect(() => queue.push({ job: { businessId: 'x', instance_did: 'a b' } })).toThrow(
114
+ expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
115
+ );
116
+ });
117
+ });
118
+
119
+ describe('security: forged payload tenant vs object tenant', () => {
120
+ it('handler refuses to act on another tenant object; the object is untouched', async () => {
121
+ const victim = await withTenant(TENANT_B, () =>
122
+ models.Customer.create({ livemode: false, did: 'z-victim', delinquent: false, instance_did: TENANT_B })
123
+ );
124
+
125
+ let observedError: any;
126
+ const queue = createQueue({
127
+ name: `tm-forged-${Date.now()}`,
128
+ onJob: async (job: any) => {
129
+ // handlers load the tenant-stamped object cross-tenant (system) then
130
+ // enforce its tenant — mirrors the real queue handlers so a forged
131
+ // payload surfaces an observable TENANT_MISMATCH rather than folding
132
+ // into a scoped null (tenant-design §10).
133
+ const row: any = await systemFindByPk(models.Customer, job.customerId);
134
+ assertJobObjectTenant(row); // throws TENANT_MISMATCH
135
+ await row.update({ name: 'pwned' });
136
+ },
137
+ });
138
+ // forge: payload claims TENANT_A but targets B's customer
139
+ const handle = await withTenant(TENANT_A, async () =>
140
+ queue.push({ job: { customerId: victim.id }, persist: true })
141
+ );
142
+ const outcome = await waitFor(handle, ['failed', 'finished']);
143
+ observedError = (outcome.data as any).error;
144
+
145
+ expect(outcome.event).toBe('failed');
146
+ expect(observedError?.code).toBe(TENANT_MISMATCH);
147
+ const reloaded: any = await systemFindByPk(models.Customer, victim.id);
148
+ expect(reloaded.name).toBeNull(); // not 'pwned'
149
+ expect(reloaded.instance_did).toBe(TENANT_B);
150
+ });
151
+ });
152
+
153
+ describe('data loss: refused jobs surface as failures, not silence', () => {
154
+ it('the failed event fires with the tenant error attached', async () => {
155
+ const queue = createQueue({
156
+ name: `tm-fail-${Date.now()}`,
157
+ onJob: async () => {
158
+ const err: any = new Error('refused');
159
+ err.code = TENANT_MISMATCH;
160
+ err.nonRetryable = true;
161
+ throw err;
162
+ },
163
+ });
164
+ const handle = await withTenant(TENANT_A, async () => queue.push({ job: { businessId: 'x' }, persist: true }));
165
+ const outcome = await waitFor(handle, ['failed', 'finished']);
166
+ expect(outcome.event).toBe('failed');
167
+ expect((outcome.data as any).error?.code).toBe(TENANT_MISMATCH);
168
+ });
169
+ });
170
+
171
+ describe('data damage: retries keep the original tenant', () => {
172
+ it('a retried job re-executes under the same tenant, never a different one', async () => {
173
+ const tenantsSeen: string[] = [];
174
+ const queue = createQueue({
175
+ name: `tm-retry-${Date.now()}`,
176
+ onJob: async (job: any) => {
177
+ tenantsSeen.push(job.instance_did);
178
+ if (tenantsSeen.length < 2) throw new Error('transient');
179
+ },
180
+ options: { maxRetries: 2, retryDelay: 1 },
181
+ });
182
+ const handle = await withTenant(TENANT_B, async () => queue.push({ job: { businessId: 'r1' }, persist: true }));
183
+ await waitFor(handle, ['finished', 'failed']);
184
+ expect(tenantsSeen.length).toBeGreaterThanOrEqual(2);
185
+ expect(new Set(tenantsSeen)).toEqual(new Set([TENANT_B]));
186
+ });
187
+ });
188
+
189
+ // NOTE: spec's "channel-identifier job id prefix" row converts to a no-op
190
+ // for Phase 5 queues — all job ids are internal globally-unique business
191
+ // ids (see queue-matrix.md); channel-identifier dedup moves to Phase 6
192
+ // integrations as (instance_did, identifier) composite keys.
193
+ describe('data leak: legacy job strategy', () => {
194
+ it('a pre-tenant job (no instance_did) executes under the default tenant in single mode', async () => {
195
+ const seen: string[] = [];
196
+ const queue = createQueue({
197
+ name: `tm-legacy-${Date.now()}`,
198
+ onJob: async () => {
199
+ // eslint-disable-next-line global-require
200
+ const { getInstanceDid } = require('../../src/libs/context');
201
+ seen.push(getInstanceDid());
202
+ },
203
+ });
204
+ // simulate a legacy row: bypass push's injection by writing the store directly
205
+ await queue.store.addJob('legacy-1', { businessId: 'legacy' }, {});
206
+ const row = await queue.get('legacy-1');
207
+ expect(row.instance_did).toBeUndefined();
208
+ // re-deliver it the way startup recovery does (push with persist=false keeps payload as-is? no — push injects)
209
+ // execution path: runJobWithTenant falls back to the default tenant
210
+ const handle = queue.push({
211
+ job: row,
212
+ id: 'legacy-1',
213
+ persist: false,
214
+ skipDuplicateCheck: true,
215
+ fromStore: true,
216
+ });
217
+ await waitFor(handle, ['finished', 'failed']);
218
+ // eslint-disable-next-line global-require
219
+ const { getDefaultInstanceDid } = require('../../src/libs/tenant');
220
+ expect(seen).toEqual([getDefaultInstanceDid()]);
221
+ });
222
+
223
+ it('a legacy job is refused permanently in multi mode (structured, non-retryable)', async () => {
224
+ const queue = createQueue({
225
+ name: `tm-legacy-multi-${Date.now()}`,
226
+ onJob: async () => 'should-not-run',
227
+ });
228
+ process.env.PAYMENT_TENANT_MODE = 'multi';
229
+ try {
230
+ const handle = queue.push({
231
+ job: { businessId: 'legacy-multi' },
232
+ id: 'legacy-multi-1',
233
+ persist: false,
234
+ fromStore: true, // store re-delivery path: gate skipped, execution-side refuses
235
+ });
236
+ const outcome = await waitFor(handle, ['failed', 'finished']);
237
+ expect(outcome.event).toBe('failed');
238
+ expect((outcome.data as any).error?.code).toBe(TENANT_CONTEXT_MISSING);
239
+ expect((outcome.data as any).error?.nonRetryable).toBe(true);
240
+ } finally {
241
+ delete process.env.PAYMENT_TENANT_MODE;
242
+ }
243
+ });
244
+ });
245
+ });