payment-kit 1.29.1 → 1.29.2

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 (310) 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 +36 -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 +27 -24
  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/index.ts +22 -161
  12. package/api/src/integrations/app-store/client.ts +3 -4
  13. package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
  14. package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
  15. package/api/src/integrations/arcblock/token.ts +21 -7
  16. package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
  17. package/api/src/integrations/google-play/handlers/voided.ts +2 -2
  18. package/api/src/integrations/google-play/verify.ts +3 -2
  19. package/api/src/integrations/iap-reconcile.ts +3 -5
  20. package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
  21. package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
  22. package/api/src/libs/archive/query.ts +19 -0
  23. package/api/src/libs/audit.ts +61 -4
  24. package/api/src/libs/auth.ts +99 -38
  25. package/api/src/libs/context.ts +78 -1
  26. package/api/src/libs/currency.ts +2 -2
  27. package/api/src/libs/dayjs.ts +8 -2
  28. package/api/src/libs/drivers/auth-storage.ts +118 -0
  29. package/api/src/libs/drivers/cron.ts +264 -0
  30. package/api/src/libs/drivers/db.ts +170 -0
  31. package/api/src/libs/drivers/identity.ts +81 -0
  32. package/api/src/libs/drivers/index.ts +40 -0
  33. package/api/src/libs/drivers/locks.ts +226 -0
  34. package/api/src/libs/drivers/migrate-runner.ts +70 -0
  35. package/api/src/libs/drivers/queue.ts +104 -0
  36. package/api/src/libs/drivers/secrets.ts +194 -0
  37. package/api/src/libs/env.ts +170 -54
  38. package/api/src/libs/exchange-rate/service.ts +7 -6
  39. package/api/src/libs/http-fetch-adapter.ts +50 -0
  40. package/api/src/libs/invoice.ts +1 -1
  41. package/api/src/libs/lock.ts +51 -47
  42. package/api/src/libs/logger.ts +48 -8
  43. package/api/src/libs/notification/index.ts +1 -1
  44. package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
  45. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
  46. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
  47. package/api/src/libs/overdraft-protection.ts +1 -1
  48. package/api/src/libs/payout.ts +1 -1
  49. package/api/src/libs/queue/index.ts +259 -52
  50. package/api/src/libs/queue/runtime.ts +175 -0
  51. package/api/src/libs/resource.ts +3 -3
  52. package/api/src/libs/secrets.ts +38 -0
  53. package/api/src/libs/session.ts +3 -2
  54. package/api/src/libs/subscription.ts +5 -5
  55. package/api/src/libs/tenant.ts +92 -0
  56. package/api/src/libs/url.ts +3 -3
  57. package/api/src/libs/util.ts +21 -13
  58. package/api/src/middlewares/hono/cdn.ts +63 -0
  59. package/api/src/middlewares/hono/context.ts +73 -0
  60. package/api/src/middlewares/hono/csrf.ts +72 -0
  61. package/api/src/middlewares/hono/fallback.ts +194 -0
  62. package/api/src/middlewares/hono/pipeline.ts +73 -0
  63. package/api/src/middlewares/hono/resource-mount.ts +42 -0
  64. package/api/src/middlewares/hono/resource.ts +63 -0
  65. package/api/src/middlewares/hono/security.ts +214 -0
  66. package/api/src/middlewares/hono/session.ts +114 -0
  67. package/api/src/middlewares/hono/xss.ts +61 -0
  68. package/api/src/queues/auto-recharge.ts +12 -10
  69. package/api/src/queues/checkout-session.ts +17 -12
  70. package/api/src/queues/credit-consume.ts +40 -36
  71. package/api/src/queues/credit-grant.ts +25 -18
  72. package/api/src/queues/credit-reconciliation.ts +7 -5
  73. package/api/src/queues/discount-status.ts +9 -6
  74. package/api/src/queues/event.ts +12 -4
  75. package/api/src/queues/exchange-rate-health.ts +49 -30
  76. package/api/src/queues/invoice.ts +18 -15
  77. package/api/src/queues/notification.ts +14 -7
  78. package/api/src/queues/payment.ts +41 -28
  79. package/api/src/queues/payout.ts +9 -5
  80. package/api/src/queues/refund.ts +18 -12
  81. package/api/src/queues/subscription.ts +83 -53
  82. package/api/src/queues/token-transfer.ts +15 -10
  83. package/api/src/queues/usage-record.ts +8 -5
  84. package/api/src/queues/vendors/commission.ts +7 -5
  85. package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
  86. package/api/src/queues/vendors/fulfillment.ts +4 -2
  87. package/api/src/queues/vendors/return-processor.ts +5 -3
  88. package/api/src/queues/vendors/return-scanner.ts +5 -4
  89. package/api/src/queues/vendors/status-check.ts +10 -7
  90. package/api/src/queues/webhook.ts +60 -32
  91. package/api/src/routes/connect/shared.ts +1 -2
  92. package/api/src/routes/connect/subscribe.ts +3 -3
  93. package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
  94. package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
  95. package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
  96. package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
  97. package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
  98. package/api/src/routes/hono/credit-tokens.ts +43 -0
  99. package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
  100. package/api/src/routes/{customers.ts → hono/customers.ts} +193 -223
  101. package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
  102. package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
  103. package/api/src/routes/{events.ts → hono/events.ts} +107 -71
  104. package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
  105. package/api/src/routes/hono/exchange-rates.ts +77 -0
  106. package/api/src/routes/hono/index.ts +115 -0
  107. package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
  108. package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
  109. package/api/src/routes/hono/integrations/stripe.ts +74 -0
  110. package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
  111. package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
  112. package/api/src/routes/hono/meters.ts +288 -0
  113. package/api/src/routes/hono/passports.ts +73 -0
  114. package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
  115. package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
  116. package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
  117. package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
  118. package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
  119. package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
  120. package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
  121. package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
  122. package/api/src/routes/{products.ts → hono/products.ts} +172 -159
  123. package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
  124. package/api/src/routes/hono/redirect.ts +24 -0
  125. package/api/src/routes/{refunds.ts → hono/refunds.ts} +96 -80
  126. package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
  127. package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
  128. package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
  129. package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
  130. package/api/src/routes/hono/tool.ts +69 -0
  131. package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
  132. package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
  133. package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
  134. package/api/src/routes/hono/webhook-endpoints.ts +126 -0
  135. package/api/src/service.ts +667 -0
  136. package/api/src/store/migrations/20230911-seeding.ts +2 -1
  137. package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
  138. package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
  139. package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
  140. package/api/src/store/models/auto-recharge-config.ts +22 -10
  141. package/api/src/store/models/checkout-session.ts +15 -14
  142. package/api/src/store/models/coupon.ts +29 -20
  143. package/api/src/store/models/credit-grant.ts +38 -29
  144. package/api/src/store/models/credit-transaction.ts +32 -21
  145. package/api/src/store/models/customer.ts +19 -17
  146. package/api/src/store/models/discount.ts +11 -2
  147. package/api/src/store/models/entitlement-grant.ts +21 -9
  148. package/api/src/store/models/entitlement-product.ts +21 -9
  149. package/api/src/store/models/entitlement.ts +19 -10
  150. package/api/src/store/models/event.ts +18 -9
  151. package/api/src/store/models/exchange-rate-provider.ts +17 -4
  152. package/api/src/store/models/invoice-item.ts +18 -9
  153. package/api/src/store/models/invoice.ts +16 -8
  154. package/api/src/store/models/meter-event.ts +27 -9
  155. package/api/src/store/models/meter.ts +31 -22
  156. package/api/src/store/models/payment-currency.ts +25 -8
  157. package/api/src/store/models/payment-intent.ts +15 -6
  158. package/api/src/store/models/payment-link.ts +15 -6
  159. package/api/src/store/models/payment-method.ts +38 -22
  160. package/api/src/store/models/payment-stat.ts +18 -9
  161. package/api/src/store/models/payout.ts +15 -6
  162. package/api/src/store/models/price-quote.ts +17 -8
  163. package/api/src/store/models/price.ts +24 -12
  164. package/api/src/store/models/pricing-table.ts +29 -20
  165. package/api/src/store/models/product-vendor.ts +20 -10
  166. package/api/src/store/models/product.ts +15 -6
  167. package/api/src/store/models/promotion-code.ts +14 -6
  168. package/api/src/store/models/refund.ts +15 -6
  169. package/api/src/store/models/revenue-snapshot.ts +21 -9
  170. package/api/src/store/models/setting.ts +18 -9
  171. package/api/src/store/models/setup-intent.ts +36 -27
  172. package/api/src/store/models/subscription-item.ts +21 -9
  173. package/api/src/store/models/subscription-schedule.ts +21 -9
  174. package/api/src/store/models/subscription.ts +21 -10
  175. package/api/src/store/models/tax-rate.ts +29 -21
  176. package/api/src/store/models/usage-record.ts +11 -2
  177. package/api/src/store/models/webhook-attempt.ts +18 -9
  178. package/api/src/store/models/webhook-endpoint.ts +18 -9
  179. package/api/src/store/scoped-core.ts +55 -0
  180. package/api/src/store/scoped.ts +247 -0
  181. package/api/src/store/sequelize.ts +66 -22
  182. package/api/src/store/sql-migrations.ts +20 -0
  183. package/api/src/store/tenant-backfill.ts +260 -0
  184. package/api/src/store/tenant-model.ts +124 -0
  185. package/api/src/store/tenant-tables.ts +50 -0
  186. package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
  187. package/api/tests/fixtures/bare-query-violation.ts +13 -0
  188. package/api/tests/fixtures/core-env-violation.ts +10 -0
  189. package/api/tests/fixtures/host-read-violation.ts +19 -0
  190. package/api/tests/fixtures/tenants.ts +4 -0
  191. package/api/tests/integrations/iap-tenant.spec.ts +284 -0
  192. package/api/tests/libs/archive-query.spec.ts +26 -0
  193. package/api/tests/libs/audit-tenant.spec.ts +153 -0
  194. package/api/tests/libs/context.spec.ts +204 -0
  195. package/api/tests/libs/core-config.spec.ts +115 -0
  196. package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
  197. package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
  198. package/api/tests/libs/lock-tenant.spec.ts +66 -0
  199. package/api/tests/libs/scoped.spec.ts +222 -0
  200. package/api/tests/libs/secrets-facade.spec.ts +52 -0
  201. package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
  202. package/api/tests/libs/tenant-middleware.spec.ts +42 -0
  203. package/api/tests/libs/tenant-scanner.spec.ts +120 -0
  204. package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
  205. package/api/tests/middlewares/hono/context.spec.ts +113 -0
  206. package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
  207. package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
  208. package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
  209. package/api/tests/middlewares/hono/security.spec.ts +181 -0
  210. package/api/tests/middlewares/hono/session.spec.ts +42 -0
  211. package/api/tests/middlewares/hono/xss.spec.ts +81 -0
  212. package/api/tests/models/tenant-backfill.spec.ts +287 -0
  213. package/api/tests/models/tenant-columns-model.spec.ts +46 -0
  214. package/api/tests/models/tenant-columns.spec.ts +161 -0
  215. package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
  216. package/api/tests/queues/credit-consume.spec.ts +8 -1
  217. package/api/tests/queues/event-tenant.spec.ts +236 -0
  218. package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
  219. package/api/tests/queues/queue-parity.spec.ts +249 -0
  220. package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
  221. package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
  222. package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
  223. package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
  224. package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
  225. package/api/tests/service/collapse.spec.ts +96 -0
  226. package/api/tests/store/tenant-crosscut.spec.ts +202 -0
  227. package/api/tests/store/tenant-model-spike.spec.ts +177 -0
  228. package/api/tests/store/tenant-model.spec.ts +162 -0
  229. package/api/tests/store/tenant-residual.spec.ts +196 -0
  230. package/api/third.d.ts +4 -0
  231. package/blocklet.yml +1 -1
  232. package/cloudflare/README.md +26 -6
  233. package/cloudflare/build.ts +28 -13
  234. package/cloudflare/did-connect-auth.ts +0 -217
  235. package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
  236. package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
  237. package/cloudflare/migrations/0008_schema_parity.sql +16 -0
  238. package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
  239. package/cloudflare/queue-runtime-mode.ts +13 -0
  240. package/cloudflare/run-build.js +10 -56
  241. package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
  242. package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
  243. package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
  244. package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
  245. package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
  246. package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
  247. package/cloudflare/shims/blocklet-sdk/util-csrf.ts +13 -0
  248. package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
  249. package/cloudflare/shims/cron.ts +38 -158
  250. package/cloudflare/shims/events.ts +124 -0
  251. package/cloudflare/shims/fastq.ts +15 -1
  252. package/cloudflare/shims/nedb-storage.ts +16 -8
  253. package/cloudflare/shims/xss.ts +8 -0
  254. package/cloudflare/tenant-middleware.ts +36 -0
  255. package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
  256. package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
  257. package/cloudflare/worker.ts +204 -433
  258. package/cloudflare/wrangler.local-e2e.jsonc +26 -0
  259. package/jest.config.js +3 -1
  260. package/package.json +33 -38
  261. package/scripts/core-env-whitelist.json +1 -0
  262. package/scripts/e2e-12b-runtime.ts +149 -0
  263. package/scripts/e2e-core-config.ts +125 -0
  264. package/scripts/e2e-d1-tenancy.ts +116 -0
  265. package/scripts/e2e-d2-cron-queue.ts +139 -0
  266. package/scripts/e2e-d3-embedded-multi.ts +171 -0
  267. package/scripts/e2e-hono-s2.ts +125 -0
  268. package/scripts/e2e-hono-s3e.ts +135 -0
  269. package/scripts/e2e-hono-s4.ts +114 -0
  270. package/scripts/e2e-migration-contract.ts +100 -0
  271. package/scripts/e2e-s0.ts +61 -0
  272. package/scripts/e2e-s1.ts +107 -0
  273. package/scripts/e2e-s2.ts +178 -0
  274. package/scripts/e2e-s3.ts +110 -0
  275. package/scripts/e2e-s4.ts +191 -0
  276. package/scripts/e2e-s5.ts +139 -0
  277. package/scripts/e2e-s6.ts +127 -0
  278. package/scripts/e2e-tenant-model.ts +119 -0
  279. package/scripts/e2e-tenant-worker.ts +199 -0
  280. package/scripts/gen-sql-migrations.js +46 -0
  281. package/scripts/phase8-codemod.js +219 -0
  282. package/scripts/phase9a-env-getters-codemod.js +82 -0
  283. package/scripts/scan-core-env.js +109 -0
  284. package/scripts/scan-tenant-queries.js +235 -0
  285. package/scripts/schema-drift-guard.ts +210 -0
  286. package/scripts/tenant-scan-whitelist.json +1 -0
  287. package/src/env.d.ts +13 -1
  288. package/tsconfig.json +1 -1
  289. package/api/src/libs/did-space.ts +0 -235
  290. package/api/src/libs/middleware.ts +0 -50
  291. package/api/src/libs/security.ts +0 -192
  292. package/api/src/queues/space.ts +0 -662
  293. package/api/src/routes/credit-tokens.ts +0 -38
  294. package/api/src/routes/exchange-rates.ts +0 -87
  295. package/api/src/routes/index.ts +0 -142
  296. package/api/src/routes/integrations/stripe.ts +0 -61
  297. package/api/src/routes/meters.ts +0 -274
  298. package/api/src/routes/passports.ts +0 -68
  299. package/api/src/routes/redirect.ts +0 -20
  300. package/api/src/routes/tool.ts +0 -65
  301. package/api/src/routes/webhook-endpoints.ts +0 -126
  302. package/api/tests/routes/credit-grants.spec.ts +0 -1261
  303. package/cloudflare/shims/did-space-js.ts +0 -17
  304. package/cloudflare/shims/did-space.ts +0 -11
  305. package/cloudflare/shims/express-compat/index.ts +0 -80
  306. package/cloudflare/shims/express-compat/types.ts +0 -41
  307. package/cloudflare/shims/lock.ts +0 -115
  308. package/cloudflare/shims/queue.ts +0 -611
  309. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
  310. package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
@@ -0,0 +1,168 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Sequelize } from 'sequelize';
5
+ import { SequelizeStorage, Umzug } from 'umzug';
6
+
7
+ import { withTenant } from '../../src/libs/context';
8
+ import { TENANT_CONTEXT_MISSING, TENANT_MISMATCH } from '../../src/libs/tenant';
9
+ import { systemFindByPk } from '../../src/store/scoped';
10
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
11
+
12
+ jest.mock('../../src/libs/logger', () => ({
13
+ __esModule: true,
14
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
15
+ }));
16
+
17
+ const STORE_DIR = path.join(__dirname, '../../src/store');
18
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-matrix-b-'));
19
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
20
+ const umzug = new Umzug({
21
+ migrations: {
22
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
23
+ resolve: ({ name, path: p, context }) => {
24
+ // eslint-disable-next-line import/no-dynamic-require, global-require
25
+ const migration = require(p!);
26
+ return {
27
+ name: name.replace(/\.ts$/, '.js'),
28
+ up: () => migration.up({ context }),
29
+ down: () => migration.down({ context }),
30
+ };
31
+ },
32
+ },
33
+ context: sequelize.getQueryInterface(),
34
+ storage: new SequelizeStorage({ sequelize }),
35
+ logger: undefined,
36
+ });
37
+
38
+ let models: any;
39
+ let createQueue: any;
40
+ let assertJobObjectTenant: any;
41
+
42
+ beforeAll(async () => {
43
+ await umzug.up();
44
+ // eslint-disable-next-line global-require
45
+ models = require('../../src/store/models');
46
+ models.initialize(sequelize);
47
+ // eslint-disable-next-line global-require
48
+ const queueModule = require('../../src/libs/queue');
49
+ createQueue = queueModule.default;
50
+ assertJobObjectTenant = queueModule.assertJobObjectTenant;
51
+ }, 120000);
52
+
53
+ afterAll(async () => {
54
+ await sequelize.close();
55
+ fs.rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ const waitFor = (emitter: any, events: string[]): Promise<{ event: string; data: any }> =>
59
+ new Promise((resolve) => {
60
+ for (const event of events) {
61
+ emitter.on(event, (data: any) => resolve({ event, data }));
62
+ }
63
+ });
64
+
65
+ describe('queue tenant layer — remaining queues (phase 6)', () => {
66
+ beforeEach(async () => {
67
+ await sequelize.query('DELETE FROM coupons');
68
+ await sequelize.query('DELETE FROM jobs');
69
+ });
70
+
71
+ describe('happy path + data damage: phase 6 object-bound handler pattern', () => {
72
+ it('a coupon-bound job (discount-status shape) executes only for its own tenant', async () => {
73
+ const coupon = await withTenant(TENANT_B, () =>
74
+ models.Coupon.create({
75
+ livemode: false,
76
+ instance_did: TENANT_B,
77
+ name: 'b-coupon',
78
+ duration: 'once',
79
+ valid: true,
80
+ created_via: 'api',
81
+ percent_off: 10,
82
+ })
83
+ );
84
+
85
+ const executed: string[] = [];
86
+ const queue = createQueue({
87
+ name: `tmb-coupon-${Date.now()}`,
88
+ onJob: async (job: any) => {
89
+ // load cross-tenant (system) then enforce — mirrors real handlers
90
+ const row: any = await systemFindByPk(models.Coupon, job.id);
91
+ assertJobObjectTenant(row);
92
+ executed.push(row.id);
93
+ },
94
+ });
95
+
96
+ // same tenant: executes
97
+ const ok = await withTenant(TENANT_B, async () => queue.push({ job: { id: coupon.id }, persist: true }));
98
+ const okOutcome = await waitFor(ok, ['finished', 'failed']);
99
+ expect(okOutcome.event).toBe('finished');
100
+ expect(executed).toEqual([coupon.id]);
101
+
102
+ // forged tenant: refused
103
+ const forged = await withTenant(TENANT_A, async () =>
104
+ queue.push({ job: { id: coupon.id }, id: `forged-${coupon.id}`, persist: true })
105
+ );
106
+ const forgedOutcome = await waitFor(forged, ['finished', 'failed']);
107
+ expect(forgedOutcome.event).toBe('failed');
108
+ expect((forgedOutcome.data as any).error?.code).toBe(TENANT_MISMATCH);
109
+ expect(executed).toHaveLength(1);
110
+ });
111
+ });
112
+
113
+ describe('data loss: in-flight legacy jobs across an upgrade window', () => {
114
+ it('single mode consumes a pre-tenant job normally (default tenant)', async () => {
115
+ const seen: string[] = [];
116
+ const queue = createQueue({
117
+ name: `tmb-legacy-${Date.now()}`,
118
+ onJob: async () => {
119
+ // eslint-disable-next-line global-require
120
+ const { getInstanceDid } = require('../../src/libs/context');
121
+ seen.push(getInstanceDid());
122
+ },
123
+ });
124
+ await queue.store.addJob('legacy-b-1', { anything: true }, {});
125
+ const row = await queue.get('legacy-b-1');
126
+ const handle = queue.push({
127
+ job: row,
128
+ id: 'legacy-b-1',
129
+ persist: false,
130
+ skipDuplicateCheck: true,
131
+ fromStore: true,
132
+ });
133
+ const outcome = await waitFor(handle, ['finished', 'failed']);
134
+ expect(outcome.event).toBe('finished');
135
+ // eslint-disable-next-line global-require
136
+ const { getDefaultInstanceDid } = require('../../src/libs/tenant');
137
+ expect(seen).toEqual([getDefaultInstanceDid()]);
138
+ });
139
+ });
140
+
141
+ describe('data leak: multi mode never falls back to a default tenant', () => {
142
+ it('a legacy job is refused with a structured non-retryable error and never executes', async () => {
143
+ const executed: any[] = [];
144
+ const queue = createQueue({
145
+ name: `tmb-legacy-multi-${Date.now()}`,
146
+ onJob: async (job: any) => {
147
+ executed.push(job);
148
+ },
149
+ });
150
+ process.env.PAYMENT_TENANT_MODE = 'multi';
151
+ try {
152
+ const handle = queue.push({
153
+ job: { anything: true },
154
+ id: 'legacy-multi-b',
155
+ persist: false,
156
+ fromStore: true,
157
+ });
158
+ const outcome = await waitFor(handle, ['finished', 'failed']);
159
+ expect(outcome.event).toBe('failed');
160
+ expect((outcome.data as any).error?.code).toBe(TENANT_CONTEXT_MISSING);
161
+ expect((outcome.data as any).error?.nonRetryable).toBe(true);
162
+ expect(executed).toHaveLength(0);
163
+ } finally {
164
+ delete process.env.PAYMENT_TENANT_MODE;
165
+ }
166
+ });
167
+ });
168
+ });
@@ -0,0 +1,107 @@
1
+ // Phase 2 (express→hono) — DID-Connect surface migration. Proves the SAME
2
+ // @blocklet/sdk WalletHandlers attach to a hono app via did-connect-js v4 native
3
+ // attachHono (isHonoApp dispatch, design §3.3): all routes register and a request
4
+ // is dispatched to the generateSession handler over app.fetch. The 14 handlers
5
+ // are unchanged (framework-agnostic CallbackArgs); only the app type changes.
6
+ //
7
+ // NOTE: the session-DEPENDENT spec-table categories (Security ensureSignedJson,
8
+ // Data-loss deep-link, Data-damage appInfo, Data-leak token isolation) and the
9
+ // happy-path session SHAPE are validated over a REAL socket in
10
+ // scripts/e2e-hono-s2.ts (10 self-validating checks, logs/s2-e2e.log). They
11
+ // cannot run in jest: generateSession needs a valid appInfo.icon URI, which jest's
12
+ // bare blocklet env does not provide (the SDK authenticator rejects it). The E2E
13
+ // uses the spike's testSetup env (parsed blocklet.yml → valid appInfo).
14
+ import { buildConnectRoutesHono } from '../../../src/service';
15
+
16
+ const PREFIX = '/api/did';
17
+ // a representative subset of the 14 actions
18
+ const ACTIONS = ['collect', 'payment', 'subscription'];
19
+ const ALL_ACTIONS = [
20
+ 'collect',
21
+ 'collect-batch',
22
+ 'payment',
23
+ 'setup',
24
+ 'subscription',
25
+ 'change-payment',
26
+ 'change-plan',
27
+ 'recharge',
28
+ 'recharge-account',
29
+ 'delegation',
30
+ 'overdraft-protection',
31
+ 're-stake',
32
+ 'auto-recharge-auth',
33
+ 'change-payer',
34
+ ];
35
+
36
+ let connectApp: ReturnType<typeof buildConnectRoutesHono>;
37
+
38
+ beforeAll(() => {
39
+ connectApp = buildConnectRoutesHono();
40
+ });
41
+
42
+ const registeredPaths = (): Set<string> => new Set(((connectApp as any).routes || []).map((r: any) => r.path));
43
+
44
+ describe('Phase 2 — attachHono route registration (all 14 handlers)', () => {
45
+ it('registers token/status/timeout/auth/auth-submit for each action', () => {
46
+ const paths = registeredPaths();
47
+ for (const action of ACTIONS) {
48
+ for (const sub of ['token', 'status', 'timeout', 'auth', 'auth/submit']) {
49
+ expect(paths.has(`${PREFIX}/${action}/${sub}`)).toBe(true);
50
+ }
51
+ }
52
+ });
53
+
54
+ it('registers the full route set (token/status/timeout/auth/auth-submit) for ALL 14 actions', () => {
55
+ const paths = registeredPaths();
56
+ expect(ALL_ACTIONS.length).toBe(14);
57
+ for (const action of ALL_ACTIONS) {
58
+ for (const sub of ['token', 'status', 'timeout', 'auth', 'auth/submit']) {
59
+ expect(paths.has(`${PREFIX}/${action}/${sub}`)).toBe(true);
60
+ }
61
+ }
62
+ });
63
+
64
+ it('a POST to /auth is registered (the signed authInfo endpoint) and dispatched to the handler', async () => {
65
+ const paths = registeredPaths();
66
+ expect(paths.has(`${PREFIX}/payment/auth`)).toBe(true); // POST exists too
67
+ // an unsigned POST with a forged token reaches the handler (500/4xx, never a
68
+ // hono 404) — proving attachHono wired the POST body adapter; the actual
69
+ // ensureSignedJson rejection on a VALID session is in the E2E.
70
+ const res = await connectApp.fetch(
71
+ new Request(`http://app.local${PREFIX}/payment/auth?_t_=deadbeef`, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/json' },
74
+ body: JSON.stringify({ userDid: 'did:abt:zForged', claims: [] }),
75
+ })
76
+ );
77
+ expect(res.status).not.toBe(404);
78
+ const body = await res.text();
79
+ expect(body).not.toContain('"status":"created"'); // never resolves a session for a forged token
80
+ });
81
+ });
82
+
83
+ describe('Phase 2 — attachHono dispatches GET .../token to the handler (over app.fetch)', () => {
84
+ it('reaches the generateSession handler (200 JSON), not a hono 404', async () => {
85
+ // The full successful session (valid appInfo) is validated over a real socket
86
+ // in scripts/e2e-hono-s2.ts (a valid appInfo.icon URI is an env detail jest's
87
+ // bare blocklet env lacks). Here we prove attachHono ROUTED the request to the
88
+ // did-connect handler: a 200 JSON response, never hono's 404 Not Found.
89
+ const res = await connectApp.fetch(new Request(`http://app.local${PREFIX}/collect/token`));
90
+ expect(res.status).toBe(200);
91
+ const body = await res.json(); // handler output is JSON (session OR a did-connect error)
92
+ expect(typeof body).toBe('object');
93
+ });
94
+ });
95
+
96
+ describe('Phase 2 — bad input: an invalid/forged session token does not resolve', () => {
97
+ it('GET .../payment/auth with a non-existent _t_ does not return a valid authInfo/session', async () => {
98
+ const res = await connectApp.fetch(
99
+ new Request(`http://app.local${PREFIX}/payment/auth?_t_=deadbeef-not-a-session`)
100
+ );
101
+ // attachHono surfaces the did-connect error path (4xx or an error-status body),
102
+ // never a created session for a forged token.
103
+ const body = await res.json().catch(() => ({}));
104
+ const looksLikeValidSession = res.status === 200 && (body.status === 'created' || body.authInfo);
105
+ expect(looksLikeValidSession).toBe(false);
106
+ });
107
+ });
@@ -0,0 +1,96 @@
1
+ // Phase 4 (express→hono) — adapter collapse.
2
+ //
3
+ // The loopback http.Server + express app shell are gone: svc.http.fetch is now a
4
+ // thin base-strip wrapper over honoApp.fetch, and the full node app is the hono
5
+ // app itself. This spec locks the two things the collapse must preserve byte-for-
6
+ // byte (the arc consumer contract): the base-strip semantics and raw-body
7
+ // fidelity through the strip, plus the hono app's healthz + onError.
8
+ import { Hono } from 'hono';
9
+ import crypto from 'crypto';
10
+ import { createFetchHandler } from '../../src/libs/http-fetch-adapter';
11
+ import { buildHonoApp } from '../../src/service';
12
+
13
+ // A stand-in "core" hono app: echoes the internal path + the raw body bytes so we
14
+ // can assert exactly what the adapter forwarded after base-stripping.
15
+ function buildEchoApp(): Hono {
16
+ const app = new Hono();
17
+ app.get('/api/echo', (c) => c.json({ path: new URL(c.req.url).pathname }));
18
+ app.post('/api/raw', async (c) => {
19
+ const buf = Buffer.from(await c.req.arrayBuffer());
20
+ return c.json({ len: buf.length, sig: crypto.createHmac('sha256', 'k').update(buf).digest('hex') });
21
+ });
22
+ return app;
23
+ }
24
+
25
+ describe('Phase 4 collapse — svc.http.fetch base-strip over app.fetch', () => {
26
+ const handler = createFetchHandler(buildEchoApp());
27
+
28
+ it('happy: strips the host mount prefix to reach the internal /api/*', async () => {
29
+ const res = await handler(new Request('http://app.local/.well-known/payment/api/echo'), {
30
+ basePath: '/.well-known/payment',
31
+ });
32
+ expect(res.status).toBe(200);
33
+ expect((await res.json()).path).toBe('/api/echo');
34
+ });
35
+
36
+ it('happy: no basePath → request passes through unchanged', async () => {
37
+ const res = await handler(new Request('http://app.local/api/echo'));
38
+ expect((await res.json()).path).toBe('/api/echo');
39
+ });
40
+
41
+ it('bad input: exact segment-boundary strip — basePath itself maps to "/"', async () => {
42
+ // /.well-known/payment (=== basePath) → "/", which the echo app 404s; the
43
+ // point is the strip is applied (not left as the prefix).
44
+ const res = await handler(new Request('http://app.local/.well-known/payment'), {
45
+ basePath: '/.well-known/payment',
46
+ });
47
+ expect(res.status).toBe(404); // "/" has no route — strip happened, didn't match
48
+ });
49
+
50
+ it('bad input: segment-boundary negative — "/mntbeta" is NOT stripped by basePath "/mnt"', async () => {
51
+ // a naive startsWith would wrongly strip "/mnt" from "/mntbeta/..."; the precise
52
+ // === / "+ /" check must leave it intact (→ /mntbeta/api/echo, 404).
53
+ const res = await handler(new Request('http://app.local/mntbeta/api/echo'), { basePath: '/mnt' });
54
+ expect(res.status).toBe(404);
55
+ // the correctly-prefixed twin strips to /api/echo and resolves
56
+ const ok = await handler(new Request('http://app.local/mnt/api/echo'), { basePath: '/mnt' });
57
+ expect(ok.status).toBe(200);
58
+ expect((await ok.json()).path).toBe('/api/echo');
59
+ });
60
+
61
+ it('security/data loss: raw body bytes survive the strip intact (Stripe webhook fidelity)', async () => {
62
+ const payload = JSON.stringify({ evt: 'x', nested: { a: 1, b: '<b>' } });
63
+ const res = await handler(
64
+ new Request('http://app.local/.well-known/payment/api/raw', {
65
+ method: 'POST',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: payload,
68
+ }),
69
+ { basePath: '/.well-known/payment' }
70
+ );
71
+ const body = await res.json();
72
+ expect(body.len).toBe(Buffer.byteLength(payload)); // every byte forwarded
73
+ expect(body.sig).toBe(crypto.createHmac('sha256', 'k').update(payload).digest('hex'));
74
+ });
75
+ });
76
+
77
+ describe('Phase 4 collapse — buildHonoApp surface', () => {
78
+ const app = buildHonoApp();
79
+
80
+ it('serves /api/healthz natively (was on the express resource router)', async () => {
81
+ const res = await app.fetch(new Request('http://app.local/api/healthz'));
82
+ expect(res.status).toBe(200);
83
+ expect(await res.json()).toEqual({ ok: true });
84
+ });
85
+
86
+ it('onError maps a thrown error to 500 JSON (express-async-errors equivalent)', async () => {
87
+ const a = buildHonoApp((native) => {
88
+ native.get('/api/boom', () => {
89
+ throw new Error('kaboom');
90
+ });
91
+ });
92
+ const res = await a.fetch(new Request('http://app.local/api/boom'));
93
+ expect(res.status).toBe(500);
94
+ expect((await res.json()).error).toBe('kaboom');
95
+ });
96
+ });
@@ -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
+ });