payment-kit 1.29.1 → 1.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) 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 +47 -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 +41 -37
  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/crons/tenant-fanout.ts +82 -0
  12. package/api/src/host-node/did-connect-runtime-node.ts +33 -0
  13. package/api/src/host-node/serve-static-arc.ts +68 -0
  14. package/api/src/host-node/serve-static.ts +41 -0
  15. package/api/src/index.ts +22 -161
  16. package/api/src/integrations/app-store/client.ts +3 -4
  17. package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
  18. package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
  19. package/api/src/integrations/arcblock/token.ts +21 -7
  20. package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
  21. package/api/src/integrations/google-play/handlers/voided.ts +2 -2
  22. package/api/src/integrations/google-play/verify.ts +3 -2
  23. package/api/src/integrations/iap-reconcile.ts +3 -5
  24. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  25. package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
  26. package/api/src/libs/archive/query.ts +19 -0
  27. package/api/src/libs/audit.ts +61 -4
  28. package/api/src/libs/auth.ts +247 -47
  29. package/api/src/libs/context.ts +89 -1
  30. package/api/src/libs/currency.ts +2 -2
  31. package/api/src/libs/dayjs.ts +8 -2
  32. package/api/src/libs/did-connect/runtime-did-connect-js.ts +88 -0
  33. package/api/src/libs/did-connect/tenant-identity.ts +221 -0
  34. package/api/src/libs/drivers/auth-storage.ts +118 -0
  35. package/api/src/libs/drivers/cron.ts +264 -0
  36. package/api/src/libs/drivers/db.ts +170 -0
  37. package/api/src/libs/drivers/identity.ts +142 -0
  38. package/api/src/libs/drivers/index.ts +40 -0
  39. package/api/src/libs/drivers/locks.ts +226 -0
  40. package/api/src/libs/drivers/migrate-runner.ts +70 -0
  41. package/api/src/libs/drivers/queue.ts +104 -0
  42. package/api/src/libs/drivers/secrets.ts +194 -0
  43. package/api/src/libs/env.ts +170 -54
  44. package/api/src/libs/exchange-rate/service.ts +7 -6
  45. package/api/src/libs/http-fetch-adapter.ts +60 -0
  46. package/api/src/libs/invoice.ts +1 -1
  47. package/api/src/libs/lock.ts +51 -47
  48. package/api/src/libs/logger.ts +48 -8
  49. package/api/src/libs/notification/index.ts +1 -1
  50. package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
  51. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
  52. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
  53. package/api/src/libs/overdraft-protection.ts +1 -1
  54. package/api/src/libs/payout.ts +1 -1
  55. package/api/src/libs/queue/index.ts +271 -52
  56. package/api/src/libs/queue/runtime.ts +175 -0
  57. package/api/src/libs/resource.ts +3 -3
  58. package/api/src/libs/secrets.ts +38 -0
  59. package/api/src/libs/session.ts +3 -2
  60. package/api/src/libs/subscription.ts +5 -5
  61. package/api/src/libs/tenant.ts +92 -0
  62. package/api/src/libs/url.ts +3 -3
  63. package/api/src/libs/util.ts +21 -13
  64. package/api/src/middlewares/hono/cdn.ts +63 -0
  65. package/api/src/middlewares/hono/context.ts +80 -0
  66. package/api/src/middlewares/hono/csrf.ts +83 -0
  67. package/api/src/middlewares/hono/fallback.ts +194 -0
  68. package/api/src/middlewares/hono/pipeline.ts +73 -0
  69. package/api/src/middlewares/hono/resource-mount.ts +42 -0
  70. package/api/src/middlewares/hono/resource.ts +63 -0
  71. package/api/src/middlewares/hono/security.ts +209 -0
  72. package/api/src/middlewares/hono/session.ts +114 -0
  73. package/api/src/middlewares/hono/xss.ts +61 -0
  74. package/api/src/queues/auto-recharge.ts +12 -10
  75. package/api/src/queues/checkout-session.ts +38 -21
  76. package/api/src/queues/credit-consume.ts +40 -36
  77. package/api/src/queues/credit-grant.ts +25 -18
  78. package/api/src/queues/credit-reconciliation.ts +7 -5
  79. package/api/src/queues/discount-status.ts +9 -6
  80. package/api/src/queues/event.ts +41 -11
  81. package/api/src/queues/exchange-rate-health.ts +49 -30
  82. package/api/src/queues/invoice.ts +18 -15
  83. package/api/src/queues/notification.ts +14 -7
  84. package/api/src/queues/payment.ts +64 -37
  85. package/api/src/queues/payout.ts +37 -21
  86. package/api/src/queues/refund.ts +36 -18
  87. package/api/src/queues/subscription.ts +83 -53
  88. package/api/src/queues/token-transfer.ts +15 -10
  89. package/api/src/queues/usage-record.ts +8 -5
  90. package/api/src/queues/vendors/commission.ts +7 -5
  91. package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
  92. package/api/src/queues/vendors/fulfillment.ts +4 -2
  93. package/api/src/queues/vendors/return-processor.ts +5 -3
  94. package/api/src/queues/vendors/return-scanner.ts +5 -4
  95. package/api/src/queues/vendors/status-check.ts +10 -7
  96. package/api/src/queues/webhook.ts +60 -32
  97. package/api/src/routes/connect/shared.ts +1 -2
  98. package/api/src/routes/connect/subscribe.ts +3 -3
  99. package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
  100. package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
  101. package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
  102. package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
  103. package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
  104. package/api/src/routes/hono/credit-tokens.ts +43 -0
  105. package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
  106. package/api/src/routes/{customers.ts → hono/customers.ts} +199 -224
  107. package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
  108. package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
  109. package/api/src/routes/{events.ts → hono/events.ts} +107 -71
  110. package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
  111. package/api/src/routes/hono/exchange-rates.ts +77 -0
  112. package/api/src/routes/hono/index.ts +115 -0
  113. package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
  114. package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
  115. package/api/src/routes/hono/integrations/stripe.ts +74 -0
  116. package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
  117. package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
  118. package/api/src/routes/hono/meters.ts +288 -0
  119. package/api/src/routes/hono/passports.ts +73 -0
  120. package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
  121. package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
  122. package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
  123. package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
  124. package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
  125. package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
  126. package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
  127. package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
  128. package/api/src/routes/{products.ts → hono/products.ts} +172 -159
  129. package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
  130. package/api/src/routes/hono/redirect.ts +24 -0
  131. package/api/src/routes/{refunds.ts → hono/refunds.ts} +98 -83
  132. package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
  133. package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
  134. package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
  135. package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
  136. package/api/src/routes/hono/tool.ts +69 -0
  137. package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
  138. package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
  139. package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
  140. package/api/src/routes/hono/webhook-endpoints.ts +126 -0
  141. package/api/src/service.ts +814 -0
  142. package/api/src/store/migrations/20230911-seeding.ts +2 -1
  143. package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
  144. package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
  145. package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
  146. package/api/src/store/models/auto-recharge-config.ts +22 -10
  147. package/api/src/store/models/checkout-session.ts +15 -14
  148. package/api/src/store/models/coupon.ts +29 -20
  149. package/api/src/store/models/credit-grant.ts +38 -29
  150. package/api/src/store/models/credit-transaction.ts +32 -21
  151. package/api/src/store/models/customer.ts +19 -17
  152. package/api/src/store/models/discount.ts +11 -2
  153. package/api/src/store/models/entitlement-grant.ts +21 -9
  154. package/api/src/store/models/entitlement-product.ts +21 -9
  155. package/api/src/store/models/entitlement.ts +19 -10
  156. package/api/src/store/models/event.ts +18 -9
  157. package/api/src/store/models/exchange-rate-provider.ts +17 -4
  158. package/api/src/store/models/invoice-item.ts +18 -9
  159. package/api/src/store/models/invoice.ts +16 -8
  160. package/api/src/store/models/meter-event.ts +27 -9
  161. package/api/src/store/models/meter.ts +31 -22
  162. package/api/src/store/models/payment-currency.ts +25 -8
  163. package/api/src/store/models/payment-intent.ts +15 -6
  164. package/api/src/store/models/payment-link.ts +15 -6
  165. package/api/src/store/models/payment-method.ts +38 -22
  166. package/api/src/store/models/payment-stat.ts +18 -9
  167. package/api/src/store/models/payout.ts +15 -6
  168. package/api/src/store/models/price-quote.ts +17 -8
  169. package/api/src/store/models/price.ts +24 -12
  170. package/api/src/store/models/pricing-table.ts +29 -20
  171. package/api/src/store/models/product-vendor.ts +20 -10
  172. package/api/src/store/models/product.ts +15 -6
  173. package/api/src/store/models/promotion-code.ts +14 -6
  174. package/api/src/store/models/refund.ts +15 -6
  175. package/api/src/store/models/revenue-snapshot.ts +21 -9
  176. package/api/src/store/models/setting.ts +18 -9
  177. package/api/src/store/models/setup-intent.ts +36 -27
  178. package/api/src/store/models/subscription-item.ts +21 -9
  179. package/api/src/store/models/subscription-schedule.ts +21 -9
  180. package/api/src/store/models/subscription.ts +21 -10
  181. package/api/src/store/models/tax-rate.ts +29 -21
  182. package/api/src/store/models/usage-record.ts +11 -2
  183. package/api/src/store/models/webhook-attempt.ts +18 -9
  184. package/api/src/store/models/webhook-endpoint.ts +18 -9
  185. package/api/src/store/scoped-core.ts +55 -0
  186. package/api/src/store/scoped.ts +247 -0
  187. package/api/src/store/sequelize.ts +82 -23
  188. package/api/src/store/sql-migrations.ts +20 -0
  189. package/api/src/store/tenant-backfill.ts +260 -0
  190. package/api/src/store/tenant-model.ts +124 -0
  191. package/api/src/store/tenant-tables.ts +50 -0
  192. package/api/tests/bootstrap/bootstrap.spec.ts +162 -0
  193. package/api/tests/crons/tenant-fanout.spec.ts +158 -0
  194. package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
  195. package/api/tests/fixtures/bare-query-violation.ts +13 -0
  196. package/api/tests/fixtures/core-env-violation.ts +10 -0
  197. package/api/tests/fixtures/host-read-violation.ts +19 -0
  198. package/api/tests/fixtures/tenants.ts +4 -0
  199. package/api/tests/integrations/iap-tenant.spec.ts +284 -0
  200. package/api/tests/libs/archive-query.spec.ts +26 -0
  201. package/api/tests/libs/audit-tenant.spec.ts +153 -0
  202. package/api/tests/libs/context.spec.ts +204 -0
  203. package/api/tests/libs/core-config.spec.ts +115 -0
  204. package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
  205. package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
  206. package/api/tests/libs/did-connect-runtime-js.spec.ts +98 -0
  207. package/api/tests/libs/did-connect-tenant-identity.spec.ts +159 -0
  208. package/api/tests/libs/lock-tenant.spec.ts +66 -0
  209. package/api/tests/libs/scoped.spec.ts +222 -0
  210. package/api/tests/libs/secrets-facade.spec.ts +52 -0
  211. package/api/tests/libs/service-host.spec.ts +37 -0
  212. package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
  213. package/api/tests/libs/tenant-middleware.spec.ts +42 -0
  214. package/api/tests/libs/tenant-scanner.spec.ts +120 -0
  215. package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
  216. package/api/tests/middlewares/hono/context.spec.ts +113 -0
  217. package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
  218. package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
  219. package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
  220. package/api/tests/middlewares/hono/security.spec.ts +181 -0
  221. package/api/tests/middlewares/hono/session.spec.ts +42 -0
  222. package/api/tests/middlewares/hono/xss.spec.ts +81 -0
  223. package/api/tests/models/tenant-backfill.spec.ts +287 -0
  224. package/api/tests/models/tenant-columns-model.spec.ts +46 -0
  225. package/api/tests/models/tenant-columns.spec.ts +161 -0
  226. package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
  227. package/api/tests/queues/credit-consume.spec.ts +8 -1
  228. package/api/tests/queues/event-tenant.spec.ts +292 -0
  229. package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
  230. package/api/tests/queues/queue-parity.spec.ts +249 -0
  231. package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
  232. package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
  233. package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
  234. package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
  235. package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
  236. package/api/tests/service/collapse.spec.ts +96 -0
  237. package/api/tests/service/didconnect-storage-slot.spec.ts +60 -0
  238. package/api/tests/service/fail-closed-http.spec.ts +79 -0
  239. package/api/tests/service/static-arc-handler.spec.ts +101 -0
  240. package/api/tests/service/static-externalized.spec.ts +48 -0
  241. package/api/tests/store/tenant-crosscut.spec.ts +202 -0
  242. package/api/tests/store/tenant-model-spike.spec.ts +177 -0
  243. package/api/tests/store/tenant-model.spec.ts +162 -0
  244. package/api/tests/store/tenant-residual.spec.ts +196 -0
  245. package/api/third.d.ts +4 -0
  246. package/blocklet.yml +1 -1
  247. package/cloudflare/MIGRATION-RUNBOOK.md +3 -8
  248. package/cloudflare/README.md +34 -27
  249. package/cloudflare/STAGING-MIGRATION-GUIDE.md +3 -15
  250. package/cloudflare/build.ts +33 -13
  251. package/cloudflare/cf-adapter.ts +419 -0
  252. package/cloudflare/did-connect-runtime.ts +96 -0
  253. package/cloudflare/did-connect-token-storage.ts +151 -0
  254. package/cloudflare/esbuild-cf-config.cjs +407 -0
  255. package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
  256. package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
  257. package/cloudflare/migrations/0008_schema_parity.sql +16 -0
  258. package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
  259. package/cloudflare/queue-runtime-mode.ts +13 -0
  260. package/cloudflare/run-build.js +33 -403
  261. package/cloudflare/scripts/cf-package-import-probe.mjs +90 -0
  262. package/cloudflare/scripts/didconnect-mock-smoke.mjs +140 -0
  263. package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
  264. package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
  265. package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
  266. package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
  267. package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
  268. package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
  269. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +16 -1
  270. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +18 -3
  271. package/cloudflare/shims/cron.ts +38 -158
  272. package/cloudflare/shims/events.ts +124 -0
  273. package/cloudflare/shims/fastq.ts +15 -1
  274. package/cloudflare/shims/nedb-storage.ts +16 -8
  275. package/cloudflare/shims/xss.ts +8 -0
  276. package/cloudflare/tenant-middleware.ts +36 -0
  277. package/cloudflare/tests/cf-adapter.spec.ts +244 -0
  278. package/cloudflare/tests/did-connect-token-storage.spec.ts +105 -0
  279. package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
  280. package/cloudflare/tests/worker-handler-gate.spec.ts +69 -0
  281. package/cloudflare/vite.config.ts +53 -45
  282. package/cloudflare/worker.ts +261 -448
  283. package/cloudflare/wrangler.json +0 -6
  284. package/cloudflare/wrangler.jsonc +0 -6
  285. package/cloudflare/wrangler.local-e2e.jsonc +25 -0
  286. package/cloudflare/wrangler.staging.json +0 -6
  287. package/jest.config.js +3 -1
  288. package/package.json +33 -38
  289. package/scripts/bootstrap-inject.ts +166 -0
  290. package/scripts/core-env-whitelist.json +1 -0
  291. package/scripts/e2e-12b-runtime.ts +149 -0
  292. package/scripts/e2e-core-config.ts +125 -0
  293. package/scripts/e2e-d1-tenancy.ts +116 -0
  294. package/scripts/e2e-d2-cron-queue.ts +139 -0
  295. package/scripts/e2e-d3-embedded-multi.ts +171 -0
  296. package/scripts/e2e-hono-s2.ts +125 -0
  297. package/scripts/e2e-hono-s3e.ts +135 -0
  298. package/scripts/e2e-hono-s4.ts +114 -0
  299. package/scripts/e2e-migration-contract.ts +100 -0
  300. package/scripts/e2e-s0.ts +61 -0
  301. package/scripts/e2e-s1.ts +107 -0
  302. package/scripts/e2e-s2.ts +178 -0
  303. package/scripts/e2e-s3.ts +110 -0
  304. package/scripts/e2e-s4.ts +191 -0
  305. package/scripts/e2e-s5.ts +139 -0
  306. package/scripts/e2e-s6.ts +127 -0
  307. package/scripts/e2e-tenant-model.ts +119 -0
  308. package/scripts/e2e-tenant-worker.ts +199 -0
  309. package/scripts/gen-sql-migrations.js +46 -0
  310. package/scripts/phase8-codemod.js +219 -0
  311. package/scripts/phase9a-env-getters-codemod.js +82 -0
  312. package/scripts/scan-core-env.js +109 -0
  313. package/scripts/scan-tenant-queries.js +235 -0
  314. package/scripts/schema-drift-guard.ts +210 -0
  315. package/scripts/tenant-scan-whitelist.json +1 -0
  316. package/src/app.tsx +2 -1
  317. package/src/env.d.ts +13 -1
  318. package/src/libs/service-host.ts +13 -0
  319. package/tsconfig.json +1 -1
  320. package/vite.arc.config.ts +159 -0
  321. package/api/src/libs/did-space.ts +0 -235
  322. package/api/src/libs/middleware.ts +0 -50
  323. package/api/src/libs/security.ts +0 -192
  324. package/api/src/queues/space.ts +0 -662
  325. package/api/src/routes/credit-tokens.ts +0 -38
  326. package/api/src/routes/exchange-rates.ts +0 -87
  327. package/api/src/routes/index.ts +0 -142
  328. package/api/src/routes/integrations/stripe.ts +0 -61
  329. package/api/src/routes/meters.ts +0 -274
  330. package/api/src/routes/passports.ts +0 -68
  331. package/api/src/routes/redirect.ts +0 -20
  332. package/api/src/routes/tool.ts +0 -65
  333. package/api/src/routes/webhook-endpoints.ts +0 -126
  334. package/api/tests/routes/credit-grants.spec.ts +0 -1261
  335. package/cloudflare/did-connect-auth.ts +0 -527
  336. package/cloudflare/shims/did-space-js.ts +0 -17
  337. package/cloudflare/shims/did-space.ts +0 -11
  338. package/cloudflare/shims/express-compat/index.ts +0 -80
  339. package/cloudflare/shims/express-compat/types.ts +0 -41
  340. package/cloudflare/shims/lock.ts +0 -115
  341. package/cloudflare/shims/queue.ts +0 -611
  342. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
  343. package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
@@ -0,0 +1,161 @@
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 { TENANT_TABLES } from '../../src/store/tenant-tables';
8
+
9
+ const STORE_DIR = path.join(__dirname, '../../src/store');
10
+ const TENANT_MIGRATION = '20260610-tenant-columns';
11
+ // pin the chain to phase 1: phase 2 (tenant backfill) intentionally rebuilds
12
+ // some tables with NOT NULL + composite uniques, which this spec must not see
13
+ const upToPhase1 = (umzug: Umzug<any>) => umzug.up({ to: `${TENANT_MIGRATION}.js` } as any);
14
+
15
+ function createHarness(storagePath: string) {
16
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: storagePath, logging: false });
17
+ const umzug = new Umzug({
18
+ migrations: {
19
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
20
+ resolve: ({ name, path: migrationPath, context }) => {
21
+ // eslint-disable-next-line import/no-dynamic-require, global-require
22
+ const migration = require(migrationPath!);
23
+ return {
24
+ name: name.replace(/\.ts$/, '.js'),
25
+ up: () => migration.up({ context }),
26
+ down: () => migration.down({ context }),
27
+ };
28
+ },
29
+ },
30
+ context: sequelize.getQueryInterface(),
31
+ storage: new SequelizeStorage({ sequelize }),
32
+ logger: undefined,
33
+ });
34
+ return { sequelize, umzug };
35
+ }
36
+
37
+ async function tableColumns(sequelize: Sequelize, table: string): Promise<Record<string, any>[]> {
38
+ const [rows] = await sequelize.query(`PRAGMA table_info(${table})`);
39
+ return rows as Record<string, any>[];
40
+ }
41
+
42
+ describe('tenant columns migration (phase 1)', () => {
43
+ let dir: string;
44
+ let dbPath: string;
45
+ let harness: ReturnType<typeof createHarness>;
46
+
47
+ beforeEach(() => {
48
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-columns-'));
49
+ dbPath = path.join(dir, 'test.db');
50
+ harness = createHarness(dbPath);
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await harness.sequelize.close();
55
+ fs.rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ describe('happy path', () => {
59
+ it('adds a nullable instance_did column to all 38 tenant tables', async () => {
60
+ await upToPhase1(harness.umzug);
61
+ for (const table of TENANT_TABLES) {
62
+ // eslint-disable-next-line no-await-in-loop
63
+ const columns = await tableColumns(harness.sequelize, table);
64
+ const column = columns.find((c) => c.name === 'instance_did');
65
+ expect({ table, found: Boolean(column) }).toEqual({ table, found: true });
66
+ expect({ table, notnull: column!.notnull }).toEqual({ table, notnull: 0 });
67
+ expect({ table, dflt: column!.dflt_value ?? null }).toEqual({ table, dflt: null });
68
+ }
69
+ }, 60000);
70
+
71
+ // "model layer exposes instance_did" lives in tenant-columns-model.spec.ts:
72
+ // initializing the model singletons here would mutate GENESIS_ATTRIBUTES
73
+ // (associations add FK clauses) and corrupt the migration harness below.
74
+ });
75
+
76
+ describe('bad input: re-running up is idempotent', () => {
77
+ it('skips existing columns without error and without duplicating them', async () => {
78
+ await upToPhase1(harness.umzug);
79
+ // call the migration's up() directly a second time, bypassing umzug bookkeeping
80
+ // eslint-disable-next-line import/no-dynamic-require, global-require
81
+ const migration = require(path.join(STORE_DIR, 'migrations', `${TENANT_MIGRATION}.ts`));
82
+ await migration.up({ context: harness.sequelize.getQueryInterface() });
83
+ const columns = await tableColumns(harness.sequelize, 'customers');
84
+ expect(columns.filter((c) => c.name === 'instance_did')).toHaveLength(1);
85
+ }, 60000);
86
+ });
87
+
88
+ describe('security: migration is built from static literals only', () => {
89
+ it('contains no env/config interpolation into SQL identifiers', () => {
90
+ const migrationSource = fs.readFileSync(path.join(STORE_DIR, 'migrations', `${TENANT_MIGRATION}.ts`), 'utf8');
91
+ expect(migrationSource).not.toContain('process.env');
92
+ expect(migrationSource).not.toContain('globalThis');
93
+ const listSource = fs.readFileSync(path.join(STORE_DIR, 'tenant-tables.ts'), 'utf8');
94
+ expect(listSource).not.toContain('process.env');
95
+ // every table entry is a quoted literal
96
+ const entries = listSource.match(/^\s*'[a-z_]+',$/gm) || [];
97
+ expect(entries.length).toBe(38);
98
+ });
99
+ });
100
+
101
+ describe('data loss / data damage: existing rows survive up and down', () => {
102
+ it('preserves pre-existing rows and their values through up, and through down', async () => {
103
+ // migrate to just before the tenant migration, then seed
104
+ await upToPhase1(harness.umzug);
105
+ await harness.umzug.down(); // revert the tenant column migration
106
+
107
+ const qi = harness.sequelize.getQueryInterface();
108
+ const now = new Date().toISOString();
109
+ await qi.bulkInsert('customers', [
110
+ {
111
+ id: 'cus_phase1_test1',
112
+ livemode: 0,
113
+ did: 'z-did-phase1-1',
114
+ delinquent: 0,
115
+ created_at: now,
116
+ updated_at: now,
117
+ },
118
+ ]);
119
+
120
+ // up: row survives, all original values intact, instance_did = NULL
121
+ await upToPhase1(harness.umzug);
122
+ const [afterUp] = await harness.sequelize.query(
123
+ "SELECT id, livemode, did, delinquent, instance_did FROM customers WHERE id = 'cus_phase1_test1'"
124
+ );
125
+ expect(afterUp).toHaveLength(1);
126
+ const row = (afterUp as any[])[0];
127
+ expect(row.did).toBe('z-did-phase1-1');
128
+ expect(row.livemode).toBe(0);
129
+ expect(row.instance_did).toBeNull();
130
+
131
+ // down: column removed, row and non-tenant values still intact
132
+ await harness.umzug.down();
133
+ const columns = await tableColumns(harness.sequelize, 'customers');
134
+ expect(columns.find((c) => c.name === 'instance_did')).toBeUndefined();
135
+ const [afterDown] = await harness.sequelize.query("SELECT id, did FROM customers WHERE id = 'cus_phase1_test1'");
136
+ expect(afterDown).toHaveLength(1);
137
+ expect((afterDown as any[])[0].did).toBe('z-did-phase1-1');
138
+ }, 120000);
139
+ });
140
+
141
+ describe('data leak: the new column is never auto-filled', () => {
142
+ it('defaults to NULL for rows written without an explicit tenant', async () => {
143
+ await upToPhase1(harness.umzug);
144
+ const qi = harness.sequelize.getQueryInterface();
145
+ const now = new Date().toISOString();
146
+ await qi.bulkInsert('products', [
147
+ {
148
+ id: 'prod_phase1_leak',
149
+ livemode: 0,
150
+ active: 1,
151
+ name: 'leak-check',
152
+ type: 'service',
153
+ created_at: now,
154
+ updated_at: now,
155
+ },
156
+ ]);
157
+ const [rows] = await harness.sequelize.query("SELECT instance_did FROM products WHERE id = 'prod_phase1_leak'");
158
+ expect((rows as any[])[0].instance_did).toBeNull();
159
+ }, 60000);
160
+ });
161
+ });
@@ -68,7 +68,14 @@ jest.mock('../../src/libs/queue', () => {
68
68
  delete: jest.fn().mockResolvedValue(undefined),
69
69
  on: jest.fn(),
70
70
  };
71
- return jest.fn().mockReturnValue(mockQueue);
71
+ const factory: any = jest.fn().mockReturnValue(mockQueue);
72
+ return {
73
+ __esModule: true,
74
+ default: factory,
75
+ // phase 5/6: tenant invariant helper — pass-through in these unit tests
76
+ // (tenant enforcement has its own suites: tenant-matrix-a/b)
77
+ assertJobObjectTenant: jest.fn(),
78
+ };
72
79
  });
73
80
 
74
81
  jest.mock('../../src/store/models', () => ({
@@ -71,7 +71,14 @@ jest.mock('../../src/libs/queue', () => {
71
71
  delete: jest.fn().mockResolvedValue(undefined),
72
72
  on: jest.fn(),
73
73
  };
74
- return jest.fn().mockReturnValue(mockQueue);
74
+ const factory: any = jest.fn().mockReturnValue(mockQueue);
75
+ return {
76
+ __esModule: true,
77
+ default: factory,
78
+ // phase 5/6: tenant invariant helper — pass-through in these unit tests
79
+ // (tenant enforcement has its own suites: tenant-matrix-a/b)
80
+ assertJobObjectTenant: jest.fn(),
81
+ };
75
82
  });
76
83
 
77
84
  jest.mock('../../src/store/models', () => ({
@@ -0,0 +1,292 @@
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 startEventQueue: any;
68
+ let eventQueue: any;
69
+ let getInstanceDid: any;
70
+ let logger: any;
71
+
72
+ beforeAll(async () => {
73
+ await umzug.up();
74
+ // eslint-disable-next-line global-require
75
+ models = require('../../src/store/models');
76
+ models.initialize(sequelize);
77
+ // eslint-disable-next-line global-require
78
+ ({ handleEvent, startEventQueue, eventQueue } = require('../../src/queues/event'));
79
+ // eslint-disable-next-line global-require
80
+ ({ getInstanceDid } = require('../../src/libs/context'));
81
+ // eslint-disable-next-line global-require
82
+ ({ handleWebhook } = jest.requireActual('../../src/queues/webhook'));
83
+ // eslint-disable-next-line global-require
84
+ ({ assertEventTenantAccessible } = require('../../src/routes/hono/events'));
85
+ // eslint-disable-next-line global-require
86
+ logger = require('../../src/libs/logger').default;
87
+ }, 120000);
88
+
89
+ afterAll(async () => {
90
+ await sequelize.close();
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ });
93
+
94
+ const seedEvent = (tenant: string, type = 'customer.updated') =>
95
+ withTenant(tenant, () =>
96
+ models.Event.create({
97
+ type,
98
+ instance_did: tenant,
99
+ api_version: 'test',
100
+ livemode: false,
101
+ object_id: 'obj_1',
102
+ object_type: 'customer',
103
+ data: { object: { id: 'obj_1' } },
104
+ request: { id: '', idempotency_key: '', requested_by: 'test' },
105
+ metadata: {},
106
+ pending_webhooks: 99,
107
+ })
108
+ );
109
+
110
+ const seedEndpoint = (tenant: string, url: string) =>
111
+ withTenant(tenant, () =>
112
+ models.WebhookEndpoint.create({
113
+ instance_did: tenant,
114
+ livemode: false,
115
+ url,
116
+ description: 'test',
117
+ status: 'enabled',
118
+ enabled_events: ['customer.updated'],
119
+ secret: 'whsec_test',
120
+ api_version: 'test',
121
+ })
122
+ );
123
+
124
+ beforeEach(async () => {
125
+ jest.clearAllMocks();
126
+ await sequelize.query('DELETE FROM events');
127
+ await sequelize.query('DELETE FROM webhook_endpoints');
128
+ await sequelize.query('DELETE FROM webhook_attempts');
129
+ });
130
+
131
+ describe('event delivery tenant isolation (phase 4)', () => {
132
+ describe('path 1 fanout (queues/event.ts): only same-tenant endpoints scheduled', () => {
133
+ it('A event fans out to A endpoint only, B endpoint untouched', async () => {
134
+ const event = await seedEvent(TENANT_A);
135
+ const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
136
+ await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
137
+
138
+ await handleEvent({ eventId: event.id });
139
+
140
+ expect(addWebhookJob).toHaveBeenCalledTimes(1);
141
+ expect(addWebhookJob).toHaveBeenCalledWith(event.id, endpointA.id, expect.anything());
142
+ });
143
+ });
144
+
145
+ describe('path 2 delivery handler (queues/webhook.ts): tenant invariant', () => {
146
+ it('forged job pairing A event with B endpoint is refused without an attempt row', async () => {
147
+ const event = await seedEvent(TENANT_A);
148
+ const endpointB = await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
149
+
150
+ await handleWebhook({ eventId: event.id, webhookId: endpointB.id });
151
+
152
+ expect(componentRequest).not.toHaveBeenCalled();
153
+ const [attempts] = await sequelize.query('SELECT COUNT(*) AS n FROM webhook_attempts');
154
+ expect((attempts as any[])[0].n).toBe(0);
155
+ expect(logger.error).toHaveBeenCalledWith(
156
+ expect.stringContaining('tenant mismatch'),
157
+ expect.objectContaining({ code: TENANT_MISMATCH })
158
+ );
159
+ });
160
+
161
+ it('same-tenant delivery succeeds and the attempt row carries the tenant', async () => {
162
+ const event = await seedEvent(TENANT_A);
163
+ const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
164
+
165
+ await handleWebhook({ eventId: event.id, webhookId: endpointA.id });
166
+
167
+ expect(componentRequest).toHaveBeenCalledTimes(1);
168
+ const [attempts] = await sequelize.query('SELECT status, instance_did FROM webhook_attempts');
169
+ expect(attempts).toEqual([{ status: 'succeeded', instance_did: TENANT_A }]);
170
+ });
171
+ });
172
+
173
+ describe('paths 3+4 manual retry routes: caller tenant guard', () => {
174
+ it('A caller cannot retry a B event (TENANT_MISMATCH -> 4xx mapping)', async () => {
175
+ await withTenant(TENANT_A, () => {
176
+ expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
177
+ expect.objectContaining({ code: TENANT_MISMATCH })
178
+ );
179
+ });
180
+ });
181
+
182
+ it('multi mode without caller context fails closed', () => {
183
+ process.env.PAYMENT_TENANT_MODE = 'multi';
184
+ try {
185
+ expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).toThrow(
186
+ expect.objectContaining({ code: TENANT_CONTEXT_MISSING })
187
+ );
188
+ } finally {
189
+ delete process.env.PAYMENT_TENANT_MODE;
190
+ }
191
+ });
192
+
193
+ it('same-tenant caller passes the guard', async () => {
194
+ await withTenant(TENANT_B, () => {
195
+ expect(() => assertEventTenantAccessible({ instance_did: TENANT_B })).not.toThrow();
196
+ });
197
+ });
198
+
199
+ it('retry fanout never schedules the other tenant endpoint (paths 3+4 data leak)', async () => {
200
+ // same shape the retry routes use after their tenant guard: endpoint
201
+ // query scoped by caller tenant -> B endpoint invisible to A
202
+ await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
203
+ await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
204
+ const visible = await withTenant(TENANT_A, () =>
205
+ models.WebhookEndpoint.findAll({
206
+ where: { status: 'enabled', livemode: false, instance_did: TENANT_A },
207
+ })
208
+ );
209
+ expect(visible).toHaveLength(1);
210
+ expect(visible[0].url).toBe('http://a.example.com/hook');
211
+ });
212
+ });
213
+
214
+ describe('path 5 pending scan (crons/retry-pending-events.ts)', () => {
215
+ it('re-enqueued events still fan out tenant-filtered (transitively via handleEvent)', async () => {
216
+ // the cron only re-enqueues IDs; prove the downstream filter holds for a
217
+ // B event when both tenants have endpoints
218
+ const event = await seedEvent(TENANT_B);
219
+ await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
220
+ const endpointB = await seedEndpoint(TENANT_B, 'http://b.example.com/hook');
221
+
222
+ await handleEvent({ eventId: event.id });
223
+
224
+ expect(addWebhookJob).toHaveBeenCalledTimes(1);
225
+ expect(addWebhookJob).toHaveBeenCalledWith(event.id, endpointB.id, expect.anything());
226
+ });
227
+ });
228
+
229
+ describe('startup recovery (startEventQueue): pending-webhook events re-enqueued under their tenant', () => {
230
+ const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
231
+ afterEach(() => {
232
+ if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
233
+ else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
234
+ });
235
+
236
+ // Regression: in multi mode startup has NO ambient tenant. The old recovery
237
+ // pushed outside withTenant (and fetched only id), so injectJobTenant ->
238
+ // getInstanceDid threw TENANT_CONTEXT_MISSING — a FATAL unhandledRejection
239
+ // that crashed the daemon. The push must run inside withTenant(event tenant).
240
+ it('multi mode: recovers a pending event INSIDE its tenant context (no crash)', async () => {
241
+ process.env.PAYMENT_TENANT_MODE = 'multi';
242
+ const event = await seedEvent(TENANT_A);
243
+
244
+ let tenantAtPush: string | undefined;
245
+ let threwAtPush = false;
246
+ (eventQueue.push as jest.Mock).mockImplementationOnce(() => {
247
+ try {
248
+ tenantAtPush = getInstanceDid(); // would THROW in multi mode if outside withTenant
249
+ } catch {
250
+ threwAtPush = true;
251
+ }
252
+ });
253
+
254
+ await expect(startEventQueue()).resolves.toBeUndefined();
255
+ expect(eventQueue.push).toHaveBeenCalledWith(
256
+ expect.objectContaining({ id: event.id, job: { eventId: event.id } })
257
+ );
258
+ expect(threwAtPush).toBe(false);
259
+ expect(tenantAtPush).toBe(TENANT_A);
260
+ });
261
+
262
+ // A row with no tenant cannot be re-stamped — skip it (warn), never crash.
263
+ it('multi mode: a tenant-less pending event is skipped, not pushed', async () => {
264
+ process.env.PAYMENT_TENANT_MODE = 'multi';
265
+ const event = await seedEvent(TENANT_A);
266
+ // null out the tenant directly (bypass the scoped writer)
267
+ await sequelize.query('UPDATE events SET instance_did = NULL WHERE id = $id', {
268
+ bind: { id: event.id },
269
+ });
270
+
271
+ await expect(startEventQueue()).resolves.toBeUndefined();
272
+ expect(eventQueue.push).not.toHaveBeenCalled();
273
+ expect(logger.warn).toHaveBeenCalledWith(
274
+ 'skip pending-webhook event with no tenant',
275
+ expect.objectContaining({ id: event.id })
276
+ );
277
+ });
278
+ });
279
+
280
+ describe('data damage: retry keeps the original tenant', () => {
281
+ it('failed delivery writes a failed attempt under the event tenant', async () => {
282
+ componentRequest.mockRejectedValueOnce(Object.assign(new Error('boom'), { response: { status: 500 } }));
283
+ const event = await seedEvent(TENANT_A);
284
+ const endpointA = await seedEndpoint(TENANT_A, 'http://a.example.com/hook');
285
+
286
+ await handleWebhook({ eventId: event.id, webhookId: endpointA.id });
287
+
288
+ const [attempts] = await sequelize.query('SELECT status, instance_did FROM webhook_attempts');
289
+ expect(attempts).toEqual([{ status: 'failed', instance_did: TENANT_A }]);
290
+ });
291
+ });
292
+ });
@@ -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
+ });