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,162 @@
1
+ // P2 — runtime-neutral bootstrap helper (README D2 / F2).
2
+ //
3
+ // Pure-function unit tests (no network). Covers the 6 test classes from
4
+ // tasks.md P2: happy / bad-input / security / data-loss / data-damage /
5
+ // data-leak, plus the T2.3 reserved-prefix regression lock.
6
+ import { buildBootstrap, mergeRemote, buildBootstrapScript, PAYMENT_KIT_DID } from '../../../scripts/bootstrap-inject';
7
+
8
+ const baseOpts = {
9
+ uiPrefix: '/.well-known/payment',
10
+ componentId: PAYMENT_KIT_DID,
11
+ remoteBlockletUrl: '/__blocklet__.js?type=json',
12
+ serviceHost: '/.well-known/service',
13
+ localOnly: ['prefix', 'navigation', 'componentMountPoints'],
14
+ };
15
+
16
+ describe('P2 bootstrap helper — buildBootstrap', () => {
17
+ it('happy: returns prefix/componentId/serviceHost/remoteBlockletUrl skeleton', () => {
18
+ const wb = buildBootstrap(baseOpts);
19
+ expect(wb.prefix).toBe('/.well-known/payment');
20
+ expect(wb.componentId).toBe(PAYMENT_KIT_DID); // getPrefix() precondition (TM-2)
21
+ expect(wb.serviceHost).toBe('/.well-known/service');
22
+ expect(wb.remoteBlockletUrl.startsWith('/__blocklet__.js')).toBe(true); // root-exact, no uiPrefix
23
+ });
24
+
25
+ it('security/regression (T2.3): throws when remoteBlockletUrl falls under uiPrefix', () => {
26
+ expect(() =>
27
+ buildBootstrap({ ...baseOpts, remoteBlockletUrl: '/.well-known/payment/__blocklet__.js' })
28
+ ).toThrow();
29
+ });
30
+
31
+ it('bad-input: throws when remoteBlockletUrl is not root-exact', () => {
32
+ expect(() => buildBootstrap({ ...baseOpts, remoteBlockletUrl: '/foo/__blocklet__.js' })).toThrow();
33
+ });
34
+
35
+ it('data-leak: bakes no per-tenant data — appName/logo absent until runtime merge', () => {
36
+ const wb = buildBootstrap(baseOpts);
37
+ expect(wb.appName).toBeUndefined();
38
+ expect(wb.appLogo).toBeUndefined();
39
+ // Two distinct tenants merging different remote branding onto the SAME helper
40
+ // output keep an IDENTICAL prefix/componentId — no per-tenant value leaks into
41
+ // the local (build-baked) skeleton.
42
+ const tenantA = mergeRemote(buildBootstrap(baseOpts), { appName: 'Tenant A', prefix: '/a' });
43
+ const tenantB = mergeRemote(buildBootstrap(baseOpts), { appName: 'Tenant B', prefix: '/b' });
44
+ expect(tenantA.appName).toBe('Tenant A');
45
+ expect(tenantB.appName).toBe('Tenant B');
46
+ expect(tenantA.prefix).toBe(tenantB.prefix); // '/.well-known/payment' for both
47
+ expect(tenantA.componentId).toBe(tenantB.componentId);
48
+ });
49
+ });
50
+
51
+ describe('P2 bootstrap helper — mergeRemote', () => {
52
+ it('happy: appName/logo/theme come from remote, localOnly preserved', () => {
53
+ const wb = buildBootstrap(baseOpts);
54
+ const merged = mergeRemote(wb, { appName: 'Acme', appLogo: 'https://x/y.png?a=1&b=2', theme: { primary: '#000' } });
55
+ expect(merged.appName).toBe('Acme');
56
+ expect(merged.appLogo).toBe('https://x/y.png?a=1&b=2'); // URL & not corrupted
57
+ expect(merged.theme).toEqual({ primary: '#000' });
58
+ });
59
+
60
+ it('data-loss: localOnly fields keep their (local) values after merge', () => {
61
+ const wb = buildBootstrap({ ...baseOpts, extra: { navigation: [{ id: 'a' }] } });
62
+ // remote tries to override prefix + navigation; both are localOnly → ignored,
63
+ // the local values survive (navigation defaults to [] and is set via extra here).
64
+ const merged = mergeRemote(wb, { prefix: '/', navigation: [{ id: 'REMOTE' }], componentMountPoints: [{ x: 1 }] });
65
+ expect(merged.prefix).toBe('/.well-known/payment');
66
+ expect(merged.navigation).toEqual([{ id: 'a' }]); // local nav kept, remote skipped
67
+ expect(merged.componentMountPoints).toEqual([]); // local default kept (extra didn't set it)
68
+ });
69
+
70
+ it('data-damage: componentId/prefix stay constant regardless of remote', () => {
71
+ const wb = buildBootstrap(baseOpts);
72
+ const merged = mergeRemote(wb, { componentId: 'zEVIL', prefix: '/' });
73
+ expect(merged.componentId).toBe(PAYMENT_KIT_DID);
74
+ expect(merged.prefix).toBe('/.well-known/payment');
75
+ });
76
+
77
+ it('security: rejects prototype pollution (__proto__/constructor/prototype)', () => {
78
+ const wb = buildBootstrap(baseOpts);
79
+ const merged = mergeRemote(wb, JSON.parse('{"__proto__":{"polluted":1},"constructor":2,"safe":3}'));
80
+ expect(({} as any).polluted).toBeUndefined();
81
+ expect((merged as any).polluted).toBeUndefined();
82
+ expect(merged.safe).toBe(3);
83
+ });
84
+
85
+ it('security: angle-bracket escapes string values (XSS)', () => {
86
+ const wb = buildBootstrap(baseOpts);
87
+ const merged = mergeRemote(wb, { appName: '<svg onload=alert(1)>' });
88
+ expect(merged.appName).not.toContain('<svg');
89
+ expect(merged.appName).toContain('&lt;svg');
90
+ });
91
+
92
+ it('security: refuses oversized payload (>256KB)', () => {
93
+ const wb = buildBootstrap(baseOpts);
94
+ expect(() => mergeRemote(wb, { appName: 'x'.repeat(300 * 1024) })).toThrow();
95
+ });
96
+
97
+ it('bad-input: empty/undefined remote does not throw, keeps base intact', () => {
98
+ const wb = buildBootstrap(baseOpts);
99
+ expect(mergeRemote(wb, {} as any).prefix).toBe('/.well-known/payment');
100
+ expect(mergeRemote(wb, undefined as any).componentId).toBe(PAYMENT_KIT_DID);
101
+ });
102
+ });
103
+
104
+ describe('P2 bootstrap helper — buildBootstrapScript', () => {
105
+ it('happy: emits a <script> with the baked window.blocklet + embedded mergeRemote', () => {
106
+ const script = buildBootstrapScript(baseOpts);
107
+ expect(script).toContain('window.blocklet =');
108
+ expect(script).toContain(PAYMENT_KIT_DID);
109
+ expect(script).toContain('/__blocklet__.js?type=json');
110
+ // the runtime merge is the same mergeRemote source (single source of truth)
111
+ expect(script).toContain('refusing merge');
112
+ });
113
+
114
+ it('data-damage: emitted script references the root-exact remote url, never under uiPrefix', () => {
115
+ const script = buildBootstrapScript(baseOpts);
116
+ expect(script).not.toContain('/.well-known/payment/__blocklet__');
117
+ });
118
+ });
119
+
120
+ // Dynamic (Layer 2) — run the EMITTED <script> body in a node vm with a stubbed
121
+ // browser window + synchronous XHR, proving it is valid browser JS and that the
122
+ // runtime merge (the embedded mergeRemote) preserves every invariant against a
123
+ // hostile remote __blocklet__.js. Committed + reproducible (replaces the former
124
+ // throwaway `tsx -e` smoke).
125
+ describe('P2 bootstrap helper — emitted script runtime behavior (vm)', () => {
126
+ function runEmittedScript(remote: Record<string, any>) {
127
+ // eslint-disable-next-line global-require
128
+ const vm = require('node:vm');
129
+ const script = buildBootstrapScript(baseOpts);
130
+ // tolerate any <script ...> attributes, not just the bare tag
131
+ const body = script.replace(/^<script[^>]*>/, '').replace(/<\/script>\s*$/, '');
132
+ const win: any = { location: { origin: 'https://t1.example' } };
133
+ const ctx: any = {
134
+ window: win,
135
+ XMLHttpRequest: function (this: any) {
136
+ this.open = () => {};
137
+ this.send = () => {
138
+ this.status = 200;
139
+ this.responseText = JSON.stringify(remote);
140
+ };
141
+ },
142
+ };
143
+ ctx.global = ctx;
144
+ vm.createContext(ctx);
145
+ vm.runInContext(body, ctx);
146
+ return win.blocklet;
147
+ }
148
+
149
+ it('runs as valid browser JS and merges a hostile remote while protecting invariants', () => {
150
+ const wb = runEmittedScript({
151
+ appName: '<svg onload=alert(1)>',
152
+ prefix: '/',
153
+ componentId: 'zEVIL',
154
+ appLogo: 'https://cdn/x.png?a=1&b=2',
155
+ });
156
+ expect(wb.prefix).toBe('/.well-known/payment'); // localOnly protected
157
+ expect(wb.componentId).toBe(PAYMENT_KIT_DID); // structural invariant
158
+ expect(wb.appName).not.toContain('<svg'); // XSS escaped
159
+ expect(wb.appLogo).toBe('https://cdn/x.png?a=1&b=2'); // URL query not corrupted
160
+ expect(wb.env.appName).toBe(wb.appName); // env mirror populated
161
+ });
162
+ });
@@ -0,0 +1,158 @@
1
+ // Phase 7 (S3 arc-payment-embed) — multi-tenant cron fan-out.
2
+ //
3
+ // Background crons that issue a top-level tenant-scoped query have no request
4
+ // to carry a tenant, so in multi mode getInstanceDid() throws
5
+ // TENANT_CONTEXT_MISSING and the pass does nothing. perTenant() wraps such a
6
+ // cron's fn so it runs once per provisioned tenant inside withTenant; single
7
+ // mode runs it as-is (the default tenant auto-resolves).
8
+ //
9
+ // Dynamic require() + jest.resetModules so each case re-reads the tenant mode.
10
+ /* eslint-disable global-require, import/no-dynamic-require */
11
+
12
+ describe('perTenant — multi-tenant cron fan-out', () => {
13
+ const ORIGINAL_MODE = process.env.PAYMENT_TENANT_MODE;
14
+ const ORIGINAL_PID = process.env.BLOCKLET_APP_PID;
15
+
16
+ afterEach(() => {
17
+ if (ORIGINAL_MODE === undefined) delete process.env.PAYMENT_TENANT_MODE;
18
+ else process.env.PAYMENT_TENANT_MODE = ORIGINAL_MODE;
19
+ if (ORIGINAL_PID === undefined) delete process.env.BLOCKLET_APP_PID;
20
+ else process.env.BLOCKLET_APP_PID = ORIGINAL_PID;
21
+ jest.resetModules();
22
+ });
23
+
24
+ // Happy path — multi mode runs the fn once per provisioned tenant, each
25
+ // inside its own tenant context.
26
+ it('multi mode: runs fn once per tenant inside withTenant', async () => {
27
+ process.env.PAYMENT_TENANT_MODE = 'multi';
28
+ jest.resetModules();
29
+ const { perTenant } = require('../../src/crons/tenant-fanout');
30
+ const { getInstanceDid } = require('../../src/libs/context');
31
+
32
+ const seen: string[] = [];
33
+ const wrapped = perTenant(
34
+ 'test.cron',
35
+ () => seen.push(getInstanceDid()),
36
+ () => Promise.resolve(['did:abt:TENANT_A', 'did:abt:TENANT_B'])
37
+ );
38
+ await wrapped();
39
+
40
+ expect(seen).toEqual(['did:abt:TENANT_A', 'did:abt:TENANT_B']);
41
+ });
42
+
43
+ // Single mode — no fan-out: the fn runs exactly once (the default tenant
44
+ // auto-resolves in getInstanceDid). Idempotent: not N times.
45
+ it('single mode: runs fn exactly once (no fan-out, no enumeration)', async () => {
46
+ process.env.PAYMENT_TENANT_MODE = 'single';
47
+ process.env.BLOCKLET_APP_PID = 'did:abt:DEFAULT';
48
+ jest.resetModules();
49
+ const { perTenant } = require('../../src/crons/tenant-fanout');
50
+
51
+ let calls = 0;
52
+ const listTenants = jest.fn(() => Promise.resolve(['did:abt:A', 'did:abt:B']));
53
+ const wrapped = perTenant(
54
+ 'test.cron',
55
+ () => {
56
+ calls += 1;
57
+ },
58
+ listTenants
59
+ );
60
+ await wrapped();
61
+
62
+ expect(calls).toBe(1);
63
+ expect(listTenants).not.toHaveBeenCalled(); // single mode never enumerates
64
+ });
65
+
66
+ // Data loss / error isolation — one tenant's failure must not abort the pass;
67
+ // every other tenant still runs.
68
+ it('multi mode: one tenant failing does not stop the others', async () => {
69
+ process.env.PAYMENT_TENANT_MODE = 'multi';
70
+ jest.resetModules();
71
+ const { perTenant } = require('../../src/crons/tenant-fanout');
72
+ const { getInstanceDid } = require('../../src/libs/context');
73
+
74
+ const ran: string[] = [];
75
+ const wrapped = perTenant(
76
+ 'test.cron',
77
+ () => {
78
+ const did = getInstanceDid();
79
+ if (did === 'did:abt:B') throw new Error('boom for B');
80
+ ran.push(did);
81
+ },
82
+ () => Promise.resolve(['did:abt:A', 'did:abt:B', 'did:abt:C'])
83
+ );
84
+
85
+ // the pass itself resolves (errors isolated), not rejects
86
+ await expect(wrapped()).resolves.toBeUndefined();
87
+ expect(ran).toEqual(['did:abt:A', 'did:abt:C']);
88
+ });
89
+
90
+ // Bad input — empty tenant list is a clean no-op (no throw).
91
+ it('multi mode: empty tenant list is a no-op', async () => {
92
+ process.env.PAYMENT_TENANT_MODE = 'multi';
93
+ jest.resetModules();
94
+ const { perTenant } = require('../../src/crons/tenant-fanout');
95
+
96
+ let calls = 0;
97
+ const wrapped = perTenant(
98
+ 'test.cron',
99
+ () => {
100
+ calls += 1;
101
+ },
102
+ () => Promise.resolve([])
103
+ );
104
+ await expect(wrapped()).resolves.toBeUndefined();
105
+ expect(calls).toBe(0);
106
+ });
107
+
108
+ // Data leak — each pass is scoped to exactly its tenant; the fn never sees
109
+ // another tenant's id leaking across iterations.
110
+ it('multi mode: each pass sees only its own tenant id', async () => {
111
+ process.env.PAYMENT_TENANT_MODE = 'multi';
112
+ jest.resetModules();
113
+ const { perTenant } = require('../../src/crons/tenant-fanout');
114
+ const { getInstanceDid } = require('../../src/libs/context');
115
+
116
+ const pairs: Array<[string, string]> = [];
117
+ const wrapped = perTenant(
118
+ 'test.cron',
119
+ () => {
120
+ // capture the tenant twice within the same pass — must be stable
121
+ pairs.push([getInstanceDid(), getInstanceDid()]);
122
+ },
123
+ () => Promise.resolve(['did:abt:A', 'did:abt:B'])
124
+ );
125
+ await wrapped();
126
+
127
+ expect(pairs).toEqual([
128
+ ['did:abt:A', 'did:abt:A'],
129
+ ['did:abt:B', 'did:abt:B'],
130
+ ]);
131
+ });
132
+ });
133
+
134
+ describe('listProvisionedTenants — DISTINCT instance_did from payment-core store', () => {
135
+ afterEach(() => {
136
+ jest.resetModules();
137
+ jest.restoreAllMocks();
138
+ });
139
+
140
+ it('dedupes and filters empty/null instance_did from a cross-tenant read', async () => {
141
+ jest.resetModules();
142
+ // mock the cross-tenant system read so this stays a pure unit test (no DB)
143
+ jest.doMock('../../src/store/scoped', () => ({
144
+ systemFindAll: jest.fn(() =>
145
+ Promise.resolve([
146
+ { instance_did: 'did:abt:A' },
147
+ { instance_did: 'did:abt:B' },
148
+ { instance_did: 'did:abt:A' }, // duplicate
149
+ { instance_did: null }, // unprovisioned / null
150
+ { instance_did: '' }, // empty
151
+ ])
152
+ ),
153
+ }));
154
+ const { listProvisionedTenants } = require('../../src/crons/tenant-fanout');
155
+ const dids = await listProvisionedTenants();
156
+ expect(dids.sort()).toEqual(['did:abt:A', 'did:abt:B']);
157
+ });
158
+ });
@@ -0,0 +1,257 @@
1
+ // D3 (S3.0) — embedded multi-mode background harness. Builds a REAL embedded
2
+ // payment service (createEmbeddedPaymentService) over a file-backed sqlite with
3
+ // the full 46-table schema (applyPaymentCoreMigrations) and tenancy:{mode:'multi'}
4
+ // + every slot, then exercises the background engine the way an arc-node host
5
+ // drives it: queue retry, delayed-job restart recovery, cross-tenant isolation,
6
+ // and slot-misconfig fail-closed.
7
+ //
8
+ // The engine-level retry/dispatch parity lives in queue-runtime-surface.spec;
9
+ // this harness proves the SAME behavior holds through the assembled multi-mode
10
+ // service (models bound by the factory, tenant carried in the payload).
11
+
12
+ import fs from 'fs';
13
+ import os from 'os';
14
+ import path from 'path';
15
+ import { Sequelize } from 'sequelize';
16
+
17
+ import { withTenant, getInstanceDid } from '../../src/libs/context';
18
+ import { createNodeDbDriver } from '../../src/libs/drivers/db';
19
+ import {
20
+ applyPaymentCoreMigrations,
21
+ createMemoryLocksDriver,
22
+ createCronRegistry,
23
+ createKeyringSecretsDriver,
24
+ nodeQueueHostHooks,
25
+ } from '../../src/libs/drivers';
26
+ import { setQueueRuntimeMode, __test__ as runtimeTest } from '../../src/libs/queue/runtime';
27
+ import { createEmbeddedPaymentService, PaymentCoreSlotError } from '../../src/service';
28
+
29
+ jest.setTimeout(60000);
30
+
31
+ jest.mock('../../src/libs/logger', () => ({
32
+ __esModule: true,
33
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
34
+ }));
35
+
36
+ const TENANT_A = 'did:abt:zD3TENANTA';
37
+ const TENANT_B = 'did:abt:zD3TENANTB';
38
+ const HOST_A = 'a.example.com';
39
+ const HOST_B = 'b.example.com';
40
+
41
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'embedded-multi-d3-'));
42
+ const dbFile = path.join(dir, 'payment.db');
43
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: dbFile, logging: false, pool: { max: 5 } });
44
+
45
+ // multi-tenant identity: Host -> tenant, plus a per-tenant EK for the keyring.
46
+ const identity = {
47
+ resolveInstanceDidForHost(host: string | undefined) {
48
+ if (host === HOST_A) return TENANT_A;
49
+ if (host === HOST_B) return TENANT_B;
50
+ return null; // unknown host fails closed at the resolver
51
+ },
52
+ getAppEk(instanceDid: string) {
53
+ // distinct per-tenant key material (hex) — the keyring isolates by this
54
+ return instanceDid === TENANT_A ? 'a'.repeat(64) : 'b'.repeat(64);
55
+ },
56
+ };
57
+
58
+ const baseSlots = () => ({
59
+ config: { BLOCKLET_APP_PID: TENANT_A, PAYMENT_LIVEMODE: 'true' },
60
+ db: { sequelize: sequelize as any },
61
+ tenancy: { mode: 'multi' as const },
62
+ identity,
63
+ secrets: createKeyringSecretsDriver(identity),
64
+ queue: nodeQueueHostHooks,
65
+ cron: createCronRegistry('node-cron'),
66
+ locks: createMemoryLocksDriver(),
67
+ });
68
+
69
+ let svc: ReturnType<typeof createEmbeddedPaymentService>;
70
+ let createQueue: any;
71
+ let createQueueStore: any;
72
+ let tableCount = 0;
73
+
74
+ const settle = (emitter: any): Promise<string> =>
75
+ new Promise((resolve) => {
76
+ ['finished', 'failed', 'cancelled'].forEach((e) => emitter.on(e, () => resolve(e)));
77
+ });
78
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
79
+
80
+ beforeAll(async () => {
81
+ // provision the full embedded schema (9 SQL migrations -> 46 tables)
82
+ const driver = createNodeDbDriver(sequelize);
83
+ await applyPaymentCoreMigrations(driver);
84
+ // canonical embedded-schema count (task 13 / D4): 46 tables INCLUDING the
85
+ // _sql_migrations tracker row table.
86
+ const rows = await driver.all<{ name: string }>(
87
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
88
+ );
89
+ tableCount = rows.length;
90
+
91
+ // assemble the multi-mode embedded service (binds models to OUR sequelize)
92
+ svc = createEmbeddedPaymentService(baseSlots());
93
+ setQueueRuntimeMode('node');
94
+ createQueue = require('../../src/libs/queue').default;
95
+ createQueueStore = require('../../src/libs/queue/store').default;
96
+ }, 120000);
97
+
98
+ afterAll(async () => {
99
+ // exercise the D2 lifecycle teardown contract (no-op here since we never
100
+ // called lifecycle.start(), but it must not throw and clears any driver state)
101
+ await svc.lifecycle.stop();
102
+ runtimeTest.reset();
103
+ await sequelize.close();
104
+ fs.rmSync(dir, { recursive: true, force: true });
105
+ });
106
+
107
+ afterEach(() => {
108
+ runtimeTest.reset();
109
+ setQueueRuntimeMode('node');
110
+ });
111
+
112
+ describe('D3 happy path — multi service assembles + a job runs under its tenant', () => {
113
+ it('the embedded schema has 46 tables and the rpc surface is present', () => {
114
+ expect(tableCount).toBe(46);
115
+ expect(typeof svc.rpc.entitlements.check).toBe('function');
116
+ expect(typeof svc.rpc.meterEvents.report).toBe('function');
117
+ });
118
+
119
+ it('a job enqueued under tenant A executes under tenant A', async () => {
120
+ let observed = '';
121
+ const q = createQueue({
122
+ name: `d3-happy-${Date.now()}`,
123
+ onJob: async () => {
124
+ observed = getInstanceDid();
125
+ },
126
+ });
127
+ const ev = await withTenant(TENANT_A, async () => q.push({ job: { v: 1, instance_did: TENANT_A } }));
128
+ expect(await settle(ev)).toBe('finished');
129
+ expect(observed).toBe(TENANT_A);
130
+ });
131
+ });
132
+
133
+ describe('D3 bad input — slot misconfig fails closed (no silent single)', () => {
134
+ it('multi without a secrets slot throws PaymentCoreSlotError', () => {
135
+ const slots: any = baseSlots();
136
+ delete slots.secrets;
137
+ expect(() => createEmbeddedPaymentService(slots)).toThrow(PaymentCoreSlotError);
138
+ });
139
+
140
+ it('multi without an identity slot throws PaymentCoreSlotError', () => {
141
+ const slots: any = baseSlots();
142
+ delete slots.identity;
143
+ expect(() => createEmbeddedPaymentService(slots)).toThrow(PaymentCoreSlotError);
144
+ });
145
+
146
+ it('multi without cron/queue/locks still assembles (safe Node defaults, not a degrade)', () => {
147
+ // identity + secrets are the ONLY slots whose absence silently degrades to
148
+ // single-tenant (D1), so they fail closed. cron/queue/locks have correct
149
+ // Node defaults (node-cron registry, no-op flush, memory locks) for a
150
+ // single-process daemon, so their absence is NOT an error — it must not be
151
+ // conflated with the identity/secrets fail-closed case.
152
+ const slots: any = baseSlots();
153
+ delete slots.cron;
154
+ delete slots.queue;
155
+ delete slots.locks;
156
+ expect(() => createEmbeddedPaymentService(slots)).not.toThrow();
157
+ });
158
+ });
159
+
160
+ describe('D3 security — cross-tenant job object is fail-closed', () => {
161
+ it('a job whose loaded object tenant != payload tenant is rejected (structured)', async () => {
162
+ const { assertJobObjectTenant } = require('../../src/libs/queue');
163
+ let leaked = false;
164
+ let observed = '';
165
+ const q = createQueue({
166
+ name: `d3-xtenant-${Date.now()}`,
167
+ onJob: async () => {
168
+ observed = getInstanceDid();
169
+ // payload says A; an object loaded cross-tenant belongs to B
170
+ assertJobObjectTenant({ instance_did: TENANT_B });
171
+ leaked = true; // unreachable — assert throws first
172
+ },
173
+ });
174
+ const ev = await withTenant(TENANT_A, async () => q.push({ job: { tag: 'x', instance_did: TENANT_A } }));
175
+ expect(await settle(ev)).toBe('failed');
176
+ expect(observed).toBe(TENANT_A);
177
+ expect(leaked).toBe(false);
178
+ });
179
+ });
180
+
181
+ describe('D3 data loss — delayed/persisted job recovers on restart', () => {
182
+ it('a persisted row left by a prior queue is recovered + executed by a fresh queue (same id)', async () => {
183
+ const name = `d3-recover-${Date.now()}`;
184
+ const jobId = `recover-${Date.now()}`;
185
+ // simulate a prior process: a persisted immediate row sits in the store,
186
+ // never executed (no live queue ran it)
187
+ const store = createQueueStore(name);
188
+ await store.addJob(jobId, { v: 1, instance_did: TENANT_A }, {});
189
+
190
+ // "restart": a fresh queue with the same name boots and recovers the row.
191
+ // Capture the RECOVERED row's id from the runtime's finished event (the
192
+ // job payload carries no id, so this is the only faithful source — proving
193
+ // the recovered id is the original, not a freshly generated one).
194
+ let ranId = '';
195
+ let executions = 0;
196
+ const q = createQueue({
197
+ name,
198
+ onJob: async () => {
199
+ executions += 1;
200
+ },
201
+ });
202
+ q.on('finished', (data: { id: string }) => {
203
+ ranId = data.id;
204
+ });
205
+ // loadExisting runs on process.nextTick; give it room to recover + execute
206
+ await wait(400);
207
+ const remaining = await store.getJobs();
208
+ expect(ranId).toBe(jobId); // the RECOVERED row's id is the original (not regenerated)
209
+ expect(executions).toBe(1); // executed exactly once on recovery
210
+ expect(remaining.find((r: any) => r.id === jobId)).toBeUndefined(); // consumed, not duplicated
211
+ });
212
+ });
213
+
214
+ describe('D3 data damage — retry does not double-execute (idempotent)', () => {
215
+ it('a job that fails then succeeds runs its success side-effect exactly once', async () => {
216
+ let attempts = 0;
217
+ let successes = 0;
218
+ const q = createQueue({
219
+ name: `d3-retry-${Date.now()}`,
220
+ onJob: async () => {
221
+ attempts += 1;
222
+ if (attempts < 3) throw new Error('transient');
223
+ successes += 1; // only on the successful attempt
224
+ },
225
+ options: { maxRetries: 5, retryDelay: 1 },
226
+ });
227
+ const ev = await withTenant(TENANT_A, async () => q.push({ job: { v: 1, instance_did: TENANT_A } }));
228
+ expect(await settle(ev)).toBe('finished');
229
+ expect(attempts).toBe(3); // 2 failures + 1 success, no infinite loop
230
+ expect(successes).toBe(1); // side-effect exactly once, not per attempt
231
+ });
232
+ });
233
+
234
+ describe('D3 data leak — tenant A and tenant B background jobs do not bleed', () => {
235
+ it('A and B jobs each run strictly under their own tenant scope', async () => {
236
+ const seen: Record<string, string> = {};
237
+ const qa = createQueue({
238
+ name: `d3-leak-a-${Date.now()}`,
239
+ onJob: async () => {
240
+ seen.a = getInstanceDid();
241
+ },
242
+ });
243
+ const qb = createQueue({
244
+ name: `d3-leak-b-${Date.now()}`,
245
+ onJob: async () => {
246
+ seen.b = getInstanceDid();
247
+ },
248
+ });
249
+ const ea = await withTenant(TENANT_A, async () => qa.push({ job: { instance_did: TENANT_A } }));
250
+ const eb = await withTenant(TENANT_B, async () => qb.push({ job: { instance_did: TENANT_B } }));
251
+ expect(await settle(ea)).toBe('finished');
252
+ expect(await settle(eb)).toBe('finished');
253
+ expect(seen.a).toBe(TENANT_A);
254
+ expect(seen.b).toBe(TENANT_B);
255
+ expect(seen.a).not.toBe(seen.b); // no cross-tenant bleed
256
+ });
257
+ });
@@ -0,0 +1,13 @@
1
+ // Deliberately violating fixture for the tenant query scanner self-test.
2
+ // NOT part of the CI scan scope (api/tests is excluded); the scanner is
3
+ // pointed at this file explicitly in scoped.spec.ts.
4
+ //
5
+ // W1′ Phase 5: a bare `Customer.findAll()` is now SAFE (Customer extends
6
+ // TenantModel — auto-scoped), so the violation the scanner must catch is a raw
7
+ // `.query(...)` on a tenant table with NO $instance_did bind (assertion ②).
8
+ import { sequelize } from '../../src/store/sequelize';
9
+
10
+ export function rawQueryViolation() {
11
+ // raw read on a tenant table (coupons) with no tenant bind — scanner flags it
12
+ return sequelize.query('SELECT * FROM coupons WHERE livemode = 1');
13
+ }
@@ -0,0 +1,10 @@
1
+ // Phase 12 (W2-4a) negative fixture — core code reading process.env / the CF env
2
+ // mirror directly, which the core-env scanner must reject (config must flow
3
+ // through the env/config boundary). Not in the CI scan scope; passed explicitly
4
+ // to the scanner in tests to prove the rule fires.
5
+ /* eslint-disable */
6
+ export function badConfigRead() {
7
+ const a = process.env.SOME_SECRET; // VIOLATION: direct process.env read in core
8
+ const b = (globalThis as any).__CF_ENV__?.APP_URL; // VIOLATION: CF env mirror read
9
+ return { a, b };
10
+ }
@@ -0,0 +1,19 @@
1
+ // Phase 10 (W2-2) negative fixture — a route reading the Host directly, which
2
+ // the tenant-scan rule must reject (tenant may only be resolved at the single
3
+ // middleware point). Not part of the CI scan scope; passed explicitly to the
4
+ // scanner in tests to prove the rule fires.
5
+ /* eslint-disable */
6
+ // Minimal req/res shapes — this is a STATIC-SCAN fixture (the tenant-query scanner
7
+ // reads the source text for Host reads); the handlers are never executed, so the
8
+ // express types are unnecessary (core is express-free post Phase 4).
9
+ type AnyReq = { headers: Record<string, any>; hostname?: string };
10
+ type AnyRes = { json: (body: any) => any };
11
+
12
+ export function badHandlerA(req: AnyReq, res: AnyRes) {
13
+ const host = req.headers.host; // VIOLATION: host read outside the tenant middleware
14
+ res.json({ host });
15
+ }
16
+
17
+ export function badHandlerB(req: AnyReq, res: AnyRes) {
18
+ res.json({ h: req.hostname }); // VIOLATION: req.hostname read outside the tenant middleware
19
+ }
@@ -0,0 +1,4 @@
1
+ // Two distinct tenant instanceDids used by all multi-tenant isolation tests.
2
+ // Built on top of the Phase 0 test injection helper (`withTenant`).
3
+ export const TENANT_A = 'did:abt:zTenantAAAAAAAAAAAAAAAAAAAAAAA';
4
+ export const TENANT_B = 'did:abt:zTenantBBBBBBBBBBBBBBBBBBBBBBB';