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,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';
@@ -0,0 +1,284 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Hono } from 'hono';
5
+ import { Sequelize } from 'sequelize';
6
+ import { SequelizeStorage, Umzug } from 'umzug';
7
+
8
+ import { withTenant } from '../../src/libs/context';
9
+ import { TENANT_A, TENANT_B } from '../fixtures/tenants';
10
+
11
+ jest.mock('../../src/libs/logger', () => ({
12
+ __esModule: true,
13
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
14
+ }));
15
+
16
+ // capture the tenant the verified-event handler runs under
17
+ const handleGooglePlayEvent = jest.fn().mockImplementation(async () => {
18
+ // eslint-disable-next-line global-require
19
+ const { getInstanceDid } = require('../../src/libs/context');
20
+ handlerTenants.push(getInstanceDid());
21
+ });
22
+ const handlerTenants: string[] = [];
23
+ jest.mock('../../src/integrations/google-play/handlers', () => ({
24
+ __esModule: true,
25
+ default: (...args: any[]) => handleGooglePlayEvent(...args),
26
+ }));
27
+
28
+ const handleAppStoreNotification = jest.fn().mockImplementation(async () => {
29
+ // eslint-disable-next-line global-require
30
+ const { getInstanceDid } = require('../../src/libs/context');
31
+ appStoreTenants.push(getInstanceDid());
32
+ });
33
+ const appStoreTenants: string[] = [];
34
+ jest.mock('../../src/integrations/app-store/handlers', () => ({
35
+ __esModule: true,
36
+ default: (...args: any[]) => handleAppStoreNotification(...args),
37
+ }));
38
+ // unverified routing peek: bundleId comes straight from our fake payload
39
+ jest.mock('../../src/integrations/app-store/notification-routing', () => ({
40
+ __esModule: true,
41
+ peekNotificationRouting: (signedPayload: string) => JSON.parse(signedPayload),
42
+ }));
43
+
44
+ const STORE_DIR = path.join(__dirname, '../../src/store');
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'iap-tenant-'));
46
+ const sequelize = new Sequelize({ dialect: 'sqlite', storage: path.join(dir, 'test.db'), logging: false });
47
+ const umzug = new Umzug({
48
+ migrations: {
49
+ glob: ['migrations/*.ts', { cwd: STORE_DIR }],
50
+ resolve: ({ name, path: p, context }) => {
51
+ // eslint-disable-next-line import/no-dynamic-require, global-require
52
+ const migration = require(p!);
53
+ return {
54
+ name: name.replace(/\.ts$/, '.js'),
55
+ up: () => migration.up({ context }),
56
+ down: () => migration.down({ context }),
57
+ };
58
+ },
59
+ },
60
+ context: sequelize.getQueryInterface(),
61
+ storage: new SequelizeStorage({ sequelize }),
62
+ logger: undefined,
63
+ });
64
+
65
+ let models: any;
66
+ let app: Hono;
67
+
68
+ const rtdnEnvelope = (packageName: string) => ({
69
+ message: {
70
+ messageId: `msg-${Math.random().toString(36).slice(2)}`,
71
+ data: Buffer.from(
72
+ JSON.stringify({
73
+ version: '1.0',
74
+ packageName,
75
+ eventTimeMillis: String(Date.now()),
76
+ subscriptionNotification: {
77
+ version: '1.0',
78
+ notificationType: 4,
79
+ purchaseToken: 'token-x',
80
+ subscriptionId: 'sku-x',
81
+ },
82
+ })
83
+ ).toString('base64'),
84
+ },
85
+ subscription: 'projects/x/subscriptions/y',
86
+ });
87
+
88
+ const postWebhook = async (body: any): Promise<{ status: number; json: any }> => {
89
+ const res = await app.fetch(
90
+ new Request('http://app.local/api/integrations/google-play/webhook', {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify(body),
94
+ })
95
+ );
96
+ return { status: res.status, json: JSON.parse((await res.text()) || '{}') };
97
+ };
98
+
99
+ const seedMethod = (tenant: string, packageName: string) =>
100
+ withTenant(tenant, () =>
101
+ models.PaymentMethod.create({
102
+ livemode: false,
103
+ active: true,
104
+ type: 'google_play',
105
+ instance_did: tenant,
106
+ confirmation: { type: 'callback' },
107
+ features: { recurring: true, refund: true, dispute: false },
108
+ settings: models.PaymentMethod.encryptSettings({
109
+ google_play: { package_name: packageName, service_account_json: '{"client_email":"x","private_key":"y"}' },
110
+ }),
111
+ })
112
+ );
113
+
114
+ beforeAll(async () => {
115
+ await umzug.up();
116
+ // eslint-disable-next-line global-require
117
+ models = require('../../src/store/models');
118
+ models.initialize(sequelize);
119
+ // eslint-disable-next-line global-require
120
+ const googlePlay = require('../../src/routes/hono/integrations/google-play').default;
121
+ // eslint-disable-next-line global-require
122
+ const appStore = require('../../src/routes/hono/integrations/app-store').default;
123
+ // eslint-disable-next-line global-require
124
+ const { mountResourceGroup } = require('../../src/middlewares/hono/resource-mount');
125
+ // Mount exactly as production does (app-shell pipeline → sanitizedBody/livemode
126
+ // populated, csrf skipped without a cookie). Drive via app.fetch — single-mode
127
+ // test env, so contextMiddleware resolves the default tenant without a Host.
128
+ app = new Hono();
129
+ mountResourceGroup(app, '/api/integrations/google-play', googlePlay);
130
+ mountResourceGroup(app, '/api/integrations/app-store', appStore);
131
+ }, 120000);
132
+
133
+ afterAll(async () => {
134
+ await sequelize.close();
135
+ fs.rmSync(dir, { recursive: true, force: true });
136
+ });
137
+
138
+ beforeEach(async () => {
139
+ // restoreMocks=true resets spies after each test — re-install per test.
140
+ // the App Store client is exercised elsewhere; here it must only verify
141
+ jest
142
+ .spyOn(models.PaymentMethod.prototype, 'getAppStoreClient')
143
+ .mockReturnValue({ verifyNotificationPayload: async (p: string) => JSON.parse(p) } as any);
144
+ handleGooglePlayEvent.mockClear();
145
+ handleAppStoreNotification.mockClear();
146
+ handlerTenants.length = 0;
147
+ appStoreTenants.length = 0;
148
+ await sequelize.query('DELETE FROM payment_methods');
149
+ await sequelize.query('DELETE FROM prices');
150
+ await sequelize.query('DELETE FROM products');
151
+ });
152
+
153
+ const seedAppStoreMethod = (tenant: string, bundleId: string) =>
154
+ withTenant(tenant, () =>
155
+ models.PaymentMethod.create({
156
+ livemode: false,
157
+ active: true,
158
+ type: 'app_store',
159
+ instance_did: tenant,
160
+ confirmation: { type: 'callback' },
161
+ features: { recurring: true, refund: false, dispute: false },
162
+ settings: models.PaymentMethod.encryptSettings({
163
+ app_store: { bundle_id: bundleId, environment: 'production' },
164
+ }),
165
+ })
166
+ );
167
+
168
+ const postAppStoreWebhook = async (routing: {
169
+ bundleId: string;
170
+ environment?: string;
171
+ }): Promise<{ status: number; json: any }> => {
172
+ const res = await app.fetch(
173
+ new Request('http://app.local/api/integrations/app-store/webhook', {
174
+ method: 'POST',
175
+ headers: { 'content-type': 'application/json' },
176
+ body: JSON.stringify({ signedPayload: JSON.stringify(routing) }),
177
+ })
178
+ );
179
+ return { status: res.status, json: JSON.parse((await res.text()) || '{}') };
180
+ };
181
+
182
+ describe('IAP channel-identifier tenant reverse lookup (phase 6)', () => {
183
+ it('happy path: RTDN routes to the tenant that registered the package_name', async () => {
184
+ await seedMethod(TENANT_A, 'com.tenant.a');
185
+ await seedMethod(TENANT_B, 'com.tenant.b');
186
+
187
+ const res = await postWebhook(rtdnEnvelope('com.tenant.b'));
188
+ expect(res.status).toBe(200);
189
+ expect(res.json).toEqual({ received: true });
190
+ expect(handleGooglePlayEvent).toHaveBeenCalledTimes(1);
191
+ expect(handlerTenants).toEqual([TENANT_B]);
192
+ });
193
+
194
+ it('bad input: unregistered package_name is acked-skipped without touching any tenant', async () => {
195
+ await seedMethod(TENANT_A, 'com.tenant.a');
196
+ const res = await postWebhook(rtdnEnvelope('com.unknown.app'));
197
+ expect(res.status).toBe(200);
198
+ expect(res.json).toEqual({ skipped: true });
199
+ expect(handleGooglePlayEvent).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('security: ambiguous registration (same package under two tenants) is refused', async () => {
203
+ await seedMethod(TENANT_A, 'com.shared.app');
204
+ await seedMethod(TENANT_B, 'com.shared.app');
205
+ const res = await postWebhook(rtdnEnvelope('com.shared.app'));
206
+ expect(res.status).toBe(200);
207
+ expect(res.json.reason).toContain('ambiguous');
208
+ expect(handleGooglePlayEvent).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('app-store: JWS bundleId routes to the registering tenant; ambiguity refused', async () => {
212
+ await seedAppStoreMethod(TENANT_A, 'com.ios.a');
213
+ await seedAppStoreMethod(TENANT_B, 'com.ios.b');
214
+
215
+ const res = await postAppStoreWebhook({ bundleId: 'com.ios.b', environment: 'production' });
216
+ expect(res.status).toBe(200);
217
+ expect(res.json).toEqual({ received: true });
218
+ expect(appStoreTenants).toEqual([TENANT_B]);
219
+
220
+ // ambiguity: register the SAME bundle under another tenant -> refused
221
+ await seedAppStoreMethod(TENANT_A, 'com.ios.b');
222
+ const dup = await postAppStoreWebhook({ bundleId: 'com.ios.b', environment: 'production' });
223
+ expect(dup.json.reason).toContain('ambiguous');
224
+ expect(appStoreTenants).toHaveLength(1); // no second delivery
225
+ });
226
+
227
+ it('data damage: two tenants with the same SKU resolve to their own Price via bundle scoping', async () => {
228
+ const seedPrice = (tenant: string, bundleId: string, marker: string) =>
229
+ withTenant(tenant, async () => {
230
+ const product = await models.Product.create({
231
+ livemode: false,
232
+ active: true,
233
+ instance_did: tenant,
234
+ name: marker,
235
+ type: 'service',
236
+ });
237
+ return models.Price.create({
238
+ livemode: false,
239
+ active: true,
240
+ instance_did: tenant,
241
+ product_id: product.id,
242
+ type: 'recurring',
243
+ billing_scheme: 'per_unit',
244
+ unit_amount: '100',
245
+ currency_options: [],
246
+ metadata: { app_store_product_id: 'sku.shared', bundle_id: bundleId },
247
+ nickname: marker,
248
+ });
249
+ });
250
+ const priceA = await seedPrice(TENANT_A, 'com.ios.a', 'price-a');
251
+ const priceB = await seedPrice(TENANT_B, 'com.ios.b', 'price-b');
252
+
253
+ // the (sku, bundle_id) lookup used by the IAP handlers. The handler first
254
+ // reverse-resolves the tenant from the registering PaymentMethod, then runs
255
+ // the price lookup under withTenant(tenant) — TenantModel scopes it to that
256
+ // tenant's row even though both share the SKU.
257
+ const foundA: any = await withTenant(TENANT_A, () =>
258
+ models.Price.findOne({
259
+ where: { 'metadata.app_store_product_id': 'sku.shared', 'metadata.bundle_id': 'com.ios.a' } as any,
260
+ })
261
+ );
262
+ const foundB: any = await withTenant(TENANT_B, () =>
263
+ models.Price.findOne({
264
+ where: { 'metadata.app_store_product_id': 'sku.shared', 'metadata.bundle_id': 'com.ios.b' } as any,
265
+ })
266
+ );
267
+ expect(foundA.id).toBe(priceA.id);
268
+ expect(foundA.instance_did).toBe(TENANT_A);
269
+ expect(foundB.id).toBe(priceB.id);
270
+ expect(foundB.instance_did).toBe(TENANT_B);
271
+ });
272
+
273
+ it('data leak: a forged notification can only ever land in the registering tenant', async () => {
274
+ // even if an attacker controls the payload entirely, the tenant is chosen
275
+ // by the server-side registration, never by payload contents
276
+ await seedMethod(TENANT_A, 'com.tenant.a');
277
+ const res = await postWebhook({
278
+ ...rtdnEnvelope('com.tenant.a'),
279
+ attacker: { wants: TENANT_B },
280
+ });
281
+ expect(res.status).toBe(200);
282
+ expect(handlerTenants).toEqual([TENANT_A]);
283
+ });
284
+ });
@@ -124,6 +124,32 @@ describe('archive/query', () => {
124
124
  expect(mockClose).toHaveBeenCalled();
125
125
  });
126
126
 
127
+ it('洞 G: the data SELECT on a tenant table is instance_did-guarded', async () => {
128
+ const mockClose = jest.fn();
129
+ let dataSql = '';
130
+ let dataOpts: any = null;
131
+ const mockQuery = jest.fn().mockImplementation((sql: string, opts: any) => {
132
+ if (sql.includes('sqlite_master')) return [[{ name: 'invoices' }]];
133
+ dataSql = sql;
134
+ dataOpts = opts;
135
+ return [];
136
+ });
137
+ mockListArchiveFiles.mockReturnValue(['/tmp/archive-2024.db']);
138
+ mockOpenArchiveSequelize.mockReturnValue({ query: mockQuery, close: mockClose } as any);
139
+ mockArchiveMetadataFindAll.mockResolvedValue([]);
140
+
141
+ await queryArchive({
142
+ table: 'invoices',
143
+ from: Math.floor(new Date('2024-01-01').getTime() / 1000),
144
+ page: 1,
145
+ limit: 10,
146
+ });
147
+
148
+ // the archived tenant-table read carries an instance_did predicate + bind
149
+ expect(dataSql).toMatch(/instance_did/);
150
+ expect(dataOpts?.replacements?.instance_did).toBeTruthy();
151
+ });
152
+
127
153
  it('should skip archive files that do not have the requested table', async () => {
128
154
  const mockClose = jest.fn();
129
155
  // Return empty array for sqlite_master query (table doesn't exist)