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,284 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Hono } from 'hono';
5
+ import { Sequelize } from 'sequelize';
6
+ import { SequelizeStorage, Umzug } from 'umzug';
7
+
8
+ import { withTenant } from '../../src/libs/context';
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
+ // capture the tenant the verified-event handler runs under
17
+ const handleGooglePlayEvent = jest.fn().mockImplementation(async () => {
18
+ // eslint-disable-next-line global-require
19
+ const { getInstanceDid } = require('../../src/libs/context');
20
+ handlerTenants.push(getInstanceDid());
21
+ });
22
+ const handlerTenants: string[] = [];
23
+ jest.mock('../../src/integrations/google-play/handlers', () => ({
24
+ __esModule: true,
25
+ default: (...args: any[]) => handleGooglePlayEvent(...args),
26
+ }));
27
+
28
+ const handleAppStoreNotification = jest.fn().mockImplementation(async () => {
29
+ // eslint-disable-next-line global-require
30
+ const { getInstanceDid } = require('../../src/libs/context');
31
+ appStoreTenants.push(getInstanceDid());
32
+ });
33
+ const appStoreTenants: string[] = [];
34
+ jest.mock('../../src/integrations/app-store/handlers', () => ({
35
+ __esModule: true,
36
+ default: (...args: any[]) => handleAppStoreNotification(...args),
37
+ }));
38
+ // unverified routing peek: bundleId comes straight from our fake payload
39
+ jest.mock('../../src/integrations/app-store/notification-routing', () => ({
40
+ __esModule: true,
41
+ peekNotificationRouting: (signedPayload: string) => JSON.parse(signedPayload),
42
+ }));
43
+
44
+ const STORE_DIR = path.join(__dirname, '../../src/store');
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iap-tenant-'));
46
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
47
+ const umzug = new Umzug({
48
+ migrations: {
49
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
50
+ resolve: ({ name, path: p, context }) => {
51
+ // eslint-disable-next-line import/no-dynamic-require, global-require
52
+ const migration = require(p!);
53
+ return {
54
+ name: name.replace(/\.ts$/, '.js'),
55
+ up: () => migration.up({ context }),
56
+ down: () => migration.down({ context }),
57
+ };
58
+ },
59
+ },
60
+ context: sequelize.getQueryInterface(),
61
+ storage: new SequelizeStorage({ sequelize }),
62
+ logger: undefined,
63
+ });
64
+
65
+ let models: any;
66
+ let app: Hono;
67
+
68
+ const rtdnEnvelope = (packageName: string) => ({
69
+ message: {
70
+ messageId: `msg-${Math.random().toString(36).slice(2)}`,
71
+ data: Buffer.from(
72
+ JSON.stringify({
73
+ version: '1.0',
74
+ packageName,
75
+ eventTimeMillis: String(Date.now()),
76
+ subscriptionNotification: {
77
+ version: '1.0',
78
+ notificationType: 4,
79
+ purchaseToken: 'token-x',
80
+ subscriptionId: 'sku-x',
81
+ },
82
+ })
83
+ ).toString('base64'),
84
+ },
85
+ subscription: 'projects/x/subscriptions/y',
86
+ });
87
+
88
+ const postWebhook = async (body: any): Promise<{ status: number; json: any }> => {
89
+ const res = await app.fetch(
90
+ new Request('http://app.local/api/integrations/google-play/webhook', {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify(body),
94
+ })
95
+ );
96
+ return { status: res.status, json: JSON.parse((await res.text()) || '{}') };
97
+ };
98
+
99
+ const seedMethod = (tenant: string, packageName: string) =>
100
+ withTenant(tenant, () =>
101
+ models.PaymentMethod.create({
102
+ livemode: false,
103
+ active: true,
104
+ type: 'google_play',
105
+ instance_did: tenant,
106
+ confirmation: { type: 'callback' },
107
+ features: { recurring: true, refund: true, dispute: false },
108
+ settings: models.PaymentMethod.encryptSettings({
109
+ google_play: { package_name: packageName, service_account_json: '{"client_email":"x","private_key":"y"}' },
110
+ }),
111
+ })
112
+ );
113
+
114
+ beforeAll(async () => {
115
+ await umzug.up();
116
+ // eslint-disable-next-line global-require
117
+ models = require('../../src/store/models');
118
+ models.initialize(sequelize);
119
+ // eslint-disable-next-line global-require
120
+ const googlePlay = require('../../src/routes/hono/integrations/google-play').default;
121
+ // eslint-disable-next-line global-require
122
+ const appStore = require('../../src/routes/hono/integrations/app-store').default;
123
+ // eslint-disable-next-line global-require
124
+ const { mountResourceGroup } = require('../../src/middlewares/hono/resource-mount');
125
+ // Mount exactly as production does (app-shell pipeline → sanitizedBody/livemode
126
+ // populated, csrf skipped without a cookie). Drive via app.fetch — single-mode
127
+ // test env, so contextMiddleware resolves the default tenant without a Host.
128
+ app = new Hono();
129
+ mountResourceGroup(app, '/api/integrations/google-play', googlePlay);
130
+ mountResourceGroup(app, '/api/integrations/app-store', appStore);
131
+ }, 120000);
132
+
133
+ afterAll(async () => {
134
+ await sequelize.close();
135
+ fs.rmSync(dir, { recursive: true, force: true });
136
+ });
137
+
138
+ beforeEach(async () => {
139
+ // restoreMocks=true resets spies after each test — re-install per test.
140
+ // the App Store client is exercised elsewhere; here it must only verify
141
+ jest
142
+ .spyOn(models.PaymentMethod.prototype, 'getAppStoreClient')
143
+ .mockReturnValue({ verifyNotificationPayload: async (p: string) => JSON.parse(p) } as any);
144
+ handleGooglePlayEvent.mockClear();
145
+ handleAppStoreNotification.mockClear();
146
+ handlerTenants.length = 0;
147
+ appStoreTenants.length = 0;
148
+ await sequelize.query('DELETE FROM payment_methods');
149
+ await sequelize.query('DELETE FROM prices');
150
+ await sequelize.query('DELETE FROM products');
151
+ });
152
+
153
+ const seedAppStoreMethod = (tenant: string, bundleId: string) =>
154
+ withTenant(tenant, () =>
155
+ models.PaymentMethod.create({
156
+ livemode: false,
157
+ active: true,
158
+ type: 'app_store',
159
+ instance_did: tenant,
160
+ confirmation: { type: 'callback' },
161
+ features: { recurring: true, refund: false, dispute: false },
162
+ settings: models.PaymentMethod.encryptSettings({
163
+ app_store: { bundle_id: bundleId, environment: 'production' },
164
+ }),
165
+ })
166
+ );
167
+
168
+ const postAppStoreWebhook = async (routing: {
169
+ bundleId: string;
170
+ environment?: string;
171
+ }): Promise<{ status: number; json: any }> => {
172
+ const res = await app.fetch(
173
+ new Request('http://app.local/api/integrations/app-store/webhook', {
174
+ method: 'POST',
175
+ headers: { 'content-type': 'application/json' },
176
+ body: JSON.stringify({ signedPayload: JSON.stringify(routing) }),
177
+ })
178
+ );
179
+ return { status: res.status, json: JSON.parse((await res.text()) || '{}') };
180
+ };
181
+
182
+ describe('IAP channel-identifier tenant reverse lookup (phase 6)', () => {
183
+ it('happy path: RTDN routes to the tenant that registered the package_name', async () => {
184
+ await seedMethod(TENANT_A, 'com.tenant.a');
185
+ await seedMethod(TENANT_B, 'com.tenant.b');
186
+
187
+ const res = await postWebhook(rtdnEnvelope('com.tenant.b'));
188
+ expect(res.status).toBe(200);
189
+ expect(res.json).toEqual({ received: true });
190
+ expect(handleGooglePlayEvent).toHaveBeenCalledTimes(1);
191
+ expect(handlerTenants).toEqual([TENANT_B]);
192
+ });
193
+
194
+ it('bad input: unregistered package_name is acked-skipped without touching any tenant', async () => {
195
+ await seedMethod(TENANT_A, 'com.tenant.a');
196
+ const res = await postWebhook(rtdnEnvelope('com.unknown.app'));
197
+ expect(res.status).toBe(200);
198
+ expect(res.json).toEqual({ skipped: true });
199
+ expect(handleGooglePlayEvent).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('security: ambiguous registration (same package under two tenants) is refused', async () => {
203
+ await seedMethod(TENANT_A, 'com.shared.app');
204
+ await seedMethod(TENANT_B, 'com.shared.app');
205
+ const res = await postWebhook(rtdnEnvelope('com.shared.app'));
206
+ expect(res.status).toBe(200);
207
+ expect(res.json.reason).toContain('ambiguous');
208
+ expect(handleGooglePlayEvent).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('app-store: JWS bundleId routes to the registering tenant; ambiguity refused', async () => {
212
+ await seedAppStoreMethod(TENANT_A, 'com.ios.a');
213
+ await seedAppStoreMethod(TENANT_B, 'com.ios.b');
214
+
215
+ const res = await postAppStoreWebhook({ bundleId: 'com.ios.b', environment: 'production' });
216
+ expect(res.status).toBe(200);
217
+ expect(res.json).toEqual({ received: true });
218
+ expect(appStoreTenants).toEqual([TENANT_B]);
219
+
220
+ // ambiguity: register the SAME bundle under another tenant -> refused
221
+ await seedAppStoreMethod(TENANT_A, 'com.ios.b');
222
+ const dup = await postAppStoreWebhook({ bundleId: 'com.ios.b', environment: 'production' });
223
+ expect(dup.json.reason).toContain('ambiguous');
224
+ expect(appStoreTenants).toHaveLength(1); // no second delivery
225
+ });
226
+
227
+ it('data damage: two tenants with the same SKU resolve to their own Price via bundle scoping', async () => {
228
+ const seedPrice = (tenant: string, bundleId: string, marker: string) =>
229
+ withTenant(tenant, async () => {
230
+ const product = await models.Product.create({
231
+ livemode: false,
232
+ active: true,
233
+ instance_did: tenant,
234
+ name: marker,
235
+ type: 'service',
236
+ });
237
+ return models.Price.create({
238
+ livemode: false,
239
+ active: true,
240
+ instance_did: tenant,
241
+ product_id: product.id,
242
+ type: 'recurring',
243
+ billing_scheme: 'per_unit',
244
+ unit_amount: '100',
245
+ currency_options: [],
246
+ metadata: { app_store_product_id: 'sku.shared', bundle_id: bundleId },
247
+ nickname: marker,
248
+ });
249
+ });
250
+ const priceA = await seedPrice(TENANT_A, 'com.ios.a', 'price-a');
251
+ const priceB = await seedPrice(TENANT_B, 'com.ios.b', 'price-b');
252
+
253
+ // the (sku, bundle_id) lookup used by the IAP handlers. The handler first
254
+ // reverse-resolves the tenant from the registering PaymentMethod, then runs
255
+ // the price lookup under withTenant(tenant) — TenantModel scopes it to that
256
+ // tenant's row even though both share the SKU.
257
+ const foundA: any = await withTenant(TENANT_A, () =>
258
+ models.Price.findOne({
259
+ where: { 'metadata.app_store_product_id': 'sku.shared', 'metadata.bundle_id': 'com.ios.a' } as any,
260
+ })
261
+ );
262
+ const foundB: any = await withTenant(TENANT_B, () =>
263
+ models.Price.findOne({
264
+ where: { 'metadata.app_store_product_id': 'sku.shared', 'metadata.bundle_id': 'com.ios.b' } as any,
265
+ })
266
+ );
267
+ expect(foundA.id).toBe(priceA.id);
268
+ expect(foundA.instance_did).toBe(TENANT_A);
269
+ expect(foundB.id).toBe(priceB.id);
270
+ expect(foundB.instance_did).toBe(TENANT_B);
271
+ });
272
+
273
+ it('data leak: a forged notification can only ever land in the registering tenant', async () => {
274
+ // even if an attacker controls the payload entirely, the tenant is chosen
275
+ // by the server-side registration, never by payload contents
276
+ await seedMethod(TENANT_A, 'com.tenant.a');
277
+ const res = await postWebhook({
278
+ ...rtdnEnvelope('com.tenant.a'),
279
+ attacker: { wants: TENANT_B },
280
+ });
281
+ expect(res.status).toBe(200);
282
+ expect(handlerTenants).toEqual([TENANT_A]);
283
+ });
284
+ });
@@ -124,6 +124,32 @@ describe('archive/query', () => {
124
124
  expect(mockClose).toHaveBeenCalled();
125
125
  });
126
126
 
127
+ it('洞 G: the data SELECT on a tenant table is instance_did-guarded', async () => {
128
+ const mockClose = jest.fn();
129
+ let dataSql = '';
130
+ let dataOpts: any = null;
131
+ const mockQuery = jest.fn().mockImplementation((sql: string, opts: any) => {
132
+ if (sql.includes('sqlite_master')) return [[{ name: 'invoices' }]];
133
+ dataSql = sql;
134
+ dataOpts = opts;
135
+ return [];
136
+ });
137
+ mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
138
+ mockOpenArchiveSequelize.mockReturnValue({ query: mockQuery, close: mockClose } as any);
139
+ mockArchiveMetadataFindAll.mockResolvedValue([]);
140
+
141
+ await queryArchive({
142
+ table: 'invoices',
143
+ from: Math.floor(new Date('2024-01-01').getTime() / 1000),
144
+ page: 1,
145
+ limit: 10,
146
+ });
147
+
148
+ // the archived tenant-table read carries an instance_did predicate + bind
149
+ expect(dataSql).toMatch(/instance_did/);
150
+ expect(dataOpts?.replacements?.instance_did).toBeTruthy();
151
+ });
152
+
127
153
  it('should skip archive files that do not have the requested table', async () => {
128
154
  const mockClose = jest.fn();
129
155
  // Return empty array for sqlite_master query (table doesn't exist)
@@ -0,0 +1,153 @@
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
+ const STORE_DIR = path.join(__dirname, '../../src/store');
17
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'audit-tenant-'));
18
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
19
+ const umzug = new Umzug({
20
+ migrations: {
21
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
22
+ resolve: ({ name, path: p, context }) => {
23
+ // eslint-disable-next-line import/no-dynamic-require, global-require
24
+ const migration = require(p!);
25
+ return {
26
+ name: name.replace(/\.ts$/, '.js'),
27
+ up: () => migration.up({ context }),
28
+ down: () => migration.down({ context }),
29
+ };
30
+ },
31
+ },
32
+ context: sequelize.getQueryInterface(),
33
+ storage: new SequelizeStorage({ sequelize }),
34
+ logger: undefined,
35
+ });
36
+
37
+ let audit: typeof import('../../src/libs/audit');
38
+ let logger: any;
39
+
40
+ beforeAll(async () => {
41
+ await umzug.up();
42
+ // eslint-disable-next-line global-require
43
+ const models = require('../../src/store/models');
44
+ models.initialize(sequelize);
45
+ // eslint-disable-next-line global-require
46
+ audit = require('../../src/libs/audit');
47
+ // eslint-disable-next-line global-require
48
+ logger = require('../../src/libs/logger').default;
49
+ }, 120000);
50
+
51
+ afterAll(async () => {
52
+ await sequelize.close();
53
+ fs.rmSync(dir, { recursive: true, force: true });
54
+ });
55
+
56
+ beforeEach(async () => {
57
+ await sequelize.query('DELETE FROM events');
58
+ });
59
+
60
+ const fakeModel = (tenant: string | null, extra: Record<string, any> = {}) => ({
61
+ id: 'obj_1',
62
+ livemode: false,
63
+ instance_did: tenant,
64
+ dataValues: { id: 'obj_1', instance_did: tenant, ...extra },
65
+ _previousDataValues: {},
66
+ });
67
+
68
+ describe('audit tenant resolution (phase 4)', () => {
69
+ describe('happy path', () => {
70
+ it('takes the tenant from the model row', async () => {
71
+ await audit.createEvent('Customer', 'customer.created', fakeModel(TENANT_A));
72
+ const [rows] = await sequelize.query('SELECT type, instance_did FROM events');
73
+ expect(rows).toEqual([{ type: 'customer.created', instance_did: TENANT_A }]);
74
+ });
75
+
76
+ it('falls back to the tenant context when the model has none', async () => {
77
+ await withTenant(TENANT_B, () => audit.createEvent('Customer', 'customer.created', fakeModel(null)));
78
+ const [rows] = await sequelize.query('SELECT instance_did FROM events');
79
+ expect(rows).toEqual([{ instance_did: TENANT_B }]);
80
+ });
81
+
82
+ it('createFlexibleEvent (system source) uses the tenant context', async () => {
83
+ const event: any = await withTenant(TENANT_A, () =>
84
+ audit.createFlexibleEvent('payout.created', 'payout', 'po_1', { ok: true })
85
+ );
86
+ expect(event.instance_did).toBe(TENANT_A);
87
+ });
88
+ });
89
+
90
+ describe('bad input: both sources missing', () => {
91
+ it('rejects in multi mode and writes no event row', async () => {
92
+ process.env.PAYMENT_TENANT_MODE = 'multi';
93
+ try {
94
+ await expect(audit.createEvent('Customer', 'customer.created', fakeModel(null))).rejects.toMatchObject({
95
+ code: TENANT_CONTEXT_MISSING,
96
+ });
97
+ } finally {
98
+ delete process.env.PAYMENT_TENANT_MODE;
99
+ }
100
+ const [rows] = await sequelize.query('SELECT COUNT(*) AS n FROM events');
101
+ expect((rows as any[])[0].n).toBe(0);
102
+ });
103
+ });
104
+
105
+ describe('security: contradictory sources', () => {
106
+ it('rejects when model tenant and context tenant disagree', async () => {
107
+ await withTenant(TENANT_A, async () => {
108
+ await expect(audit.createEvent('Customer', 'customer.created', fakeModel(TENANT_B))).rejects.toMatchObject({
109
+ code: TENANT_MISMATCH,
110
+ });
111
+ });
112
+ const [rows] = await sequelize.query('SELECT COUNT(*) AS n FROM events');
113
+ expect((rows as any[])[0].n).toBe(0);
114
+ });
115
+ });
116
+
117
+ describe('data loss: rejection never blocks the business write', () => {
118
+ it('reportAuditFailure is fire-and-forget with a dedicated code', () => {
119
+ const err = Object.assign(new Error('conflict'), { code: TENANT_MISMATCH });
120
+ expect(() => audit.reportAuditFailure(err)).not.toThrow();
121
+ expect(logger.error).toHaveBeenCalledWith(
122
+ '[audit] event creation failed',
123
+ expect.objectContaining({ code: TENANT_MISMATCH })
124
+ );
125
+
126
+ audit.reportAuditFailure(new Error('boom'));
127
+ expect(logger.error).toHaveBeenCalledWith(
128
+ '[audit] event creation failed',
129
+ expect.objectContaining({ code: 'EVENT_CREATE_FAILED' })
130
+ );
131
+ });
132
+ });
133
+
134
+ describe('data leak: status/custom event paths carry the tenant too', () => {
135
+ it('createStatusEvent stamps the model tenant', async () => {
136
+ const model = fakeModel(TENANT_A, { status: 'active' });
137
+ await audit.createStatusEvent('Subscription', 'customer.subscription', { active: 'activated' }, model, {
138
+ fields: ['status'],
139
+ });
140
+ const [rows] = await sequelize.query('SELECT instance_did FROM events');
141
+ expect(rows).toEqual([{ instance_did: TENANT_A }]);
142
+ });
143
+
144
+ it('createCustomEvent stamps the model tenant', async () => {
145
+ const model = fakeModel(TENANT_B, { status: 'canceled' });
146
+ await audit.createCustomEvent('Subscription', 'customer.subscription', () => 'deleted', model, {
147
+ fields: ['status'],
148
+ });
149
+ const [rows] = await sequelize.query('SELECT type, instance_did FROM events');
150
+ expect(rows).toEqual([{ type: 'customer.subscription.deleted', instance_did: TENANT_B }]);
151
+ });
152
+ });
153
+ });
@@ -0,0 +1,204 @@
1
+ import {
2
+ TENANT_CONTEXT_MISSING,
3
+ TenantError,
4
+ context,
5
+ getDefaultInstanceDid,
6
+ getInstanceDid,
7
+ getTenantMode,
8
+ withTenant,
9
+ } from '../../src/libs/context';
10
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
11
+
12
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
13
+
14
+ describe('libs/context tenant infrastructure', () => {
15
+ const originalMode = process.env.PAYMENT_TENANT_MODE;
16
+
17
+ afterEach(() => {
18
+ if (originalMode === undefined) {
19
+ delete process.env.PAYMENT_TENANT_MODE;
20
+ } else {
21
+ process.env.PAYMENT_TENANT_MODE = originalMode;
22
+ }
23
+ });
24
+
25
+ const asMulti = () => {
26
+ process.env.PAYMENT_TENANT_MODE = 'multi';
27
+ };
28
+
29
+ describe('tenant mode', () => {
30
+ it('defaults to single mode', () => {
31
+ delete process.env.PAYMENT_TENANT_MODE;
32
+ expect(getTenantMode()).toBe('single');
33
+ });
34
+
35
+ it('reads multi mode from env', () => {
36
+ asMulti();
37
+ expect(getTenantMode()).toBe('multi');
38
+ });
39
+ });
40
+
41
+ describe('happy path', () => {
42
+ it('returns the tenant inside withTenant and across await boundaries', async () => {
43
+ await withTenant(TENANT_A, async () => {
44
+ expect(getInstanceDid()).toBe(TENANT_A);
45
+ await sleep(5);
46
+ expect(getInstanceDid()).toBe(TENANT_A);
47
+ await Promise.resolve().then(() => {
48
+ expect(getInstanceDid()).toBe(TENANT_A);
49
+ });
50
+ });
51
+ });
52
+
53
+ it('exposes instanceDid via context.getContext()', async () => {
54
+ await withTenant(TENANT_A, async () => {
55
+ expect(context.getContext().instanceDid).toBe(TENANT_A);
56
+ });
57
+ });
58
+
59
+ it('returns the value produced by fn', async () => {
60
+ const result = await withTenant(TENANT_A, async () => 42);
61
+ expect(result).toBe(42);
62
+ });
63
+
64
+ it('keeps tenant when context.run is nested inside withTenant', async () => {
65
+ await withTenant(TENANT_A, () =>
66
+ context.run({ requestedBy: 'user-x' }, async () => {
67
+ expect(getInstanceDid()).toBe(TENANT_A);
68
+ expect(context.getRequestedBy()).toBe('user-x');
69
+ })
70
+ );
71
+ });
72
+
73
+ it('keeps tenant when withTenant is nested inside context.run', async () => {
74
+ await context.run({ requestedBy: 'user-x' }, () =>
75
+ withTenant(TENANT_A, async () => {
76
+ expect(getInstanceDid()).toBe(TENANT_A);
77
+ expect(context.getRequestedBy()).toBe('user-x');
78
+ })
79
+ );
80
+ });
81
+ });
82
+
83
+ describe('bad input', () => {
84
+ it.each([['' as any], [' ' as any], [null as any], [undefined as any], [123 as any], ['a b' as any]])(
85
+ 'rejects invalid instanceDid %p',
86
+ async (bad) => {
87
+ await expect(withTenant(bad, async () => 'never')).rejects.toMatchObject({
88
+ code: TENANT_CONTEXT_MISSING,
89
+ });
90
+ }
91
+ );
92
+
93
+ it('throws TENANT_CONTEXT_MISSING in multi mode without context', () => {
94
+ asMulti();
95
+ try {
96
+ getInstanceDid();
97
+ throw new Error('expected getInstanceDid to throw');
98
+ } catch (err: any) {
99
+ expect(err).toBeInstanceOf(TenantError);
100
+ expect(err.code).toBe(TENANT_CONTEXT_MISSING);
101
+ }
102
+ });
103
+ });
104
+
105
+ describe('security: concurrent isolation', () => {
106
+ it('does not leak tenant between interleaved concurrent withTenant scopes', async () => {
107
+ const observed: Record<string, string[]> = { a: [], b: [] };
108
+ await Promise.all([
109
+ withTenant(TENANT_A, async () => {
110
+ observed.a!.push(getInstanceDid());
111
+ await sleep(10);
112
+ observed.a!.push(getInstanceDid());
113
+ await sleep(20);
114
+ observed.a!.push(getInstanceDid());
115
+ }),
116
+ withTenant(TENANT_B, async () => {
117
+ observed.b!.push(getInstanceDid());
118
+ await sleep(15);
119
+ observed.b!.push(getInstanceDid());
120
+ await sleep(5);
121
+ observed.b!.push(getInstanceDid());
122
+ }),
123
+ ]);
124
+ expect(observed.a).toEqual([TENANT_A, TENANT_A, TENANT_A]);
125
+ expect(observed.b).toEqual([TENANT_B, TENANT_B, TENANT_B]);
126
+ });
127
+ });
128
+
129
+ describe('data loss: context survives async scheduling primitives', () => {
130
+ it('keeps tenant across setTimeout', async () => {
131
+ await withTenant(TENANT_A, async () => {
132
+ const seen = await new Promise((resolve) => {
133
+ setTimeout(() => resolve(getInstanceDid()), 5);
134
+ });
135
+ expect(seen).toBe(TENANT_A);
136
+ });
137
+ });
138
+
139
+ it('keeps tenant across queueMicrotask', async () => {
140
+ await withTenant(TENANT_A, async () => {
141
+ const seen = await new Promise((resolve) => {
142
+ queueMicrotask(() => resolve(getInstanceDid()));
143
+ });
144
+ expect(seen).toBe(TENANT_A);
145
+ });
146
+ });
147
+
148
+ it('keeps tenant inside event emitter callbacks registered within the scope', async () => {
149
+ // eslint-disable-next-line global-require
150
+ const { EventEmitter } = require('events');
151
+ const emitter = new EventEmitter();
152
+ await withTenant(TENANT_A, async () => {
153
+ const seen = await new Promise((resolve) => {
154
+ emitter.on('ping', () => resolve(getInstanceDid()));
155
+ emitter.emit('ping');
156
+ });
157
+ expect(seen).toBe(TENANT_A);
158
+ });
159
+ });
160
+ });
161
+
162
+ describe('data damage: nesting and immutability', () => {
163
+ it('restores outer tenant after a nested withTenant exits', async () => {
164
+ await withTenant(TENANT_A, async () => {
165
+ expect(getInstanceDid()).toBe(TENANT_A);
166
+ await withTenant(TENANT_B, async () => {
167
+ expect(getInstanceDid()).toBe(TENANT_B);
168
+ });
169
+ expect(getInstanceDid()).toBe(TENANT_A);
170
+ });
171
+ });
172
+
173
+ it('prevents fn from mutating the stored context to affect sibling calls', async () => {
174
+ await withTenant(TENANT_A, async () => {
175
+ const ctx = context.getContext() as any;
176
+ try {
177
+ ctx.instanceDid = TENANT_B;
178
+ } catch {
179
+ // frozen object throws in strict mode — either way the mutation must not stick
180
+ }
181
+ expect(getInstanceDid()).toBe(TENANT_A);
182
+ });
183
+ });
184
+ });
185
+
186
+ describe('data leak: single mode default fill', () => {
187
+ it('falls back to the configured app DID in single mode', () => {
188
+ delete process.env.PAYMENT_TENANT_MODE;
189
+ // jest globalSetup configures BLOCKLET_APP_PID with the test wallet address
190
+ expect(getInstanceDid()).toBe(getDefaultInstanceDid());
191
+ expect(getDefaultInstanceDid()).toBe(process.env.BLOCKLET_APP_PID);
192
+ expect(getDefaultInstanceDid()).not.toBe(TENANT_A);
193
+ expect(getDefaultInstanceDid()).not.toBe(TENANT_B);
194
+ });
195
+
196
+ it('never leaks a tenant injected by another scope into the default', async () => {
197
+ await withTenant(TENANT_A, async () => {
198
+ expect(getInstanceDid()).toBe(TENANT_A);
199
+ });
200
+ // outside any scope, single mode returns the app DID again
201
+ expect(getInstanceDid()).toBe(getDefaultInstanceDid());
202
+ });
203
+ });
204
+ });