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,287 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Sequelize } from 'sequelize';
5
+ import { SequelizeStorage, Umzug } from 'umzug';
6
+
7
+ import { runTenantBackfill } from '../../src/store/tenant-backfill';
8
+ import { TENANT_TABLES } from '../../src/store/tenant-tables';
9
+ import { getDefaultInstanceDid } from '../../src/libs/tenant';
10
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
11
+
12
+ const STORE_DIR = path.join(__dirname, '../../src/store');
13
+
14
+ function createHarness(storagePath: string) {
15
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: storagePath, logging: false });
16
+ const umzug = new Umzug({
17
+ migrations: {
18
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
19
+ resolve: ({ name, path: migrationPath, context }) => {
20
+ // eslint-disable-next-line import/no-dynamic-require, global-require
21
+ const migration = require(migrationPath!);
22
+ return {
23
+ name: name.replace(/\.ts$/, '.js'),
24
+ up: () => migration.up({ context }),
25
+ down: () => migration.down({ context }),
26
+ };
27
+ },
28
+ },
29
+ context: sequelize.getQueryInterface(),
30
+ storage: new SequelizeStorage({ sequelize }),
31
+ logger: undefined,
32
+ });
33
+ return { sequelize, umzug };
34
+ }
35
+
36
+ const now = () => new Date().toISOString();
37
+
38
+ async function countNulls(sequelize: Sequelize, table: string): Promise<number> {
39
+ const [rows] = await sequelize.query(`SELECT COUNT(*) AS n FROM ${table} WHERE instance_did IS NULL`);
40
+ return (rows as any[])[0].n;
41
+ }
42
+
43
+ describe('tenant backfill migration (phase 2)', () => {
44
+ let dir: string;
45
+ let harness: ReturnType<typeof createHarness>;
46
+
47
+ beforeEach(() => {
48
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-backfill-'));
49
+ harness = createHarness(path.join(dir, 'test.db'));
50
+ });
51
+
52
+ afterEach(async () => {
53
+ await harness.sequelize.close();
54
+ fs.rmSync(dir, { recursive: true, force: true });
55
+ });
56
+
57
+ async function migrateToJustBeforeBackfill() {
58
+ await harness.umzug.up();
59
+ await harness.umzug.down(); // revert only the backfill migration (latest)
60
+ }
61
+
62
+ describe('happy path', () => {
63
+ it('backfills every tenant table to zero NULLs with the app DID', async () => {
64
+ await migrateToJustBeforeBackfill();
65
+ const qi = harness.sequelize.getQueryInterface();
66
+ await qi.bulkInsert('customers', [
67
+ { id: 'cus_bf_1', livemode: 0, did: 'z-did-bf-1', delinquent: 0, created_at: now(), updated_at: now() },
68
+ ]);
69
+ await qi.bulkInsert('products', [
70
+ { id: 'prod_bf_1', livemode: 0, active: 1, name: 'bf', type: 'service', created_at: now(), updated_at: now() },
71
+ ]);
72
+
73
+ await harness.umzug.up(); // runs the backfill migration
74
+
75
+ for (const table of TENANT_TABLES) {
76
+ // eslint-disable-next-line no-await-in-loop
77
+ expect({ table, nulls: await countNulls(harness.sequelize, table) }).toEqual({ table, nulls: 0 });
78
+ }
79
+ const [rows] = await harness.sequelize.query("SELECT instance_did FROM customers WHERE id = 'cus_bf_1'");
80
+ expect((rows as any[])[0].instance_did).toBe(getDefaultInstanceDid());
81
+ }, 120000);
82
+
83
+ it('allows (A, key) and (B, key) to coexist after the unique revision', async () => {
84
+ await harness.umzug.up();
85
+ const qi = harness.sequelize.getQueryInterface();
86
+ await qi.bulkInsert('meters', [
87
+ {
88
+ id: 'mtr_a',
89
+ livemode: 0,
90
+ event_name: 'shared.event',
91
+ name: 'a',
92
+ unit: 'unit',
93
+ created_via: 'api',
94
+ instance_did: TENANT_A,
95
+ created_at: now(),
96
+ updated_at: now(),
97
+ },
98
+ {
99
+ id: 'mtr_b',
100
+ livemode: 0,
101
+ event_name: 'shared.event',
102
+ name: 'b',
103
+ unit: 'unit',
104
+ created_via: 'api',
105
+ instance_did: TENANT_B,
106
+ created_at: now(),
107
+ updated_at: now(),
108
+ },
109
+ ]);
110
+ const [rows] = await harness.sequelize.query(
111
+ "SELECT COUNT(*) AS n FROM meters WHERE event_name = 'shared.event'"
112
+ );
113
+ expect((rows as any[])[0].n).toBe(2);
114
+ }, 120000);
115
+ });
116
+
117
+ describe('bad input', () => {
118
+ it('does not overwrite rows that already carry a tenant', async () => {
119
+ await migrateToJustBeforeBackfill();
120
+ const qi = harness.sequelize.getQueryInterface();
121
+ await qi.bulkInsert('customers', [
122
+ {
123
+ id: 'cus_keep',
124
+ livemode: 0,
125
+ did: 'z-did-keep',
126
+ delinquent: 0,
127
+ instance_did: TENANT_B,
128
+ created_at: now(),
129
+ updated_at: now(),
130
+ },
131
+ ]);
132
+ await harness.umzug.up();
133
+ const [rows] = await harness.sequelize.query("SELECT instance_did FROM customers WHERE id = 'cus_keep'");
134
+ expect((rows as any[])[0].instance_did).toBe(TENANT_B);
135
+ }, 120000);
136
+
137
+ it('is safe on empty tables and re-runnable (idempotent)', async () => {
138
+ await harness.umzug.up();
139
+ const first = await runTenantBackfill(harness.sequelize);
140
+ const second = await runTenantBackfill(harness.sequelize);
141
+ expect(Object.values(second.backfilled).every((n) => n === 0)).toBe(true);
142
+ expect(first.instanceDid).toBe(getDefaultInstanceDid());
143
+ }, 120000);
144
+ });
145
+
146
+ describe('security', () => {
147
+ it('derives the backfill value only from the configured app DID getter', async () => {
148
+ await harness.umzug.up();
149
+ const result = await runTenantBackfill(harness.sequelize);
150
+ expect(result.instanceDid).toBe(getDefaultInstanceDid());
151
+ const source = fs.readFileSync(path.join(STORE_DIR, 'tenant-backfill.ts'), 'utf8');
152
+ // no request- or row-content-derived tenant paths
153
+ expect(source).not.toMatch(/req\.|headers|x-forwarded/i);
154
+ expect(source).toContain('getDefaultInstanceDid()');
155
+ }, 120000);
156
+ });
157
+
158
+ describe('data loss', () => {
159
+ it('continues after a simulated mid-run failure without losing rows', async () => {
160
+ await migrateToJustBeforeBackfill();
161
+ const qi = harness.sequelize.getQueryInterface();
162
+ const customers = Array.from({ length: 5 }, (_, i) => ({
163
+ id: `cus_resume_${i}`,
164
+ livemode: 0,
165
+ did: `z-did-resume-${i}`,
166
+ delinquent: 0,
167
+ created_at: now(),
168
+ updated_at: now(),
169
+ }));
170
+ await qi.bulkInsert('customers', customers);
171
+
172
+ // simulate a crash that backfilled only part of the data
173
+ await harness.sequelize.query(
174
+ `UPDATE customers SET instance_did = '${getDefaultInstanceDid()}' WHERE id IN ('cus_resume_0', 'cus_resume_1')`
175
+ );
176
+
177
+ await harness.umzug.up(); // full run picks up the remaining NULL rows
178
+ expect(await countNulls(harness.sequelize, 'customers')).toBe(0);
179
+ const [rows] = await harness.sequelize.query('SELECT COUNT(*) AS n FROM customers');
180
+ expect((rows as any[])[0].n).toBe(5);
181
+ }, 120000);
182
+ });
183
+
184
+ describe('data damage', () => {
185
+ it('backfills payment_currencies from their payment_method tenant (D1)', async () => {
186
+ await migrateToJustBeforeBackfill();
187
+ const qi = harness.sequelize.getQueryInterface();
188
+ await qi.bulkInsert('payment_methods', [
189
+ {
190
+ id: 'pm_join',
191
+ livemode: 0,
192
+ active: 1,
193
+ confirmation: '{}',
194
+ settings: '{}',
195
+ features: '{}',
196
+ instance_did: TENANT_B, // pre-tagged method from another tenant
197
+ created_at: now(),
198
+ updated_at: now(),
199
+ },
200
+ ]);
201
+ await qi.bulkInsert('payment_currencies', [
202
+ {
203
+ id: 'pc_join',
204
+ active: 1,
205
+ livemode: 0,
206
+ payment_method_id: 'pm_join',
207
+ name: 'Join Coin',
208
+ logo: 'http://x/l.png',
209
+ symbol: 'JC',
210
+ decimal: 8,
211
+ created_at: now(),
212
+ updated_at: now(),
213
+ },
214
+ {
215
+ id: 'pc_orphan',
216
+ active: 1,
217
+ livemode: 0,
218
+ payment_method_id: 'pm_missing',
219
+ name: 'Orphan Coin',
220
+ logo: 'http://x/l.png',
221
+ symbol: 'OC',
222
+ decimal: 8,
223
+ created_at: now(),
224
+ updated_at: now(),
225
+ },
226
+ ]);
227
+ await harness.umzug.up();
228
+ const [rows] = await harness.sequelize.query(
229
+ "SELECT id, instance_did FROM payment_currencies WHERE id IN ('pc_join', 'pc_orphan') ORDER BY id"
230
+ );
231
+ const byId = Object.fromEntries((rows as any[]).map((r) => [r.id, r.instance_did]));
232
+ expect(byId.pc_join).toBe(TENANT_B); // follows its method
233
+ expect(byId.pc_orphan).toBe(getDefaultInstanceDid()); // dangling -> default
234
+ }, 120000);
235
+ });
236
+
237
+ describe('data leak', () => {
238
+ it('rejects a duplicate promotion code within one tenant, allows it across tenants', async () => {
239
+ await harness.umzug.up();
240
+ const qi = harness.sequelize.getQueryInterface();
241
+ const promo = (id: string, tenant: string) => ({
242
+ id,
243
+ livemode: 0,
244
+ active: 1,
245
+ code: 'CODE1',
246
+ coupon_id: 'coup_x',
247
+ instance_did: tenant,
248
+ created_at: now(),
249
+ updated_at: now(),
250
+ });
251
+ await qi.bulkInsert('promotion_codes', [promo('promo_a', TENANT_A)]);
252
+ await qi.bulkInsert('promotion_codes', [promo('promo_b', TENANT_B)]); // cross-tenant ok
253
+ await expect(qi.bulkInsert('promotion_codes', [promo('promo_a2', TENANT_A)])).rejects.toThrow(
254
+ /unique|validation/i
255
+ );
256
+ const [rows] = await harness.sequelize.query("SELECT COUNT(*) AS n FROM promotion_codes WHERE code = 'CODE1'");
257
+ expect((rows as any[])[0].n).toBe(2);
258
+ }, 120000);
259
+
260
+ it('customers: same user DID may exist under two tenants after the rebuild', async () => {
261
+ await harness.umzug.up();
262
+ const qi = harness.sequelize.getQueryInterface();
263
+ const cust = (id: string, tenant: string) => ({
264
+ id,
265
+ livemode: 0,
266
+ did: 'z-shared-user',
267
+ delinquent: 0,
268
+ instance_did: tenant,
269
+ created_at: now(),
270
+ updated_at: now(),
271
+ });
272
+ await qi.bulkInsert('customers', [cust('cus_ta', TENANT_A)]);
273
+ await qi.bulkInsert('customers', [cust('cus_tb', TENANT_B)]);
274
+ await expect(qi.bulkInsert('customers', [cust('cus_ta2', TENANT_A)])).rejects.toThrow(/unique|validation/i);
275
+ }, 120000);
276
+ });
277
+
278
+ describe('negative: backfill without phase 1', () => {
279
+ it('fails loudly with a missing-column error and writes nothing', async () => {
280
+ // migrate to BEFORE the phase 1 column migration: down twice
281
+ await harness.umzug.up();
282
+ await harness.umzug.down(); // revert backfill
283
+ await harness.umzug.down(); // revert tenant columns
284
+ await expect(runTenantBackfill(harness.sequelize)).rejects.toThrow(/instance_did missing/i);
285
+ }, 120000);
286
+ });
287
+ });
@@ -0,0 +1,46 @@
1
+ import { Sequelize } from 'sequelize';
2
+
3
+ import models, { Customer, initialize } from '../../src/store/models';
4
+ import { TENANT_TABLES } from '../../src/store/tenant-tables';
5
+ import { TENANT_A } from '../fixtures/tenants';
6
+
7
+ // Isolated from tenant-columns.spec.ts on purpose: initialize() mutates the
8
+ // model singletons (associations add FK clauses to attributes), which would
9
+ // corrupt the migration harness used there.
10
+ const sequelize = new Sequelize('sqlite::memory:', { logging: false });
11
+ initialize(sequelize);
12
+ afterAll(() => sequelize.close());
13
+
14
+ const MODEL_BY_TABLE = Object.fromEntries(Object.values(models).map((model: any) => [model.tableName, model]));
15
+
16
+ describe('tenant column model declarations (phase 1)', () => {
17
+ it('every tenant table model declares instance_did as a nullable attribute', () => {
18
+ for (const table of TENANT_TABLES) {
19
+ const model: any = MODEL_BY_TABLE[table];
20
+ expect({ table, hasModel: Boolean(model) }).toEqual({ table, hasModel: true });
21
+ const attribute = model.getAttributes().instance_did;
22
+ expect({ table, hasAttribute: Boolean(attribute) }).toEqual({ table, hasAttribute: true });
23
+ expect({ table, allowNull: attribute.allowNull !== false }).toEqual({ table, allowNull: true });
24
+ }
25
+ });
26
+
27
+ it('model instances can read and write instance_did', () => {
28
+ const built = Customer.build({
29
+ livemode: false,
30
+ did: 'z-test-did',
31
+ delinquent: false,
32
+ instance_did: TENANT_A,
33
+ } as any);
34
+ expect(built.instance_did).toBe(TENANT_A);
35
+ built.instance_did = TENANT_A.replace('A', 'X');
36
+ expect(built.instance_did).toBe(TENANT_A.replace('A', 'X'));
37
+ });
38
+
39
+ it('exempt tables do not gain the column', () => {
40
+ for (const table of ['jobs', 'locks', 'archive_locks', 'archive_metadata', 'exchange_rate_providers']) {
41
+ const model: any = MODEL_BY_TABLE[table];
42
+ expect({ table, hasModel: Boolean(model) }).toEqual({ table, hasModel: true });
43
+ expect({ table, attr: model.getAttributes().instance_did }).toEqual({ table, attr: undefined });
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,161 @@
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 { TENANT_TABLES } from '../../src/store/tenant-tables';
8
+
9
+ const STORE_DIR = path.join(__dirname, '../../src/store');
10
+ const TENANT_MIGRATION = '20260610-tenant-columns';
11
+ // pin the chain to phase 1: phase 2 (tenant backfill) intentionally rebuilds
12
+ // some tables with NOT NULL + composite uniques, which this spec must not see
13
+ const upToPhase1 = (umzug: Umzug<any>) => umzug.up({ to: `${TENANT_MIGRATION}.js` } as any);
14
+
15
+ function createHarness(storagePath: string) {
16
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: storagePath, logging: false });
17
+ const umzug = new Umzug({
18
+ migrations: {
19
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
20
+ resolve: ({ name, path: migrationPath, context }) => {
21
+ // eslint-disable-next-line import/no-dynamic-require, global-require
22
+ const migration = require(migrationPath!);
23
+ return {
24
+ name: name.replace(/\.ts$/, '.js'),
25
+ up: () => migration.up({ context }),
26
+ down: () => migration.down({ context }),
27
+ };
28
+ },
29
+ },
30
+ context: sequelize.getQueryInterface(),
31
+ storage: new SequelizeStorage({ sequelize }),
32
+ logger: undefined,
33
+ });
34
+ return { sequelize, umzug };
35
+ }
36
+
37
+ async function tableColumns(sequelize: Sequelize, table: string): Promise<Record<string, any>[]> {
38
+ const [rows] = await sequelize.query(`PRAGMA table_info(${table})`);
39
+ return rows as Record<string, any>[];
40
+ }
41
+
42
+ describe('tenant columns migration (phase 1)', () => {
43
+ let dir: string;
44
+ let dbPath: string;
45
+ let harness: ReturnType<typeof createHarness>;
46
+
47
+ beforeEach(() => {
48
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tenant-columns-'));
49
+ dbPath = path.join(dir, 'test.db');
50
+ harness = createHarness(dbPath);
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await harness.sequelize.close();
55
+ fs.rmSync(dir, { recursive: true, force: true });
56
+ });
57
+
58
+ describe('happy path', () => {
59
+ it('adds a nullable instance_did column to all 38 tenant tables', async () => {
60
+ await upToPhase1(harness.umzug);
61
+ for (const table of TENANT_TABLES) {
62
+ // eslint-disable-next-line no-await-in-loop
63
+ const columns = await tableColumns(harness.sequelize, table);
64
+ const column = columns.find((c) => c.name === 'instance_did');
65
+ expect({ table, found: Boolean(column) }).toEqual({ table, found: true });
66
+ expect({ table, notnull: column!.notnull }).toEqual({ table, notnull: 0 });
67
+ expect({ table, dflt: column!.dflt_value ?? null }).toEqual({ table, dflt: null });
68
+ }
69
+ }, 60000);
70
+
71
+ // "model layer exposes instance_did" lives in tenant-columns-model.spec.ts:
72
+ // initializing the model singletons here would mutate GENESIS_ATTRIBUTES
73
+ // (associations add FK clauses) and corrupt the migration harness below.
74
+ });
75
+
76
+ describe('bad input: re-running up is idempotent', () => {
77
+ it('skips existing columns without error and without duplicating them', async () => {
78
+ await upToPhase1(harness.umzug);
79
+ // call the migration's up() directly a second time, bypassing umzug bookkeeping
80
+ // eslint-disable-next-line import/no-dynamic-require, global-require
81
+ const migration = require(path.join(STORE_DIR, 'migrations', `${TENANT_MIGRATION}.ts`));
82
+ await migration.up({ context: harness.sequelize.getQueryInterface() });
83
+ const columns = await tableColumns(harness.sequelize, 'customers');
84
+ expect(columns.filter((c) => c.name === 'instance_did')).toHaveLength(1);
85
+ }, 60000);
86
+ });
87
+
88
+ describe('security: migration is built from static literals only', () => {
89
+ it('contains no env/config interpolation into SQL identifiers', () => {
90
+ const migrationSource = fs.readFileSync(path.join(STORE_DIR, 'migrations', `${TENANT_MIGRATION}.ts`), 'utf8');
91
+ expect(migrationSource).not.toContain('process.env');
92
+ expect(migrationSource).not.toContain('globalThis');
93
+ const listSource = fs.readFileSync(path.join(STORE_DIR, 'tenant-tables.ts'), 'utf8');
94
+ expect(listSource).not.toContain('process.env');
95
+ // every table entry is a quoted literal
96
+ const entries = listSource.match(/^\s*'[a-z_]+',$/gm) || [];
97
+ expect(entries.length).toBe(38);
98
+ });
99
+ });
100
+
101
+ describe('data loss / data damage: existing rows survive up and down', () => {
102
+ it('preserves pre-existing rows and their values through up, and through down', async () => {
103
+ // migrate to just before the tenant migration, then seed
104
+ await upToPhase1(harness.umzug);
105
+ await harness.umzug.down(); // revert the tenant column migration
106
+
107
+ const qi = harness.sequelize.getQueryInterface();
108
+ const now = new Date().toISOString();
109
+ await qi.bulkInsert('customers', [
110
+ {
111
+ id: 'cus_phase1_test1',
112
+ livemode: 0,
113
+ did: 'z-did-phase1-1',
114
+ delinquent: 0,
115
+ created_at: now,
116
+ updated_at: now,
117
+ },
118
+ ]);
119
+
120
+ // up: row survives, all original values intact, instance_did = NULL
121
+ await upToPhase1(harness.umzug);
122
+ const [afterUp] = await harness.sequelize.query(
123
+ "SELECT id, livemode, did, delinquent, instance_did FROM customers WHERE id = 'cus_phase1_test1'"
124
+ );
125
+ expect(afterUp).toHaveLength(1);
126
+ const row = (afterUp as any[])[0];
127
+ expect(row.did).toBe('z-did-phase1-1');
128
+ expect(row.livemode).toBe(0);
129
+ expect(row.instance_did).toBeNull();
130
+
131
+ // down: column removed, row and non-tenant values still intact
132
+ await harness.umzug.down();
133
+ const columns = await tableColumns(harness.sequelize, 'customers');
134
+ expect(columns.find((c) => c.name === 'instance_did')).toBeUndefined();
135
+ const [afterDown] = await harness.sequelize.query("SELECT id, did FROM customers WHERE id = 'cus_phase1_test1'");
136
+ expect(afterDown).toHaveLength(1);
137
+ expect((afterDown as any[])[0].did).toBe('z-did-phase1-1');
138
+ }, 120000);
139
+ });
140
+
141
+ describe('data leak: the new column is never auto-filled', () => {
142
+ it('defaults to NULL for rows written without an explicit tenant', async () => {
143
+ await upToPhase1(harness.umzug);
144
+ const qi = harness.sequelize.getQueryInterface();
145
+ const now = new Date().toISOString();
146
+ await qi.bulkInsert('products', [
147
+ {
148
+ id: 'prod_phase1_leak',
149
+ livemode: 0,
150
+ active: 1,
151
+ name: 'leak-check',
152
+ type: 'service',
153
+ created_at: now,
154
+ updated_at: now,
155
+ },
156
+ ]);
157
+ const [rows] = await harness.sequelize.query("SELECT instance_did FROM products WHERE id = 'prod_phase1_leak'");
158
+ expect((rows as any[])[0].instance_did).toBeNull();
159
+ }, 60000);
160
+ });
161
+ });
@@ -68,7 +68,14 @@ jest.mock('../../src/libs/queue', () => {
68
68
  delete: jest.fn().mockResolvedValue(undefined),
69
69
  on: jest.fn(),
70
70
  };
71
- return jest.fn().mockReturnValue(mockQueue);
71
+ const factory: any = jest.fn().mockReturnValue(mockQueue);
72
+ return {
73
+ __esModule: true,
74
+ default: factory,
75
+ // phase 5/6: tenant invariant helper — pass-through in these unit tests
76
+ // (tenant enforcement has its own suites: tenant-matrix-a/b)
77
+ assertJobObjectTenant: jest.fn(),
78
+ };
72
79
  });
73
80
 
74
81
  jest.mock('../../src/store/models', () => ({
@@ -71,7 +71,14 @@ jest.mock('../../src/libs/queue', () => {
71
71
  delete: jest.fn().mockResolvedValue(undefined),
72
72
  on: jest.fn(),
73
73
  };
74
- return jest.fn().mockReturnValue(mockQueue);
74
+ const factory: any = jest.fn().mockReturnValue(mockQueue);
75
+ return {
76
+ __esModule: true,
77
+ default: factory,
78
+ // phase 5/6: tenant invariant helper — pass-through in these unit tests
79
+ // (tenant enforcement has its own suites: tenant-matrix-a/b)
80
+ assertJobObjectTenant: jest.fn(),
81
+ };
75
82
  });
76
83
 
77
84
  jest.mock('../../src/store/models', () => ({