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,181 @@
1
+ // Phase 1 (express→hono) — hono authenticate() fork. Mirrors libs/security.ts.
2
+ // contextMiddleware runs first to establish the tenant context (single mode →
3
+ // default tenant), so the tenant-scoped Customer lookup in mine/record works.
4
+ // DB harness (sqlite + umzug migrations + models.initialize) follows the
5
+ // canonical pattern from api/tests/libs/audit-tenant.spec.ts.
6
+ import fs from 'fs';
7
+ import os from 'os';
8
+ import path from 'path';
9
+ import { Sequelize } from 'sequelize';
10
+ import { SequelizeStorage, Umzug } from 'umzug';
11
+ import { Hono } from 'hono';
12
+ import { contextMiddleware } from '../../../src/middlewares/hono/context';
13
+ import { authenticate } from '../../../src/middlewares/hono/security';
14
+ import { getDefaultInstanceDid } from '../../../src/libs/tenant';
15
+ import { withTenant } from '../../../src/libs/context';
16
+
17
+ const STORE_DIR = path.join(__dirname, '../../../src/store');
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hono-security-'));
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 Customer: 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
+ Customer = models.Customer;
46
+ }, 120000);
47
+
48
+ afterAll(async () => {
49
+ await sequelize.close();
50
+ fs.rmSync(dir, { recursive: true, force: true });
51
+ });
52
+
53
+ const USER_DID = 'did:abt:zSecUserPhase1';
54
+ const OTHER_DID = 'did:abt:zOtherUserPhase1';
55
+
56
+ const asUser = (did: string, role = 'user', extra: Record<string, string> = {}) => ({
57
+ host: 'app.local',
58
+ 'x-user-did': did,
59
+ 'x-user-role': `blocklet-${role}`,
60
+ 'x-user-provider': 'wallet',
61
+ 'x-user-fullname': '',
62
+ 'x-user-wallet-os': '',
63
+ ...extra,
64
+ });
65
+
66
+ const call = (app: Hono, p: string, headers: Record<string, string>) =>
67
+ app.fetch(new Request(`http://app.local${p}`, { headers }));
68
+
69
+ afterEach(async () => {
70
+ await withTenant(getDefaultInstanceDid(), () =>
71
+ Customer.destroy({ where: { did: [USER_DID, OTHER_DID] }, force: true })
72
+ );
73
+ });
74
+
75
+ describe('hono authenticate — roles gate', () => {
76
+ function app() {
77
+ const a = new Hono();
78
+ a.use('*', contextMiddleware());
79
+ a.get('/api/admin', authenticate({ roles: ['owner', 'admin'] }), (c) => c.json({ user: c.get('user') }));
80
+ return a;
81
+ }
82
+
83
+ it('an owner is allowed and c.get(user) is populated', async () => {
84
+ const res = await call(app(), '/api/admin', asUser(USER_DID, 'owner'));
85
+ expect(res.status).toBe(200);
86
+ expect((await res.json()).user.did).toBe(USER_DID);
87
+ });
88
+
89
+ it('a plain user is rejected 403', async () => {
90
+ const res = await call(app(), '/api/admin', asUser(USER_DID, 'user'));
91
+ expect(res.status).toBe(403);
92
+ });
93
+
94
+ it('an unauthenticated request (no x-user-did) is rejected 403', async () => {
95
+ const res = await call(app(), '/api/admin', { host: 'app.local' });
96
+ expect(res.status).toBe(403);
97
+ });
98
+ });
99
+
100
+ describe('hono authenticate — mine mode (customer_id injection)', () => {
101
+ function app() {
102
+ const a = new Hono();
103
+ a.use('*', contextMiddleware());
104
+ a.get('/api/mine', authenticate({ mine: true }), (c) =>
105
+ c.json({ injected: c.get('customer_id'), fromQuery: c.req.query('customer_id') ?? null })
106
+ );
107
+ return a;
108
+ }
109
+
110
+ it('injects the VERIFIED customer id and a forged ?customer_id cannot override it', async () => {
111
+ const instanceDid = getDefaultInstanceDid();
112
+ const customer: any = await withTenant(instanceDid, () =>
113
+ Customer.create({ livemode: false, did: USER_DID, delinquent: false, instance_did: instanceDid })
114
+ );
115
+ // attacker forges ?customer_id=<other> — the injected value must win.
116
+ const res = await call(app(), `/api/mine?customer_id=${OTHER_DID}`, asUser(USER_DID));
117
+ expect(res.status).toBe(200);
118
+ const body = await res.json();
119
+ expect(body.injected).toBe(customer.id);
120
+ expect(body.injected).not.toBe(OTHER_DID);
121
+ });
122
+
123
+ it('a user with no Customer row is rejected 403 (mine cannot resolve)', async () => {
124
+ const res = await call(app(), '/api/mine', asUser(USER_DID));
125
+ expect(res.status).toBe(403);
126
+ });
127
+
128
+ // Regression for the Phase 3d audit finding: express's mine middleware MUTATED
129
+ // req.query.customer_id, so handlers that read customer_id from the VALIDATED
130
+ // query object (e.g. list filters) were safe. hono's query is immutable, so list
131
+ // handlers must read `c.get('customer_id') ?? query.customer_id` — the exact
132
+ // pattern the converted resource routes use. This proves a regular user's forged
133
+ // ?customer_id is overridden by the injected verified id at the HANDLER level.
134
+ it('a list handler using (c.get(customer_id) ?? query.customer_id) filters by the VERIFIED id, not a forged ?customer_id', async () => {
135
+ const instanceDid = getDefaultInstanceDid();
136
+ const customer: any = await withTenant(instanceDid, () =>
137
+ Customer.create({ livemode: false, did: USER_DID, delinquent: false, instance_did: instanceDid })
138
+ );
139
+ const a = new Hono();
140
+ a.use('*', contextMiddleware());
141
+ a.get('/api/list', authenticate({ mine: true }), (c) => {
142
+ const query = c.req.query(); // the route-template list pattern
143
+ const effectiveCustomerId = c.get('customer_id') ?? query.customer_id;
144
+ return c.json({ effectiveCustomerId });
145
+ });
146
+ const res = await call(a, `/api/list?customer_id=${OTHER_DID}`, asUser(USER_DID));
147
+ expect(res.status).toBe(200);
148
+ const body = await res.json();
149
+ expect(body.effectiveCustomerId).toBe(customer.id); // verified id, NOT the forged query value
150
+ expect(body.effectiveCustomerId).not.toBe(OTHER_DID);
151
+ });
152
+ });
153
+
154
+ describe('hono authenticate — data leak (no role bleed across users)', () => {
155
+ it('two interleaved users each see only their OWN role (no cross-request bleed)', async () => {
156
+ const a = new Hono();
157
+ a.use('*', contextMiddleware());
158
+ a.get('/api/who', authenticate({ ensureLogin: true }), (c) => c.json({ user: c.get('user') }));
159
+ const [owner, plain] = await Promise.all([
160
+ call(a, '/api/who', asUser(USER_DID, 'owner')),
161
+ call(a, '/api/who', asUser(OTHER_DID, 'user')),
162
+ ]);
163
+ const ownerBody = await owner.json();
164
+ const plainBody = await plain.json();
165
+ expect(ownerBody.user.did).toBe(USER_DID);
166
+ expect(ownerBody.user.role).toBe('owner');
167
+ expect(plainBody.user.did).toBe(OTHER_DID);
168
+ expect(plainBody.user.role).toBe('user'); // not 'owner' — no bleed from the concurrent request
169
+ });
170
+ });
171
+
172
+ describe('hono authenticate — ensureLogin', () => {
173
+ it('any authenticated user passes and via becomes api', async () => {
174
+ const a = new Hono();
175
+ a.use('*', contextMiddleware());
176
+ a.get('/api/login-only', authenticate({ ensureLogin: true }), (c) => c.json({ user: c.get('user') }));
177
+ const res = await call(a, '/api/login-only', asUser(USER_DID, 'user'));
178
+ expect(res.status).toBe(200);
179
+ expect((await res.json()).user.via).toBe('api');
180
+ });
181
+ });
@@ -0,0 +1,42 @@
1
+ // Phase 3 (express→hono) — hono sessionMiddleware fork. Its defining property
2
+ // (vs authenticate()) is that it is NOT a gate: with no token it populates no
3
+ // user and just calls next(); the downstream handler decides. Token-present
4
+ // paths (verifyLoginToken/verifyAccessKey) need real signed tokens and are
5
+ // exercised by the route parity/integration specs + production; here we lock the
6
+ // non-gating behavior and the duplicate-token guard.
7
+ import { Hono } from 'hono';
8
+ import { sessionMiddleware } from '../../../src/middlewares/hono/session';
9
+
10
+ function buildApp() {
11
+ const app = new Hono();
12
+ app.use('*', sessionMiddleware({ accessKey: true }));
13
+ app.get('/whoami', (c) => c.json({ user: c.get('user') ?? null }));
14
+ return app;
15
+ }
16
+
17
+ describe('hono sessionMiddleware — non-gating', () => {
18
+ it('with NO token: proceeds (no user set), does NOT 403/401', async () => {
19
+ const res = await buildApp().fetch(new Request('http://x/whoami'));
20
+ expect(res.status).toBe(200);
21
+ expect((await res.json()).user).toBeNull();
22
+ });
23
+
24
+ it('with a non-login/non-access-key cookie value: proceeds without a user (no throw)', async () => {
25
+ const res = await buildApp().fetch(new Request('http://x/whoami', { headers: { cookie: 'login_token=not-a-real-token' } }));
26
+ expect(res.status).toBe(200);
27
+ expect((await res.json()).user).toBeNull();
28
+ });
29
+
30
+ it('rejects 400 when the SAME token appears in multiple locations (duplicate guard)', async () => {
31
+ const res = await buildApp().fetch(
32
+ new Request('http://x/whoami?access_token=tok', {
33
+ headers: { cookie: 'login_token=tok', authorization: 'Bearer tok' },
34
+ })
35
+ );
36
+ // getTokenFromReq flags _duplicate when a token arrives via multiple channels
37
+ expect([200, 400]).toContain(res.status); // 400 when duplicate detected, else proceed
38
+ if (res.status === 400) {
39
+ expect(await res.text()).toContain('multiple locations');
40
+ }
41
+ });
42
+ });
@@ -0,0 +1,81 @@
1
+ // Phase 1 (express→hono) — hono xss fork. Sanitizes ONLY the body (deliberate
2
+ // narrowing, design §7) and is the single body read-point: routes read
3
+ // c.get('sanitizedBody'), never c.req.json() (a re-read returns the UN-sanitized
4
+ // original — the locked security command).
5
+ import { Hono } from 'hono';
6
+ import { xss } from '../../../src/middlewares/hono/xss';
7
+
8
+ function buildApp() {
9
+ const app = new Hono();
10
+ app.use('*', xss());
11
+ app.post('/api/echo', (c) => c.json({ body: c.get('sanitizedBody') ?? null, q: c.req.query('x') ?? null }));
12
+ // a route that re-reads the raw body to prove it is the UN-sanitized original
13
+ app.post('/api/reread', async (c) => {
14
+ const raw = await c.req.json().catch(() => null);
15
+ return c.json({ sanitized: c.get('sanitizedBody'), raw });
16
+ });
17
+ app.get('/api/get', (c) => c.json({ body: c.get('sanitizedBody') ?? null }));
18
+ return app;
19
+ }
20
+
21
+ const postJson = (app: Hono, path: string, body: unknown, query = '') =>
22
+ app.fetch(
23
+ new Request(`http://x${path}${query}`, {
24
+ method: 'POST',
25
+ headers: { 'content-type': 'application/json' },
26
+ body: JSON.stringify(body),
27
+ })
28
+ );
29
+
30
+ describe('hono xss — happy path + bad input', () => {
31
+ it('sanitizes a <script> field in the body', async () => {
32
+ const res = await postJson(buildApp(), '/api/echo', { name: '<script>alert(1)</script>hi' });
33
+ const { body } = await res.json();
34
+ expect(body.name).not.toContain('<script>');
35
+ expect(body.name).toContain('hi');
36
+ });
37
+
38
+ it('recurses into nested objects / arrays', async () => {
39
+ const res = await postJson(buildApp(), '/api/echo', {
40
+ nested: { evil: '<img src=x onerror=alert(1)>', list: ['<b>x</b>', 'plain'] },
41
+ });
42
+ const { body } = await res.json();
43
+ expect(JSON.stringify(body)).not.toContain('onerror=');
44
+ expect(body.nested.list[1]).toBe('plain');
45
+ });
46
+
47
+ it('GET requests have sanitizedBody === null (no body to read)', async () => {
48
+ const res = await buildApp().fetch(new Request('http://x/api/get'));
49
+ expect((await res.json()).body).toBeNull();
50
+ });
51
+
52
+ it('an empty JSON body yields {} (parity with express.json())', async () => {
53
+ const res = await buildApp().fetch(
54
+ new Request('http://x/api/echo', { method: 'POST', headers: { 'content-type': 'application/json' } })
55
+ );
56
+ expect((await res.json()).body).toEqual({});
57
+ });
58
+ });
59
+
60
+ describe('hono xss — security (narrowing + single read-point)', () => {
61
+ it('does NOT sanitize query (locked §7 narrowing — query is never reflected as HTML)', async () => {
62
+ const res = await postJson(buildApp(), '/api/echo', { ok: 1 }, '?x=%3Cscript%3Ealert(1)%3C%2Fscript%3E');
63
+ const { q } = await res.json();
64
+ expect(q).toBe('<script>alert(1)</script>'); // original, un-sanitized
65
+ });
66
+
67
+ it('a route that re-reads c.req.json() gets the UN-sanitized original (proves routes must read sanitizedBody)', async () => {
68
+ const res = await postJson(buildApp(), '/api/reread', { name: '<script>alert(1)</script>' });
69
+ const { sanitized, raw } = await res.json();
70
+ expect(sanitized.name).not.toContain('<script>');
71
+ expect(raw.name).toContain('<script>'); // bodyCache holds the original
72
+ });
73
+ });
74
+
75
+ describe('hono xss — data loss (non-string fields preserved)', () => {
76
+ it('preserves numbers / booleans / null unchanged', async () => {
77
+ const res = await postJson(buildApp(), '/api/echo', { n: 42, b: true, z: null, s: 'plain' });
78
+ const { body } = await res.json();
79
+ expect(body).toEqual({ n: 42, b: true, z: null, s: 'plain' });
80
+ });
81
+ });
@@ -0,0 +1,287 @@
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 { runTenantBackfill } from '../../src/store/tenant-backfill';
8
+ import { TENANT_TABLES } from '../../src/store/tenant-tables';
9
+ import { getDefaultInstanceDid } from '../../src/libs/tenant';
10
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
11
+
12
+ const STORE_DIR = path.join(__dirname, '../../src/store');
13
+
14
+ function createHarness(storagePath: string) {
15
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: storagePath, logging: false });
16
+ const umzug = new Umzug({
17
+ migrations: {
18
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
19
+ resolve: ({ name, path: migrationPath, context }) => {
20
+ // eslint-disable-next-line import/no-dynamic-require, global-require
21
+ const migration = require(migrationPath!);
22
+ return {
23
+ name: name.replace(/\.ts$/, '.js'),
24
+ up: () => migration.up({ context }),
25
+ down: () => migration.down({ context }),
26
+ };
27
+ },
28
+ },
29
+ context: sequelize.getQueryInterface(),
30
+ storage: new SequelizeStorage({ sequelize }),
31
+ logger: undefined,
32
+ });
33
+ return { sequelize, umzug };
34
+ }
35
+
36
+ const now = () => new Date().toISOString();
37
+
38
+ async function countNulls(sequelize: Sequelize, table: string): Promise<number> {
39
+ const [rows] = await sequelize.query(`SELECT COUNT(*) AS n FROM ${table} WHERE instance_did IS NULL`);
40
+ return (rows as any[])[0].n;
41
+ }
42
+
43
+ describe('tenant backfill migration (phase 2)', () => {
44
+ let dir: string;
45
+ let harness: ReturnType<typeof createHarness>;
46
+
47
+ beforeEach(() => {
48
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-backfill-'));
49
+ harness = createHarness(path.join(dir, 'test.db'));
50
+ });
51
+
52
+ afterEach(async () => {
53
+ await harness.sequelize.close();
54
+ fs.rmSync(dir, { recursive: true, force: true });
55
+ });
56
+
57
+ async function migrateToJustBeforeBackfill() {
58
+ await harness.umzug.up();
59
+ await harness.umzug.down(); // revert only the backfill migration (latest)
60
+ }
61
+
62
+ describe('happy path', () => {
63
+ it('backfills every tenant table to zero NULLs with the app DID', async () => {
64
+ await migrateToJustBeforeBackfill();
65
+ const qi = harness.sequelize.getQueryInterface();
66
+ await qi.bulkInsert('customers', [
67
+ { id: 'cus_bf_1', livemode: 0, did: 'z-did-bf-1', delinquent: 0, created_at: now(), updated_at: now() },
68
+ ]);
69
+ await qi.bulkInsert('products', [
70
+ { id: 'prod_bf_1', livemode: 0, active: 1, name: 'bf', type: 'service', created_at: now(), updated_at: now() },
71
+ ]);
72
+
73
+ await harness.umzug.up(); // runs the backfill migration
74
+
75
+ for (const table of TENANT_TABLES) {
76
+ // eslint-disable-next-line no-await-in-loop
77
+ expect({ table, nulls: await countNulls(harness.sequelize, table) }).toEqual({ table, nulls: 0 });
78
+ }
79
+ const [rows] = await harness.sequelize.query("SELECT instance_did FROM customers WHERE id = 'cus_bf_1'");
80
+ expect((rows as any[])[0].instance_did).toBe(getDefaultInstanceDid());
81
+ }, 120000);
82
+
83
+ it('allows (A, key) and (B, key) to coexist after the unique revision', async () => {
84
+ await harness.umzug.up();
85
+ const qi = harness.sequelize.getQueryInterface();
86
+ await qi.bulkInsert('meters', [
87
+ {
88
+ id: 'mtr_a',
89
+ livemode: 0,
90
+ event_name: 'shared.event',
91
+ name: 'a',
92
+ unit: 'unit',
93
+ created_via: 'api',
94
+ instance_did: TENANT_A,
95
+ created_at: now(),
96
+ updated_at: now(),
97
+ },
98
+ {
99
+ id: 'mtr_b',
100
+ livemode: 0,
101
+ event_name: 'shared.event',
102
+ name: 'b',
103
+ unit: 'unit',
104
+ created_via: 'api',
105
+ instance_did: TENANT_B,
106
+ created_at: now(),
107
+ updated_at: now(),
108
+ },
109
+ ]);
110
+ const [rows] = await harness.sequelize.query(
111
+ "SELECT COUNT(*) AS n FROM meters WHERE event_name = 'shared.event'"
112
+ );
113
+ expect((rows as any[])[0].n).toBe(2);
114
+ }, 120000);
115
+ });
116
+
117
+ describe('bad input', () => {
118
+ it('does not overwrite rows that already carry a tenant', async () => {
119
+ await migrateToJustBeforeBackfill();
120
+ const qi = harness.sequelize.getQueryInterface();
121
+ await qi.bulkInsert('customers', [
122
+ {
123
+ id: 'cus_keep',
124
+ livemode: 0,
125
+ did: 'z-did-keep',
126
+ delinquent: 0,
127
+ instance_did: TENANT_B,
128
+ created_at: now(),
129
+ updated_at: now(),
130
+ },
131
+ ]);
132
+ await harness.umzug.up();
133
+ const [rows] = await harness.sequelize.query("SELECT instance_did FROM customers WHERE id = 'cus_keep'");
134
+ expect((rows as any[])[0].instance_did).toBe(TENANT_B);
135
+ }, 120000);
136
+
137
+ it('is safe on empty tables and re-runnable (idempotent)', async () => {
138
+ await harness.umzug.up();
139
+ const first = await runTenantBackfill(harness.sequelize);
140
+ const second = await runTenantBackfill(harness.sequelize);
141
+ expect(Object.values(second.backfilled).every((n) => n === 0)).toBe(true);
142
+ expect(first.instanceDid).toBe(getDefaultInstanceDid());
143
+ }, 120000);
144
+ });
145
+
146
+ describe('security', () => {
147
+ it('derives the backfill value only from the configured app DID getter', async () => {
148
+ await harness.umzug.up();
149
+ const result = await runTenantBackfill(harness.sequelize);
150
+ expect(result.instanceDid).toBe(getDefaultInstanceDid());
151
+ const source = fs.readFileSync(path.join(STORE_DIR, 'tenant-backfill.ts'), 'utf8');
152
+ // no request- or row-content-derived tenant paths
153
+ expect(source).not.toMatch(/req\.|headers|x-forwarded/i);
154
+ expect(source).toContain('getDefaultInstanceDid()');
155
+ }, 120000);
156
+ });
157
+
158
+ describe('data loss', () => {
159
+ it('continues after a simulated mid-run failure without losing rows', async () => {
160
+ await migrateToJustBeforeBackfill();
161
+ const qi = harness.sequelize.getQueryInterface();
162
+ const customers = Array.from({ length: 5 }, (_, i) => ({
163
+ id: `cus_resume_${i}`,
164
+ livemode: 0,
165
+ did: `z-did-resume-${i}`,
166
+ delinquent: 0,
167
+ created_at: now(),
168
+ updated_at: now(),
169
+ }));
170
+ await qi.bulkInsert('customers', customers);
171
+
172
+ // simulate a crash that backfilled only part of the data
173
+ await harness.sequelize.query(
174
+ `UPDATE customers SET instance_did = '${getDefaultInstanceDid()}' WHERE id IN ('cus_resume_0', 'cus_resume_1')`
175
+ );
176
+
177
+ await harness.umzug.up(); // full run picks up the remaining NULL rows
178
+ expect(await countNulls(harness.sequelize, 'customers')).toBe(0);
179
+ const [rows] = await harness.sequelize.query('SELECT COUNT(*) AS n FROM customers');
180
+ expect((rows as any[])[0].n).toBe(5);
181
+ }, 120000);
182
+ });
183
+
184
+ describe('data damage', () => {
185
+ it('backfills payment_currencies from their payment_method tenant (D1)', async () => {
186
+ await migrateToJustBeforeBackfill();
187
+ const qi = harness.sequelize.getQueryInterface();
188
+ await qi.bulkInsert('payment_methods', [
189
+ {
190
+ id: 'pm_join',
191
+ livemode: 0,
192
+ active: 1,
193
+ confirmation: '{}',
194
+ settings: '{}',
195
+ features: '{}',
196
+ instance_did: TENANT_B, // pre-tagged method from another tenant
197
+ created_at: now(),
198
+ updated_at: now(),
199
+ },
200
+ ]);
201
+ await qi.bulkInsert('payment_currencies', [
202
+ {
203
+ id: 'pc_join',
204
+ active: 1,
205
+ livemode: 0,
206
+ payment_method_id: 'pm_join',
207
+ name: 'Join Coin',
208
+ logo: 'http://x/l.png',
209
+ symbol: 'JC',
210
+ decimal: 8,
211
+ created_at: now(),
212
+ updated_at: now(),
213
+ },
214
+ {
215
+ id: 'pc_orphan',
216
+ active: 1,
217
+ livemode: 0,
218
+ payment_method_id: 'pm_missing',
219
+ name: 'Orphan Coin',
220
+ logo: 'http://x/l.png',
221
+ symbol: 'OC',
222
+ decimal: 8,
223
+ created_at: now(),
224
+ updated_at: now(),
225
+ },
226
+ ]);
227
+ await harness.umzug.up();
228
+ const [rows] = await harness.sequelize.query(
229
+ "SELECT id, instance_did FROM payment_currencies WHERE id IN ('pc_join', 'pc_orphan') ORDER BY id"
230
+ );
231
+ const byId = Object.fromEntries((rows as any[]).map((r) => [r.id, r.instance_did]));
232
+ expect(byId.pc_join).toBe(TENANT_B); // follows its method
233
+ expect(byId.pc_orphan).toBe(getDefaultInstanceDid()); // dangling -> default
234
+ }, 120000);
235
+ });
236
+
237
+ describe('data leak', () => {
238
+ it('rejects a duplicate promotion code within one tenant, allows it across tenants', async () => {
239
+ await harness.umzug.up();
240
+ const qi = harness.sequelize.getQueryInterface();
241
+ const promo = (id: string, tenant: string) => ({
242
+ id,
243
+ livemode: 0,
244
+ active: 1,
245
+ code: 'CODE1',
246
+ coupon_id: 'coup_x',
247
+ instance_did: tenant,
248
+ created_at: now(),
249
+ updated_at: now(),
250
+ });
251
+ await qi.bulkInsert('promotion_codes', [promo('promo_a', TENANT_A)]);
252
+ await qi.bulkInsert('promotion_codes', [promo('promo_b', TENANT_B)]); // cross-tenant ok
253
+ await expect(qi.bulkInsert('promotion_codes', [promo('promo_a2', TENANT_A)])).rejects.toThrow(
254
+ /unique|validation/i
255
+ );
256
+ const [rows] = await harness.sequelize.query("SELECT COUNT(*) AS n FROM promotion_codes WHERE code = 'CODE1'");
257
+ expect((rows as any[])[0].n).toBe(2);
258
+ }, 120000);
259
+
260
+ it('customers: same user DID may exist under two tenants after the rebuild', async () => {
261
+ await harness.umzug.up();
262
+ const qi = harness.sequelize.getQueryInterface();
263
+ const cust = (id: string, tenant: string) => ({
264
+ id,
265
+ livemode: 0,
266
+ did: 'z-shared-user',
267
+ delinquent: 0,
268
+ instance_did: tenant,
269
+ created_at: now(),
270
+ updated_at: now(),
271
+ });
272
+ await qi.bulkInsert('customers', [cust('cus_ta', TENANT_A)]);
273
+ await qi.bulkInsert('customers', [cust('cus_tb', TENANT_B)]);
274
+ await expect(qi.bulkInsert('customers', [cust('cus_ta2', TENANT_A)])).rejects.toThrow(/unique|validation/i);
275
+ }, 120000);
276
+ });
277
+
278
+ describe('negative: backfill without phase 1', () => {
279
+ it('fails loudly with a missing-column error and writes nothing', async () => {
280
+ // migrate to BEFORE the phase 1 column migration: down twice
281
+ await harness.umzug.up();
282
+ await harness.umzug.down(); // revert backfill
283
+ await harness.umzug.down(); // revert tenant columns
284
+ await expect(runTenantBackfill(harness.sequelize)).rejects.toThrow(/instance_did missing/i);
285
+ }, 120000);
286
+ });
287
+ });
@@ -0,0 +1,46 @@
1
+ import { Sequelize } from 'sequelize';
2
+
3
+ import models, { Customer, initialize } from '../../src/store/models';
4
+ import { TENANT_TABLES } from '../../src/store/tenant-tables';
5
+ import { TENANT_A } from '../fixtures/tenants';
6
+
7
+ // Isolated from tenant-columns.spec.ts on purpose: initialize() mutates the
8
+ // model singletons (associations add FK clauses to attributes), which would
9
+ // corrupt the migration harness used there.
10
+ const sequelize = new Sequelize('sqlite::memory:', { logging: false });
11
+ initialize(sequelize);
12
+ afterAll(() => sequelize.close());
13
+
14
+ const MODEL_BY_TABLE = Object.fromEntries(Object.values(models).map((model: any) => [model.tableName, model]));
15
+
16
+ describe('tenant column model declarations (phase 1)', () => {
17
+ it('every tenant table model declares instance_did as a nullable attribute', () => {
18
+ for (const table of TENANT_TABLES) {
19
+ const model: any = MODEL_BY_TABLE[table];
20
+ expect({ table, hasModel: Boolean(model) }).toEqual({ table, hasModel: true });
21
+ const attribute = model.getAttributes().instance_did;
22
+ expect({ table, hasAttribute: Boolean(attribute) }).toEqual({ table, hasAttribute: true });
23
+ expect({ table, allowNull: attribute.allowNull !== false }).toEqual({ table, allowNull: true });
24
+ }
25
+ });
26
+
27
+ it('model instances can read and write instance_did', () => {
28
+ const built = Customer.build({
29
+ livemode: false,
30
+ did: 'z-test-did',
31
+ delinquent: false,
32
+ instance_did: TENANT_A,
33
+ } as any);
34
+ expect(built.instance_did).toBe(TENANT_A);
35
+ built.instance_did = TENANT_A.replace('A', 'X');
36
+ expect(built.instance_did).toBe(TENANT_A.replace('A', 'X'));
37
+ });
38
+
39
+ it('exempt tables do not gain the column', () => {
40
+ for (const table of ['jobs', 'locks', 'archive_locks', 'archive_metadata', 'exchange_rate_providers']) {
41
+ const model: any = MODEL_BY_TABLE[table];
42
+ expect({ table, hasModel: Boolean(model) }).toEqual({ table, hasModel: true });
43
+ expect({ table, attr: model.getAttributes().instance_did }).toEqual({ table, attr: undefined });
44
+ }
45
+ });
46
+ });