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,79 @@
1
+ // TM-4 (frontend-serve P4) — MANDATORY fail-closed regression lock.
2
+ //
3
+ // Project principle #1: in multi-tenant mode a request with no resolvable
4
+ // Host/tenant context MUST fail closed (4xx), never fall back to APP_PID/default
5
+ // tenant. P4 wires a host static/SPA handler onto the full node hono app (after
6
+ // the api/connect routes). This lock proves that slot did NOT weaken the API
7
+ // gate: with createNodeStaticHandler attached, an /api/* request from an unbound
8
+ // host still 400s with TENANT_HOST_UNRESOLVED. Runs in did-pay (no arc dev
9
+ // runtime) — the same buildHonoApp + contextMiddleware the production svc uses.
10
+ import fs from 'fs';
11
+ import os from 'os';
12
+ import path from 'path';
13
+
14
+ import { Hono } from 'hono';
15
+
16
+ import { getInstanceDid } from '../../src/libs/context';
17
+ import { createDefaultIdentityDriver, type IdentityDriver, setIdentityDriver } from '../../src/libs/drivers/identity';
18
+ import { contextMiddleware, ensureI18n } from '../../src/middlewares/hono/context';
19
+ import { createNodeStaticHandler } from '../../src/host-node/serve-static-arc';
20
+ import { buildHonoApp } from '../../src/service';
21
+
22
+ const BOUND = 'did:abt:zBOUNDTENANTAAAAAAAAAAAAAAAAAA';
23
+ const identity: IdentityDriver = {
24
+ resolveInstanceDidForHost: (host) => (host === 'bound.example' ? BOUND : null),
25
+ };
26
+
27
+ let webRoot: string;
28
+ beforeAll(() => {
29
+ webRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tm4-web-'));
30
+ fs.writeFileSync(path.join(webRoot, 'index.html'), '<!doctype html><div id="app"></div>');
31
+ });
32
+ afterAll(() => fs.rmSync(webRoot, { recursive: true, force: true }));
33
+ afterEach(() => {
34
+ delete process.env.PAYMENT_TENANT_MODE;
35
+ setIdentityDriver(createDefaultIdentityDriver());
36
+ });
37
+
38
+ // the production wiring: native pipeline (with the Host→tenant contextMiddleware)
39
+ // + the host static/SPA slot attached last (exactly buildHonoApp's 3rd arg).
40
+ function buildAppWithStatic(): Hono {
41
+ return buildHonoApp(
42
+ (native) => {
43
+ native.use('*', ensureI18n());
44
+ native.use('*', contextMiddleware());
45
+ native.get('/api/settings', (c) => c.json({ tenant: getInstanceDid() }));
46
+ },
47
+ undefined,
48
+ createNodeStaticHandler(webRoot)
49
+ );
50
+ }
51
+
52
+ describe('TM-4 — static slot must not weaken the multi-tenant API gate (P4)', () => {
53
+ it('multi mode, UNBOUND host: /api/settings → 400 fail-closed (no default-tenant fallback)', async () => {
54
+ process.env.PAYMENT_TENANT_MODE = 'multi';
55
+ setIdentityDriver(identity);
56
+ const app = buildAppWithStatic();
57
+ const res = await app.fetch(new Request('http://unbound.example/api/settings', { headers: { host: 'unbound.example' } }));
58
+ expect(res.status).toBe(400);
59
+ expect((await res.json()).error.code).toBe('TENANT_HOST_UNRESOLVED');
60
+ });
61
+
62
+ it('multi mode, BOUND host: /api/settings resolves its tenant (gate passes, slot present)', async () => {
63
+ process.env.PAYMENT_TENANT_MODE = 'multi';
64
+ setIdentityDriver(identity);
65
+ const app = buildAppWithStatic();
66
+ const res = await app.fetch(new Request('http://bound.example/api/settings', { headers: { host: 'bound.example' } }));
67
+ expect(res.status).toBe(200);
68
+ expect((await res.json()).tenant).toBe(BOUND);
69
+ });
70
+
71
+ it('the static slot still serves the SPA shell (html GET) — coexists with the gate', async () => {
72
+ process.env.PAYMENT_TENANT_MODE = 'multi';
73
+ setIdentityDriver(identity);
74
+ const app = buildAppWithStatic();
75
+ const res = await app.fetch(new Request('http://bound.example/admin', { headers: { host: 'bound.example', accept: 'text/html' } }));
76
+ expect(res.status).toBe(200);
77
+ expect(await res.text()).toContain('<div id="app">');
78
+ });
79
+ });
@@ -0,0 +1,101 @@
1
+ // P3b (README D3 / F3) — arc-node clean static/SPA handler.
2
+ //
3
+ // Drives createNodeStaticHandler over a real temp webRoot through buildHonoApp's
4
+ // staticHandler slot (the same wiring arc uses), via in-process app.fetch.
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import path from 'path';
8
+
9
+ import { buildHonoApp } from '../../src/service';
10
+ import { createNodeStaticHandler } from '../../src/host-node/serve-static-arc';
11
+
12
+ let webRoot: string;
13
+
14
+ beforeAll(() => {
15
+ webRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'arc-webroot-'));
16
+ fs.mkdirSync(path.join(webRoot, 'assets'), { recursive: true });
17
+ // index.html with a build-time bootstrap already baked in (P2) — the handler
18
+ // must serve this VERBATIM, no server-side re-injection.
19
+ fs.writeFileSync(
20
+ path.join(webRoot, 'index.html'),
21
+ '<!doctype html><html><head><script>window.blocklet={prefix:"/.well-known/payment"}</script></head><body><div id="app"></div></body></html>'
22
+ );
23
+ fs.writeFileSync(path.join(webRoot, 'assets', 'app.js'), 'console.log("asset")');
24
+ });
25
+
26
+ afterAll(() => {
27
+ fs.rmSync(webRoot, { recursive: true, force: true });
28
+ });
29
+
30
+ const appWith = () => buildHonoApp(undefined, undefined, createNodeStaticHandler(webRoot));
31
+
32
+ describe('P3b — createNodeStaticHandler', () => {
33
+ it('bad-input: throws at construction when webRoot has no index.html', () => {
34
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'arc-empty-'));
35
+ expect(() => createNodeStaticHandler(empty)).toThrow(/no index\.html/);
36
+ fs.rmSync(empty, { recursive: true, force: true });
37
+ });
38
+
39
+ it('happy: root + deep link return index.html, asset hits the file', async () => {
40
+ const app = appWith();
41
+ const root = await app.fetch(new Request('http://h/', { headers: { accept: 'text/html' } }));
42
+ expect(root.status).toBe(200);
43
+ expect(await root.text()).toContain('<div id="app">');
44
+
45
+ const deep = await app.fetch(new Request('http://h/admin', { headers: { accept: 'text/html' } }));
46
+ expect(deep.status).toBe(200);
47
+ expect(await deep.text()).toContain('<!doctype html');
48
+
49
+ const asset = await app.fetch(new Request('http://h/assets/app.js'));
50
+ expect(asset.status).toBe(200);
51
+ expect(await asset.text()).toContain('console.log("asset")');
52
+ });
53
+
54
+ it('bad-input: non-GET / non-html accept does not return index.html', async () => {
55
+ const app = appWith();
56
+ const post = await app.fetch(new Request('http://h/admin', { method: 'POST', headers: { accept: 'text/html' } }));
57
+ expect(post.status).not.toBe(200);
58
+ const json = await app.fetch(new Request('http://h/admin', { headers: { accept: 'application/json' } }));
59
+ expect(await json.text()).not.toContain('<!doctype html');
60
+ });
61
+
62
+ it('security: asset miss → 404, NOT index.html (RESOURCE_PATTERN, T3b.2)', async () => {
63
+ const app = appWith();
64
+ const miss = await app.fetch(new Request('http://h/assets/nope.js'));
65
+ expect(miss.status).toBe(404);
66
+ expect(await miss.text()).not.toContain('<!doctype html');
67
+ });
68
+
69
+ it('security: path traversal never leaks out-of-webRoot files (asset-typed → 404; SPA-typed → index.html, never passwd)', async () => {
70
+ const app = appWith();
71
+ const indexHtml = fs.readFileSync(path.join(webRoot, 'index.html'), 'utf8');
72
+ // asset-typed traversal (serveStatic must reject, never serve outside webRoot)
73
+ for (const p of ['/assets/..%2f..%2f..%2f..%2fetc%2fpasswd.js', '/assets/../../../../etc/hosts.css']) {
74
+ const res = await app.fetch(new Request(`http://h${p}`));
75
+ const body = await res.text();
76
+ expect(body).not.toMatch(/root:.*:0:0:/); // no /etc/passwd content
77
+ expect(res.status).toBe(404); // miss, not a leaked file
78
+ }
79
+ // SPA-typed traversal (no asset extension) → safe app shell, NOT a host file
80
+ const spa = await app.fetch(new Request('http://h/../../../../etc/passwd', { headers: { accept: 'text/html' } }));
81
+ const spaBody = await spa.text();
82
+ expect(spaBody).not.toMatch(/root:.*:0:0:/);
83
+ expect(spaBody === indexHtml || spa.status === 404).toBe(true);
84
+ });
85
+
86
+ it('data-loss: /api/* is NOT swallowed by the static handler (wired last)', async () => {
87
+ const app = appWith();
88
+ const res = await app.fetch(new Request('http://h/api/healthz'));
89
+ expect(res.status).toBe(200);
90
+ expect(await res.json()).toEqual({ ok: true });
91
+ });
92
+
93
+ it('data-leak: index.html is served VERBATIM — exactly one window.blocklet (no server-side re-inject, TM-3)', async () => {
94
+ const app = appWith();
95
+ const res = await app.fetch(new Request('http://h/dashboard', { headers: { accept: 'text/html' } }));
96
+ const html = await res.text();
97
+ expect((html.match(/window\.blocklet\s*=/g) || []).length).toBe(1);
98
+ // byte-identical to the build artifact
99
+ expect(html).toBe(fs.readFileSync(path.join(webRoot, 'index.html'), 'utf8'));
100
+ });
101
+ });
@@ -0,0 +1,48 @@
1
+ // S3-CF Phase 1 inversion ① — static/SPA shell externalized from buildHonoApp.
2
+ //
3
+ // The runtime-neutral hono app must NOT wire node-only static serving
4
+ // (@hono/node-server/serve-static + the node:fs fallback) itself — that is what
5
+ // kept `http.fetch` node-bound and forced the CF worker onto a second surface.
6
+ // Instead the HOST injects a `staticHandler` (node blocklet-server replicates
7
+ // today's serving; the CF/standalone worker serves assets via env.ASSETS and
8
+ // injects nothing). This spec locks the delegation.
9
+ import { Hono } from 'hono';
10
+ import { buildHonoApp } from '../../src/service';
11
+
12
+ describe('Phase 1 (S3-CF) — static/SPA shell externalized from buildHonoApp', () => {
13
+ it('serves no static itself — an unmatched html GET 404s when no staticHandler is given', async () => {
14
+ const app = buildHonoApp();
15
+ const res = await app.fetch(
16
+ new Request('http://app.local/some/spa/deeplink', { headers: { accept: 'text/html' } })
17
+ );
18
+ // No SPA fallback is wired into the neutral app; the host owns it.
19
+ expect(res.status).toBe(404);
20
+ });
21
+
22
+ it('invokes the host-provided staticHandler exactly once with the app, after the api/connect routes', async () => {
23
+ let called = 0;
24
+ let received: unknown;
25
+ const app = buildHonoApp(undefined, undefined, (a: Hono) => {
26
+ called += 1;
27
+ received = a;
28
+ a.get('/__host_static_probe', (c) => c.text('served-by-host'));
29
+ });
30
+
31
+ expect(called).toBe(1);
32
+ expect(received).toBe(app);
33
+
34
+ const res = await app.fetch(new Request('http://app.local/__host_static_probe'));
35
+ expect(res.status).toBe(200);
36
+ expect(await res.text()).toBe('served-by-host');
37
+ });
38
+
39
+ it('keeps /api/healthz reachable even with a host staticHandler wired last', async () => {
40
+ const app = buildHonoApp(undefined, undefined, (a: Hono) => {
41
+ a.use('*', async (c) => c.text('static-catchall'));
42
+ });
43
+ const res = await app.fetch(new Request('http://app.local/api/healthz'));
44
+ // healthz is registered before the host static catch-all, so it still wins.
45
+ expect(res.status).toBe(200);
46
+ expect(await res.json()).toEqual({ ok: true });
47
+ });
48
+ });
@@ -0,0 +1,202 @@
1
+ // Phase 6 (W1′) — cross-cutting tenant isolation CI (two engines, blocking).
2
+ //
3
+ // Not per-route: assert the SAME read-isolation invariant over the WHOLE tenant
4
+ // model surface generically, on BOTH engines:
5
+ // - real Sequelize (Node): every one of the 38 tenant models — a bare
6
+ // findAll returns only the active tenant's rows, and a cross-tenant
7
+ // findByPk returns null (§13.1). Rows are seeded with a raw INSERT (driven
8
+ // by PRAGMA table_info) so the crosscut is model-agnostic — it exercises
9
+ // the scoping, not each model's business validators/hooks.
10
+ // - sequelize-d1 shim (worker): the same invariant on the shim base.
11
+ // - index hit: a WHERE instance_did = ? equality predicate uses the
12
+ // idx_<table>_instance_did index (SEARCH), not a full SCAN.
13
+ import { DatabaseSync } from 'node:sqlite';
14
+ import { DataTypes, Sequelize } from 'sequelize';
15
+
16
+ import { getDefaultInstanceDid, withTenant } from '../../src/libs/context';
17
+ import { makeTenantModel } from '../../src/store/tenant-model';
18
+ import realModels, { Coupon, initialize } from '../../src/store/models';
19
+ import { TENANT_TABLES } from '../../src/store/tenant-tables';
20
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
21
+
22
+ const TENANT_TABLE_SET = new Set(TENANT_TABLES);
23
+ const sequelize = new Sequelize('sqlite::memory:', { logging: false });
24
+ initialize(sequelize);
25
+
26
+ beforeAll(async () => {
27
+ await sequelize.sync({ force: true });
28
+ // raw seeding uses dummy FK values; this test exercises tenant scoping, not
29
+ // referential integrity, so disable FK enforcement for the inserts.
30
+ await sequelize.query('PRAGMA foreign_keys = OFF');
31
+ });
32
+ afterAll(() => sequelize.close());
33
+
34
+ function dummyForSqlType(type: string, seed: string): string | number {
35
+ const t = (type || '').toUpperCase();
36
+ if (/INT/.test(t)) return 0;
37
+ if (/REAL|FLOA|DOUB|DEC|NUM/.test(t)) return 0;
38
+ if (/BOOL|TINYINT/.test(t)) return 0;
39
+ if (/DATE|TIME/.test(t)) return '2024-01-01 00:00:00.000 +00:00';
40
+ if (/JSON/.test(t)) return '{}';
41
+ // unique per row+column so a standalone UNIQUE column can't collide across the
42
+ // two tenant rows we insert.
43
+ return seed;
44
+ }
45
+
46
+ // raw-insert one row for `tenant` into `table`. Returns the value of the
47
+ // single-column `id` PK (null when the table has a composite PK — those skip
48
+ // the findByPk assertion and rely on findAll isolation).
49
+ async function seedRaw(table: string, tenant: string, idVal: string): Promise<string | null> {
50
+ const cols = (await sequelize.query(`PRAGMA table_info("${table}")`, {
51
+ type: (DataTypes as any).SELECT ?? 'SELECT',
52
+ })) as any[];
53
+ const pkCols = cols.filter((c) => c.pk).map((c) => c.name);
54
+ const values: Record<string, any> = { instance_did: tenant };
55
+ for (const c of cols) {
56
+ if (c.name === 'instance_did') continue;
57
+ const required = c.notnull === 1 && c.dflt_value === null;
58
+ if (c.pk) values[c.name] = idVal;
59
+ else if (required) values[c.name] = dummyForSqlType(c.type, `${idVal}-${c.name}`);
60
+ }
61
+ const names = Object.keys(values);
62
+ await sequelize.query(
63
+ `INSERT INTO "${table}" (${names.map((n) => `"${n}"`).join(',')}) VALUES (${names.map((n) => `:${n}`).join(',')})`,
64
+ { replacements: values }
65
+ );
66
+ return pkCols.length === 1 && pkCols[0] === 'id' ? idVal : null;
67
+ }
68
+
69
+ const tenantModels = Object.values<any>(realModels).filter((m) => TENANT_TABLE_SET.has(m.tableName));
70
+
71
+ describe('cross-cut read isolation over all tenant models (real Sequelize)', () => {
72
+ it('covers all 38 tenant tables', () => {
73
+ expect(tenantModels.length).toBe(38);
74
+ });
75
+
76
+ it.each(tenantModels.map((m) => [m.tableName, m]))(
77
+ '%s: bare findAll returns only active tenant rows; cross-tenant findByPk -> null',
78
+ async (table: string, Model: any) => {
79
+ const idA = await seedRaw(table, TENANT_A, `a-${table}`);
80
+ const idB = await seedRaw(table, TENANT_B, `b-${table}`);
81
+
82
+ // bare findAll under A returns ONLY A's rows — no B row leaks
83
+ const aRows: any[] = await withTenant(TENANT_A, () => Model.findAll());
84
+ expect(aRows.length).toBeGreaterThan(0);
85
+ expect(aRows.every((r) => r.instance_did === TENANT_A)).toBe(true);
86
+
87
+ // cross-tenant findByPk -> null (not-found, §13.1); same-tenant works.
88
+ // composite-PK tables (idA/idB null) rely on the findAll isolation above.
89
+ if (idA && idB) {
90
+ expect(await withTenant(TENANT_A, () => Model.findByPk(idB))).toBeNull();
91
+ expect(await withTenant(TENANT_A, () => Model.findByPk(idA))).not.toBeNull();
92
+ }
93
+ }
94
+ );
95
+ });
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Second engine: the sequelize-d1 shim (worker runtime) over a node:sqlite fake
99
+ // D1 — the SAME makeTenantModel mechanism on the OTHER base class.
100
+ // ---------------------------------------------------------------------------
101
+ function makeFakeD1(db: DatabaseSync) {
102
+ const prepare = (sql: string) => {
103
+ let bound: any[] = [];
104
+ const stmt: any = {
105
+ __sql: sql,
106
+ bind: (...vals: any[]) => {
107
+ bound = vals;
108
+ return stmt;
109
+ },
110
+ all: () => ({ results: db.prepare(sql).all(...bound), meta: {} }),
111
+ run: () => {
112
+ const r = db.prepare(sql).run(...bound);
113
+ return { meta: { changes: Number(r.changes), last_row_id: Number(r.lastInsertRowid) } };
114
+ },
115
+ first: () => db.prepare(sql).get(...bound) ?? null,
116
+ };
117
+ return stmt;
118
+ };
119
+ return {
120
+ prepare,
121
+ batch: async (stmts: any[]) =>
122
+ stmts.map((s) => {
123
+ if (/^\s*SELECT/i.test(s.__sql)) return s.all();
124
+ s.run();
125
+ return { results: [], meta: {} };
126
+ }),
127
+ };
128
+ }
129
+
130
+ describe('cross-cut read isolation on the sequelize-d1 shim engine', () => {
131
+ let ShimWidget: any;
132
+ const TABLE = 'coupons'; // a real tenant table so isTenantTable() is true and the shim scopes
133
+
134
+ beforeAll(() => {
135
+ // eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires
136
+ const shim = require('../../../cloudflare/shims/sequelize-d1');
137
+ const db = new DatabaseSync(':memory:');
138
+ db.exec(`CREATE TABLE ${TABLE} (id TEXT PRIMARY KEY, instance_did TEXT, name TEXT)`);
139
+ shim.setDB(makeFakeD1(db));
140
+ ShimWidget = class extends makeTenantModel(shim.Model) {};
141
+ ShimWidget.init(
142
+ { id: {}, instance_did: {}, name: {} },
143
+ { sequelize: { models: {} }, modelName: 'CrosscutWidget', tableName: TABLE }
144
+ );
145
+ });
146
+
147
+ it('shim: bare findAll returns only active tenant; cross-tenant findByPk -> null', async () => {
148
+ await withTenant(TENANT_A, () => ShimWidget.create({ id: 'a1', name: 'a' }));
149
+ await withTenant(TENANT_B, () => ShimWidget.create({ id: 'b1', name: 'b' }));
150
+
151
+ const aRows: any[] = await withTenant(TENANT_A, () => ShimWidget.findAll());
152
+ expect(aRows.map((r) => r.id)).toEqual(['a1']);
153
+ expect(aRows.every((r) => r.instance_did === TENANT_A)).toBe(true);
154
+
155
+ expect(await withTenant(TENANT_A, () => ShimWidget.findByPk('b1'))).toBeNull();
156
+ expect(await withTenant(TENANT_A, () => ShimWidget.findByPk('a1'))).not.toBeNull();
157
+ });
158
+ });
159
+
160
+ describe('single mode is NOT a passthrough — it injects instance_did = APP_PID', () => {
161
+ // The single-tenant blocklet-server safety rope: single mode does not "let
162
+ // bare queries through because there is only one tenant" — it scopes every
163
+ // query to the default tenant (BLOCKLET_APP_PID). A row stamped with a
164
+ // DIFFERENT instance_did must NOT be visible in single mode.
165
+ it('single-mode bare findAll only returns default-tenant rows, not a foreign-stamped row', async () => {
166
+ const savedMode = process.env.PAYMENT_TENANT_MODE;
167
+ process.env.PAYMENT_TENANT_MODE = 'single';
168
+ try {
169
+ const appPid = getDefaultInstanceDid(); // BLOCKLET_APP_PID in the test env
170
+ expect(appPid).toBeTruthy();
171
+ await sequelize.query('DELETE FROM coupons');
172
+ // one row in the default tenant, one foreign-stamped row (raw, bypassing stamp)
173
+ await sequelize.query(
174
+ `INSERT INTO coupons (id, instance_did, livemode, duration, name, created_via, created_at, updated_at)
175
+ VALUES ('own', :pid, 0, 'once', 'own', 'api', :ts, :ts), ('foreign', :other, 0, 'once', 'foreign', 'api', :ts, :ts)`,
176
+ { replacements: { pid: appPid, other: TENANT_B, ts: '2024-01-01 00:00:00.000 +00:00' } }
177
+ );
178
+ // no withTenant -> single mode default fill = APP_PID, NOT a passthrough
179
+ const rows: any[] = await Coupon.findAll();
180
+ expect(rows.map((r) => r.id)).toEqual(['own']);
181
+ expect(await Coupon.findByPk('foreign')).toBeNull();
182
+ } finally {
183
+ if (savedMode === undefined) delete process.env.PAYMENT_TENANT_MODE;
184
+ else process.env.PAYMENT_TENANT_MODE = savedMode;
185
+ }
186
+ });
187
+ });
188
+
189
+ describe('index hit: instance_did equality uses an index, not a full scan', () => {
190
+ it('EXPLAIN QUERY PLAN on WHERE instance_did = ? uses the idx_<table>_instance_did index', async () => {
191
+ // sync() does not create the migration index; create it as the Phase 2
192
+ // backfill migration does (idx_<table>_instance_did) and prove it is used.
193
+ await sequelize.query('CREATE INDEX IF NOT EXISTS idx_subscriptions_instance_did ON subscriptions(instance_did)');
194
+ const plan = (await sequelize.query('EXPLAIN QUERY PLAN SELECT * FROM subscriptions WHERE instance_did = :did', {
195
+ replacements: { did: TENANT_A },
196
+ type: (DataTypes as any).SELECT ?? 'SELECT',
197
+ })) as any[];
198
+ const text = JSON.stringify(plan);
199
+ expect(text).toMatch(/USING (COVERING )?INDEX idx_subscriptions_instance_did/);
200
+ expect(text).not.toMatch(/SCAN subscriptions(?! USING)/);
201
+ });
202
+ });
@@ -0,0 +1,177 @@
1
+ // SPIKE: validate the TenantModel base-class mechanism against BOTH query
2
+ // engines in one run — real Sequelize (Node) and the sequelize-d1 shim
3
+ // (worker). Go/No-Go for `TENANT-ISOLATION-DESIGN.md` §12.
4
+ //
5
+ // Proves: (a) static override + super works on both bases; (b) `this`
6
+ // resolves to the concrete model through the extra inheritance layer (shim);
7
+ // (c) scopeWhere idempotency absorbs the findByPk->findOne->findAll double
8
+ // injection; (d) cross-tenant read/write/aggregate are isolated; (e) the
9
+ // worker reads the tenant via ALS (withTenant).
10
+ import { DatabaseSync } from 'node:sqlite';
11
+ import { DataTypes, Model as RealModel, Sequelize } from 'sequelize';
12
+
13
+ import { withTenant } from '../../src/libs/context';
14
+ import { makeTenantModel } from '../../src/store/tenant-model';
15
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
16
+
17
+ // 'coupons' is a real tenant table (in TENANT_TABLES) -> _isTenantTable() true.
18
+ // We give it a minimal schema we fully control, on a throwaway DB.
19
+ const TABLE = 'coupons';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Base 1: real Sequelize (Node runtime)
23
+ // ---------------------------------------------------------------------------
24
+ describe('TenantModel over real Sequelize', () => {
25
+ let sequelize: Sequelize;
26
+ let Widget: any;
27
+
28
+ beforeAll(async () => {
29
+ sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false });
30
+ Widget = class extends makeTenantModel(RealModel) {};
31
+ Widget.init(
32
+ {
33
+ id: { type: DataTypes.STRING, primaryKey: true },
34
+ instance_did: { type: DataTypes.STRING },
35
+ name: { type: DataTypes.STRING },
36
+ qty: { type: DataTypes.INTEGER },
37
+ },
38
+ { sequelize, modelName: 'Widget', tableName: TABLE, timestamps: false }
39
+ );
40
+ await sequelize.sync();
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await sequelize.close();
45
+ });
46
+
47
+ beforeEach(async () => {
48
+ await sequelize.query(`DELETE FROM ${TABLE}`);
49
+ await withTenant(TENANT_A, () => Widget.create({ id: 'a1', name: 'a-one', qty: 10 }));
50
+ await withTenant(TENANT_A, () => Widget.create({ id: 'a2', name: 'a-two', qty: 5 }));
51
+ await withTenant(TENANT_B, () => Widget.create({ id: 'b1', name: 'b-one', qty: 100 }));
52
+ });
53
+
54
+ it('findAll returns only the active tenant', async () => {
55
+ const a = await withTenant(TENANT_A, () => Widget.findAll());
56
+ expect(a.map((r: any) => r.id).sort()).toEqual(['a1', 'a2']);
57
+ const b = await withTenant(TENANT_B, () => Widget.findAll());
58
+ expect(b.map((r: any) => r.id)).toEqual(['b1']);
59
+ });
60
+
61
+ it('findByPk across tenants returns null (delegation + idempotent double-scope)', async () => {
62
+ expect(await withTenant(TENANT_A, () => Widget.findByPk('a1'))).not.toBeNull();
63
+ expect(await withTenant(TENANT_A, () => Widget.findByPk('b1'))).toBeNull();
64
+ });
65
+
66
+ it('create stamps the active tenant', async () => {
67
+ const row = await withTenant(TENANT_A, () => Widget.findByPk('a1'));
68
+ expect(row.instance_did).toBe(TENANT_A);
69
+ });
70
+
71
+ it('sum aggregates only the active tenant', async () => {
72
+ expect(await withTenant(TENANT_A, () => Widget.sum('qty'))).toBe(15);
73
+ expect(await withTenant(TENANT_B, () => Widget.sum('qty'))).toBe(100);
74
+ });
75
+
76
+ it('update never crosses tenants', async () => {
77
+ await withTenant(TENANT_A, () => Widget.update({ name: 'a-upd' }, { where: {} }));
78
+ const b1 = await withTenant(TENANT_B, () => Widget.findByPk('b1'));
79
+ expect(b1.name).toBe('b-one');
80
+ });
81
+
82
+ it('explicit matching instance_did is idempotent; conflicting fails closed', async () => {
83
+ await expect(withTenant(TENANT_A, () => Widget.findAll({ where: { instance_did: TENANT_A } }))).resolves.toHaveLength(2);
84
+ await expect(withTenant(TENANT_A, () => Widget.findAll({ where: { instance_did: TENANT_B } }))).rejects.toThrow(/conflicts with the active tenant/);
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Base 2: sequelize-d1 shim (worker runtime), backed by node:sqlite as a
90
+ // minimal D1-compatible fake.
91
+ // ---------------------------------------------------------------------------
92
+ function makeFakeD1(db: DatabaseSync) {
93
+ const prepare = (sql: string) => {
94
+ let bound: any[] = [];
95
+ const stmt: any = {
96
+ __sql: sql,
97
+ bind: (...vals: any[]) => {
98
+ bound = vals;
99
+ return stmt;
100
+ },
101
+ all: () => ({ results: db.prepare(sql).all(...bound), meta: {} }),
102
+ run: () => {
103
+ const r = db.prepare(sql).run(...bound);
104
+ return { meta: { changes: Number(r.changes), last_row_id: Number(r.lastInsertRowid) } };
105
+ },
106
+ first: () => db.prepare(sql).get(...bound) ?? null,
107
+ };
108
+ return stmt;
109
+ };
110
+ return {
111
+ prepare,
112
+ batch: async (stmts: any[]) =>
113
+ stmts.map((s) => {
114
+ if (/^\s*SELECT/i.test(s.__sql)) return s.all();
115
+ s.run();
116
+ return { results: [], meta: {} };
117
+ }),
118
+ };
119
+ }
120
+
121
+ describe('TenantModel over sequelize-d1 shim', () => {
122
+ let ShimModel: any;
123
+ let setDB: any;
124
+ let Widget: any;
125
+
126
+ beforeAll(() => {
127
+ // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
128
+ const shim = require('../../../cloudflare/shims/sequelize-d1');
129
+ ShimModel = shim.Model;
130
+ setDB = shim.setDB;
131
+
132
+ const db = new DatabaseSync(':memory:');
133
+ db.exec(`CREATE TABLE ${TABLE} (id TEXT PRIMARY KEY, instance_did TEXT, name TEXT, qty INTEGER)`);
134
+ setDB(makeFakeD1(db));
135
+
136
+ Widget = class extends makeTenantModel(ShimModel) {};
137
+ Widget.init(
138
+ { id: {}, instance_did: {}, name: {}, qty: {} },
139
+ { sequelize: { models: {} }, modelName: 'Widget', tableName: TABLE }
140
+ );
141
+ });
142
+
143
+ beforeEach(async () => {
144
+ await withTenant(TENANT_A, () => Widget.destroy({ where: {} }));
145
+ await withTenant(TENANT_B, () => Widget.destroy({ where: {} }));
146
+ await withTenant(TENANT_A, () => Widget.create({ id: 'a1', name: 'a-one', qty: 10 }));
147
+ await withTenant(TENANT_A, () => Widget.create({ id: 'a2', name: 'a-two', qty: 5 }));
148
+ await withTenant(TENANT_B, () => Widget.create({ id: 'b1', name: 'b-one', qty: 100 }));
149
+ });
150
+
151
+ it('findAll returns only the active tenant', async () => {
152
+ const a = await withTenant(TENANT_A, () => Widget.findAll());
153
+ expect(a.map((r: any) => r.id).sort()).toEqual(['a1', 'a2']);
154
+ const b = await withTenant(TENANT_B, () => Widget.findAll());
155
+ expect(b.map((r: any) => r.id)).toEqual(['b1']);
156
+ });
157
+
158
+ it('findByPk across tenants returns null (this-resolution + delegation through extra layer)', async () => {
159
+ expect(await withTenant(TENANT_A, () => Widget.findByPk('a1'))).not.toBeNull();
160
+ expect(await withTenant(TENANT_A, () => Widget.findByPk('b1'))).toBeNull();
161
+ });
162
+
163
+ it('create stamps the active tenant', async () => {
164
+ const row = await withTenant(TENANT_A, () => Widget.findByPk('a1'));
165
+ expect(row.instance_did).toBe(TENANT_A);
166
+ });
167
+
168
+ it('update never crosses tenants', async () => {
169
+ await withTenant(TENANT_A, () => Widget.update({ name: 'a-upd' }, { where: {} }));
170
+ const b1 = await withTenant(TENANT_B, () => Widget.findByPk('b1'));
171
+ expect(b1.name).toBe('b-one');
172
+ });
173
+
174
+ it('conflicting explicit instance_did fails closed', async () => {
175
+ await expect(withTenant(TENANT_A, () => Widget.findAll({ where: { instance_did: TENANT_B } }))).rejects.toThrow(/conflicts with the active tenant/);
176
+ });
177
+ });