@cosmicdrift/kumiko-samples 0.389.0 → 0.390.1

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 (301) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +27 -15
  3. package/packages/bundled-features/src/audit/feature.ts +1 -1
  4. package/packages/bundled-features/src/auth-email-password/auth-user-row.ts +1 -1
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +13 -1
  6. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  7. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -2
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +7 -7
  9. package/packages/bundled-features/src/auth-email-password/handlers/confirm-account-unlock.write.ts +3 -3
  10. package/packages/bundled-features/src/auth-email-password/handlers/confirm-token-flow.ts +5 -5
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +9 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +11 -7
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -4
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-info.query.ts +2 -2
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +9 -7
  16. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +23 -16
  17. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +3 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +12 -12
  19. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +12 -8
  20. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +4 -2
  21. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -2
  22. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +3 -2
  23. package/packages/bundled-features/src/auth-email-password/seeding.ts +14 -9
  24. package/packages/bundled-features/src/auth-foundation/feature.ts +13 -4
  25. package/packages/bundled-features/src/auth-mfa/handlers/disable.write.ts +5 -5
  26. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +24 -14
  27. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
  28. package/packages/bundled-features/src/auth-mfa/handlers/enable-start-preauth.write.ts +6 -1
  29. package/packages/bundled-features/src/auth-mfa/handlers/enable-start.write.ts +3 -1
  30. package/packages/bundled-features/src/auth-mfa/handlers/reencrypt.job.ts +14 -9
  31. package/packages/bundled-features/src/auth-mfa/handlers/regenerate-recovery.write.ts +5 -4
  32. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +25 -14
  33. package/packages/bundled-features/src/auth-mfa/mfa-code-verifier.ts +10 -4
  34. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +9 -3
  35. package/packages/bundled-features/src/auth-mfa/verify-factor.ts +1 -1
  36. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +2 -2
  37. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +1 -1
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +5 -5
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +10 -7
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +15 -12
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-open-cancels.ts +22 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-provider-lock.ts +4 -0
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +4 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +17 -58
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +10 -8
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +18 -15
  48. package/packages/bundled-features/src/billing-foundation/events.ts +1 -1
  49. package/packages/bundled-features/src/billing-foundation/feature.ts +8 -3
  50. package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +1 -1
  51. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +5 -5
  52. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +3 -3
  53. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +2 -2
  54. package/packages/bundled-features/src/billing-foundation/projection.ts +1 -1
  55. package/packages/bundled-features/src/billing-foundation/types.ts +1 -1
  56. package/packages/bundled-features/src/cap-counter/aggregate-id.ts +1 -1
  57. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +5 -4
  58. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +1 -1
  59. package/packages/bundled-features/src/cap-counter/constants.ts +1 -1
  60. package/packages/bundled-features/src/cap-counter/feature.ts +2 -2
  61. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +1 -1
  62. package/packages/bundled-features/src/cap-counter/reservation-chain.ts +1 -1
  63. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +4 -4
  64. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -1
  65. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +1 -1
  66. package/packages/bundled-features/src/compliance-profiles/seeding.ts +12 -7
  67. package/packages/bundled-features/src/config/feature.ts +6 -6
  68. package/packages/bundled-features/src/config/handlers/cascade.query.ts +1 -1
  69. package/packages/bundled-features/src/config/handlers/readiness.query.ts +1 -1
  70. package/packages/bundled-features/src/config/handlers/reencrypt.job.ts +23 -13
  71. package/packages/bundled-features/src/config/handlers/reset.write.ts +3 -3
  72. package/packages/bundled-features/src/config/handlers/selected-extensions.query.ts +1 -1
  73. package/packages/bundled-features/src/config/handlers/set.write.ts +4 -4
  74. package/packages/bundled-features/src/config/handlers/values.query.ts +1 -1
  75. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +8 -8
  76. package/packages/bundled-features/src/crypto-shredding/resolve-file-bytes-deleter.ts +3 -3
  77. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  78. package/packages/bundled-features/src/custom-fields/handlers/clear-custom-field.write.ts +1 -1
  79. package/packages/bundled-features/src/custom-fields/handlers/delete-system-field.write.ts +1 -1
  80. package/packages/bundled-features/src/custom-fields/handlers/delete-tenant-field.write.ts +1 -1
  81. package/packages/bundled-features/src/custom-fields/handlers/set-custom-field.write.ts +1 -1
  82. package/packages/bundled-features/src/custom-fields/index.ts +4 -0
  83. package/packages/bundled-features/src/data-retention/feature.ts +1 -1
  84. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +12 -2
  85. package/packages/bundled-features/src/delivery/attempt-log.ts +1 -1
  86. package/packages/bundled-features/src/delivery/delivery-service.ts +6 -5
  87. package/packages/bundled-features/src/delivery/feature.ts +2 -2
  88. package/packages/bundled-features/src/delivery/jobs.ts +11 -11
  89. package/packages/bundled-features/src/delivery/testing.ts +1 -1
  90. package/packages/bundled-features/src/document-ingest-foundation/events.ts +1 -1
  91. package/packages/bundled-features/src/feature-toggles/handlers/registered.query.ts +1 -1
  92. package/packages/bundled-features/src/feature-toggles/handlers/set.write.ts +1 -1
  93. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +1 -1
  94. package/packages/bundled-features/src/file-derivatives/feature.ts +2 -2
  95. package/packages/bundled-features/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +5 -1
  96. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +7 -5
  97. package/packages/bundled-features/src/file-foundation/feature.ts +1 -1
  98. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -5
  99. package/packages/bundled-features/src/file-provider-s3/feature.ts +4 -1
  100. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +8 -8
  101. package/packages/bundled-features/src/form-draft/feature.ts +1 -1
  102. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +22 -12
  103. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +3 -3
  104. package/packages/bundled-features/src/form-draft/handlers/get.query.ts +1 -1
  105. package/packages/bundled-features/src/foundation-shared/config-helpers.ts +1 -1
  106. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +1 -1
  107. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  108. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-state.ts +2 -2
  109. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  110. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +1 -1
  111. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +10 -10
  112. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +1 -1
  113. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +1 -1
  114. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +1 -1
  115. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +4 -1
  117. package/packages/bundled-features/src/jobs/changes.json +6 -0
  118. package/packages/bundled-features/src/jobs/events.ts +1 -1
  119. package/packages/bundled-features/src/jobs/handlers/consumer.write.ts +7 -4
  120. package/packages/bundled-features/src/jobs/handlers/projection-rebuild.job.ts +1 -1
  121. package/packages/bundled-features/src/jobs/handlers/reindex-entity.job.ts +11 -5
  122. package/packages/bundled-features/src/jobs/handlers/retention-cleanup.job.ts +1 -1
  123. package/packages/bundled-features/src/jobs/handlers/retry.write.ts +1 -1
  124. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +1 -1
  125. package/packages/bundled-features/src/jobs/handlers/trigger.write.ts +2 -2
  126. package/packages/bundled-features/src/jobs/job-run-logger.ts +139 -90
  127. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  128. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +12 -8
  129. package/packages/bundled-features/src/mail-foundation/feature.ts +2 -2
  130. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  131. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -1
  132. package/packages/bundled-features/src/managed-pages/seeding.ts +13 -8
  133. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  134. package/packages/bundled-features/src/personal-access-tokens/feature.ts +1 -1
  135. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +2 -2
  136. package/packages/bundled-features/src/personal-access-tokens/handlers/revoke.write.ts +1 -1
  137. package/packages/bundled-features/src/rate-limiting/handlers/status.query.ts +2 -2
  138. package/packages/bundled-features/src/readiness/handlers/status.query.ts +4 -1
  139. package/packages/bundled-features/src/renderer-foundation/api.ts +3 -3
  140. package/packages/bundled-features/src/secrets/announce-secret-change.ts +4 -4
  141. package/packages/bundled-features/src/secrets/feature.ts +9 -9
  142. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +4 -3
  143. package/packages/bundled-features/src/secrets/handlers/rotate.job.ts +13 -8
  144. package/packages/bundled-features/src/secrets/handlers/set.write.ts +4 -3
  145. package/packages/bundled-features/src/secrets/secrets-context.ts +16 -6
  146. package/packages/bundled-features/src/secrets/write-gate.ts +1 -1
  147. package/packages/bundled-features/src/seo/feature.ts +1 -1
  148. package/packages/bundled-features/src/sessions/changes.json +12 -0
  149. package/packages/bundled-features/src/sessions/feature.ts +1 -1
  150. package/packages/bundled-features/src/sessions/handlers/cleanup.job.ts +2 -2
  151. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +5 -2
  152. package/packages/bundled-features/src/sessions/handlers/list.query.ts +5 -2
  153. package/packages/bundled-features/src/sessions/handlers/mine.query.ts +5 -2
  154. package/packages/bundled-features/src/sessions/handlers/revoke-all-for-user.write.ts +3 -3
  155. package/packages/bundled-features/src/sessions/handlers/revoke-all-others.write.ts +1 -1
  156. package/packages/bundled-features/src/sessions/handlers/revoke.write.ts +1 -1
  157. package/packages/bundled-features/src/sessions/screens.ts +8 -4
  158. package/packages/bundled-features/src/sessions/session-revoked-event.ts +1 -1
  159. package/packages/bundled-features/src/sessions/web/session-device-cell.tsx +10 -12
  160. package/packages/bundled-features/src/sessions/web/user-agent.ts +12 -0
  161. package/packages/bundled-features/src/shared/index.ts +15 -0
  162. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +12 -2
  163. package/packages/bundled-features/src/shared/require-for-tenant.ts +7 -5
  164. package/packages/bundled-features/src/shared/stream-tenant-backfill.ts +201 -0
  165. package/packages/bundled-features/src/shared/stream-tenant-derived-state.ts +107 -0
  166. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -1
  167. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +1 -1
  168. package/packages/bundled-features/src/subscription-dodo/runtime.ts +7 -7
  169. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +5 -3
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -7
  171. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  172. package/packages/bundled-features/src/template-resolver/changes.json +18 -0
  173. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +5 -4
  174. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +2 -2
  175. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +1 -1
  176. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +2 -2
  177. package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +1 -1
  178. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +2 -2
  179. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +6 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +4 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +1 -1
  182. package/packages/bundled-features/src/template-resolver/seeding.ts +12 -2
  183. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +18 -13
  184. package/packages/bundled-features/src/template-resolver/template-resources.ts +4 -4
  185. package/packages/bundled-features/src/template-resolver/testing.ts +2 -2
  186. package/packages/bundled-features/src/template-resolver/web/document-insert-fields.tsx +222 -0
  187. package/packages/bundled-features/src/template-resolver/web/document-insert-model.ts +164 -0
  188. package/packages/bundled-features/src/template-resolver/web/document-insert-panel.tsx +115 -0
  189. package/packages/bundled-features/src/template-resolver/web/document-tiptap-bubble.tsx +177 -0
  190. package/packages/bundled-features/src/template-resolver/web/document-tiptap-content.tsx +38 -0
  191. package/packages/bundled-features/src/template-resolver/web/document-tiptap-context.ts +119 -0
  192. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +51 -1098
  193. package/packages/bundled-features/src/template-resolver/web/document-tiptap-helpers.ts +161 -0
  194. package/packages/bundled-features/src/template-resolver/web/document-tiptap-layout.tsx +83 -0
  195. package/packages/bundled-features/src/template-resolver/web/document-tiptap-toolbar-groups.tsx +153 -0
  196. package/packages/bundled-features/src/template-resolver/web/document-tiptap-toolbar.tsx +182 -0
  197. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +18 -0
  198. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +19 -6
  199. package/packages/bundled-features/src/template-resolver/web/use-document-tiptap-model.ts +138 -0
  200. package/packages/bundled-features/src/tenant/changes.json +19 -0
  201. package/packages/bundled-features/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  202. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +1 -1
  203. package/packages/bundled-features/src/tenant/handlers/remove-member.write.ts +2 -2
  204. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -1
  205. package/packages/bundled-features/src/tenant/index.ts +5 -0
  206. package/packages/bundled-features/src/tenant/invited-membership.ts +6 -1
  207. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -1
  208. package/packages/bundled-features/src/tenant/seeding.ts +65 -62
  209. package/packages/bundled-features/src/tenant/stream-tenant-backfill.ts +115 -0
  210. package/packages/bundled-features/src/tenant/testing.ts +77 -0
  211. package/packages/bundled-features/src/tenant-handover/events.ts +1 -1
  212. package/packages/bundled-features/src/tenant-handover/handlers/claim.write.ts +1 -1
  213. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +5 -5
  214. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +22 -10
  215. package/packages/bundled-features/src/tenant-lifecycle/handlers/request-destruction.write.ts +22 -11
  216. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -12
  217. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +7 -3
  218. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +9 -8
  219. package/packages/bundled-features/src/tier-engine/entity.ts +1 -1
  220. package/packages/bundled-features/src/tier-engine/feature.ts +8 -3
  221. package/packages/bundled-features/src/tier-engine/handlers/active-tier.query.ts +3 -3
  222. package/packages/bundled-features/src/tier-engine/handlers/get-tenant-tier.query.ts +7 -1
  223. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +3 -3
  224. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +71 -97
  225. package/packages/bundled-features/src/user/handlers/find-for-auth.query.ts +2 -2
  226. package/packages/bundled-features/src/user/handlers/update-roles.ts +2 -2
  227. package/packages/bundled-features/src/user/index.ts +2 -0
  228. package/packages/bundled-features/src/user/invited-global-roles.ts +6 -1
  229. package/packages/bundled-features/src/user/principal-status.ts +1 -1
  230. package/packages/bundled-features/src/user/seeding.ts +24 -6
  231. package/packages/bundled-features/src/user/testing.ts +22 -0
  232. package/packages/bundled-features/src/user-data-rights/COMPLIANCE.md +1 -1
  233. package/packages/bundled-features/src/user-data-rights/README.md +1 -1
  234. package/packages/bundled-features/src/user-data-rights/audit-download.ts +12 -2
  235. package/packages/bundled-features/src/user-data-rights/changes.json +24 -0
  236. package/packages/bundled-features/src/user-data-rights/feature.ts +29 -24
  237. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +1 -1
  238. package/packages/bundled-features/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  239. package/packages/bundled-features/src/user-data-rights/handlers/download-by-job.query.ts +2 -2
  240. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +2 -2
  241. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -1
  242. package/packages/bundled-features/src/user-data-rights/handlers/restrict-account.write.ts +4 -4
  243. package/packages/bundled-features/src/user-data-rights/handlers/run-forget-cleanup.write.ts +12 -6
  244. package/packages/bundled-features/src/user-data-rights/index.ts +2 -0
  245. package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
  246. package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
  247. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +6 -1
  248. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +8 -2
  249. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +14 -3
  250. package/packages/bundled-features/src/user-data-rights/run-user-export.ts +11 -1
  251. package/packages/bundled-features/src/user-data-rights/web/constants.ts +10 -0
  252. package/packages/bundled-features/src/user-data-rights/web/index.ts +6 -1
  253. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -6
  254. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +1 -1
  255. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +2 -2
  256. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +1 -1
  257. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +9 -4
  258. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +6 -1
  259. package/packages/bundled-features/src/waitlist/link-on-signup.ts +7 -1
  260. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  261. package/packages/bundled-features/src/workflow-runner/db/queries/due-runs.ts +1 -1
  262. package/packages/bundled-features/src/workflow-runner/event-subscriber.ts +4 -3
  263. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  264. package/packages/bundled-features/src/workflow-runner/feature.ts +2 -2
  265. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +8 -8
  266. package/packages/bundled-features/src/workflow-runner/runner.ts +6 -4
  267. package/samples/apps/cap-billing-demo/package.json +1 -1
  268. package/samples/apps/cap-billing-demo/src/feature.ts +19 -5
  269. package/samples/apps/config-demo/src/app/server.ts +1 -1
  270. package/samples/apps/styleguide/src/app/server.ts +2 -2
  271. package/samples/apps/ui-walkthrough/package.json +1 -1
  272. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  273. package/samples/apps/use-all-bundled/package.json +1 -1
  274. package/samples/apps/use-all-bundled/src/app/seed.ts +12 -2
  275. package/samples/apps/user-data-rights-demo/README.md +1 -1
  276. package/samples/apps/user-data-rights-demo/package.json +1 -1
  277. package/samples/apps/user-data-rights-demo/src/feature.ts +3 -2
  278. package/samples/recipes/api-worker-split/README.md +2 -1
  279. package/samples/recipes/api-worker-split/src/feature.ts +2 -1
  280. package/samples/recipes/cross-feature-events/README.md +5 -5
  281. package/samples/recipes/cross-feature-events/package.json +1 -1
  282. package/samples/recipes/cross-feature-events/src/feature.ts +4 -4
  283. package/samples/recipes/event-sourcing/README.md +5 -5
  284. package/samples/recipes/event-sourcing/package.json +1 -1
  285. package/samples/recipes/event-sourcing/src/feature.ts +15 -15
  286. package/samples/recipes/files-post-processing/README.md +1 -1
  287. package/samples/recipes/files-post-processing/package.json +1 -1
  288. package/samples/recipes/files-post-processing/src/feature.ts +6 -4
  289. package/samples/recipes/lane-routing/package.json +1 -1
  290. package/samples/recipes/legal-pages/README.md +2 -2
  291. package/samples/recipes/lifecycle-hooks/README.md +2 -2
  292. package/samples/recipes/lifecycle-hooks/src/feature.ts +1 -1
  293. package/samples/recipes/managed-config/README.md +3 -3
  294. package/samples/recipes/search/README.md +1 -1
  295. package/samples/recipes/search/src/feature.ts +3 -3
  296. package/samples/recipes/secrets-demo/README.md +1 -1
  297. package/samples/recipes/secrets-demo/src/feature.ts +5 -2
  298. package/samples/recipes/state-machine/src/events.ts +1 -1
  299. package/samples/recipes/state-machine/src/reducer.ts +1 -1
  300. package/samples/recipes/workflow-engine/src/resume-loop.ts +4 -4
  301. package/samples/recipes/workflow-engine/src/workflow-runner.ts +2 -2
@@ -50,8 +50,8 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
50
50
  escapeHatch: {
51
51
  grants: ["systemIdentity", "unsafeRaw"],
52
52
  reason:
53
- "Pre-auth MFA step has no session yet — re-derives it via ctx.queryAs(SYSTEM, " +
54
- "user:findForAuth) and ctx.resolveActiveMembership for the user the challenge token names. " +
53
+ "Pre-auth MFA step has no session yet — re-derives it via ctx.dispatch.queryAs(SYSTEM, " +
54
+ "user:findForAuth) and ctx.auth.resolveActiveMembership for the user the challenge token names. " +
55
55
  "Also reads the MFA enrollment of the tenant named in the signed login/setup token, not the " +
56
56
  "guest dispatch tenant.",
57
57
  },
@@ -71,14 +71,15 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
71
71
  // this route sits behind a challenge token from a successful login,
72
72
  // but its own secret is still just a short TOTP/recovery code —
73
73
  // silently skipping the brute-force cap and single-use burn when
74
- // ctx.redis is absent would let a stolen challenge token be guessed
74
+ // ctx.services.redis is absent would let a stolen challenge token be guessed
75
75
  // against unboundedly, and let a recovery code be replayed via a
76
76
  // reissued challenge (mfa-verify-attempts.ts's counter is deliberately
77
77
  // NOT reset by reissuance, precisely to survive this).
78
- if (!ctx.redis) {
78
+ if (!ctx.services.redis) {
79
79
  return writeFailure(
80
80
  new InternalError({
81
- message: "verify (mfa) requires ctx.redis for the brute-force cap and single-use burn",
81
+ message:
82
+ "verify (mfa) requires ctx.services.redis for the brute-force cap and single-use burn",
82
83
  }),
83
84
  );
84
85
  }
@@ -86,7 +87,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
86
87
  // Per-account brute-force cap — survives challenge-token reissuance
87
88
  // on purpose (see mfa-verify-attempts.ts header). Checked BEFORE the
88
89
  // TOTP/recovery verify so a locked account can't be guessed against.
89
- const state = await getMfaVerifyLockoutState(ctx.redis, userId);
90
+ const state = await getMfaVerifyLockoutState(ctx.services.redis, userId);
90
91
  if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
91
92
  const now = Date.now();
92
93
  if (state.lockedUntil > now) {
@@ -98,7 +99,12 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
98
99
  // "system" mode: this handler runs with a guest identity whose own
99
100
  // tenantId is meaningless here — the challenge token is the source
100
101
  // of truth for which tenant's row to read.
101
- const scopedDb = createTenantDb({ db: ctx.db.unsafeRaw(), tenantId, mode: "system" });
102
+ const scopedDb = createTenantDb({
103
+ db: ctx.db.unsafeRaw(),
104
+ tenantId,
105
+ mode: "system",
106
+ reason: "MFA challenge has no session, the tenant comes from the verified challenge token",
107
+ });
102
108
  const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
103
109
  const row = await findUserMfaRow(scopedDb, scopedUser);
104
110
  // MFA got disabled between login and verify (race, or a stale
@@ -106,10 +112,10 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
106
112
  // bad code, no detail leak about account state.
107
113
  if (!row) return invalidChallengeToken();
108
114
 
109
- const replay = { redis: ctx.redis, userId };
115
+ const replay = { redis: ctx.services.redis, userId };
110
116
  const verify = await verifyMfaFactor(row, event.payload.code, replay);
111
117
  if (!verify.ok) {
112
- await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
118
+ await recordFailedMfaVerifyAttempt(ctx.services.redis, userId, maxAttempts, lockoutMinutes);
113
119
  return invalidTotpCode();
114
120
  }
115
121
 
@@ -121,7 +127,12 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
121
127
  // is reached, so this burn only needs to guard a DIFFERENT code
122
128
  // (recovery, or a still-valid-but-not-yet-burned TOTP step) reused
123
129
  // against the same challenge token.
124
- const burnResult = await burnToken(ctx.redis, "mfa-challenge", userId, verified.expiresAtMs);
130
+ const burnResult = await burnToken(
131
+ ctx.services.redis,
132
+ "mfa-challenge",
133
+ userId,
134
+ verified.expiresAtMs,
135
+ );
125
136
  if (burnResult === "already-used") return invalidChallengeToken();
126
137
 
127
138
  // Recovery-code use consumes the code — persist the reduced hash-list
@@ -140,13 +151,13 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
140
151
  if (!updateResult.isSuccess) return updateResult;
141
152
  }
142
153
 
143
- await clearMfaVerifyAttempts(ctx.redis, userId);
154
+ await clearMfaVerifyAttempts(ctx.services.redis, userId);
144
155
 
145
156
  // Re-derive the full session the way login.write.ts would have, for
146
157
  // the SAME tenant the challenge token already committed to (no
147
158
  // "pick a tenant" step here — that already happened at login time).
148
159
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
149
- const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
160
+ const userRow = (await ctx.dispatch.queryAs(systemUser, UserQueries.findForAuth, {
150
161
  id: userId,
151
162
  })) as {
152
163
  roles?: string | null;
@@ -174,7 +185,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
174
185
 
175
186
  // Membership revoked or principal blocked between login and verify —
176
187
  // mirror login.write.ts's refusal instead of falling back to globalRoles only.
177
- const active = await ctx.resolveActiveMembership(userId, tenantId);
188
+ const active = await ctx.auth.resolveActiveMembership(userId, tenantId);
178
189
  if (active.kind === "rejected") return invalidChallengeToken();
179
190
  // buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
180
191
  // roles from the membership portion (globalRoles keeps SystemAdmin) —
@@ -188,7 +199,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
188
199
  ...sessionTimezoneField(userRow?.timezone),
189
200
  ...sessionLocaleField(userRow?.locale),
190
201
  };
191
- const claims = await ctx.resolveAuthClaims(baseSession);
202
+ const claims = await ctx.auth.resolveClaims(baseSession);
192
203
  const session: SessionUser =
193
204
  Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
194
205
 
@@ -29,13 +29,19 @@ export function createMfaCodeVerifier(): MfaCodeVerifier {
29
29
  reason:
30
30
  "reads the MFA enrollment of the user being re-authenticated on the caller's handler context; the calling handler declares its own escapeHatch",
31
31
  });
32
- const scopedDb = createTenantDb({ db: ctx.db.unsafeRaw(), tenantId, mode: "system" });
32
+ const scopedDb = createTenantDb({
33
+ db: ctx.db.unsafeRaw(),
34
+ tenantId,
35
+ mode: "system",
36
+ reason:
37
+ "re-authentication reads the MFA enrollment of the user's own tenant, not the handler's dispatch tenant",
38
+ });
33
39
  const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
34
40
  if (!row) return { enrolled: false, ok: false };
35
- // Fail closed without ctx.redis, matching disable.write.ts/verify.write.ts —
41
+ // Fail closed without ctx.services.redis, matching disable.write.ts/verify.write.ts —
36
42
  // skipping the TOTP-replay burn would let an observed code be replayed.
37
- if (!code || !ctx.redis) return { enrolled: true, ok: false };
38
- const verify = await verifyMfaFactor(row, code, { redis: ctx.redis, userId });
43
+ if (!code || !ctx.services.redis) return { enrolled: true, ok: false };
44
+ const verify = await verifyMfaFactor(row, code, { redis: ctx.services.redis, userId });
39
45
  return { enrolled: true, ok: verify.ok && verify.method === "totp" };
40
46
  };
41
47
  }
@@ -48,7 +48,13 @@ export function createMfaStatusChecker(opts: {
48
48
  reason:
49
49
  "reads the MFA enrollment of the tenant named in the signed login/setup token, not the guest dispatch tenant, on the caller's handler context; the calling handler declares its own escapeHatch",
50
50
  });
51
- const scopedDb = createTenantDb({ db: ctx.db.unsafeRaw(), tenantId, mode: "system" });
51
+ const scopedDb = createTenantDb({
52
+ db: ctx.db.unsafeRaw(),
53
+ tenantId,
54
+ mode: "system",
55
+ reason:
56
+ "login/setup token names the tenant to check, the guest dispatch tenant is meaningless",
57
+ });
52
58
  const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
53
59
  const row = await findUserMfaRow(scopedDb, scopedUser);
54
60
 
@@ -65,12 +71,12 @@ export function createMfaStatusChecker(opts: {
65
71
  }
66
72
 
67
73
  // ctx.config is bound to the CALLING user's tenant (the anonymous
68
- // identity's tenant for login) — wrong tenant here. ctx.configResolver takes tenantId
74
+ // identity's tenant for login) — wrong tenant here. ctx.services.configResolver takes tenantId
69
75
  // explicitly, which is what a pre-session check against the LOGGING-IN
70
76
  // user's tenant needs.
71
77
  // @cast-boundary engine-payload — "select" config values are validated
72
78
  // against the declared `options` at write-time by the config feature.
73
- const policy = ((await ctx.configResolver?.get(
79
+ const policy = ((await ctx.services.configResolver?.get(
74
80
  mfaRequiredConfigHandle.name,
75
81
  mfaRequiredConfigKey(opts.requiredPolicy),
76
82
  tenantId,
@@ -21,7 +21,7 @@ export type MfaFactorVerifyResult =
21
21
  // must not verify again within its ±1-step window — otherwise a code an
22
22
  // attacker observed once, via phishing proxy or shoulder-surfing, stays
23
23
  // usable for ~90s of parallel logins). Omitted where a caller has no
24
- // ctx.redis available; the code-verify itself still works, just without
24
+ // ctx.services.redis available; the code-verify itself still works, just without
25
25
  // replay protection for that call.
26
26
  export async function verifyMfaFactor(
27
27
  row: UserMfaRow,
@@ -68,8 +68,8 @@ export function createBillingInfoQueryConfig<TTier extends string>(
68
68
  const billingLive = ctx.config
69
69
  ? (await ctx.config(STRIPE_BILLING_LIVE_CONFIG_QN)) === true
70
70
  : false;
71
- const apiKeySet = ctx.secrets
72
- ? await ctx.secrets.has(SYSTEM_TENANT_ID, STRIPE_API_KEY_CONFIG_QN)
71
+ const apiKeySet = ctx.services.secrets
72
+ ? await ctx.services.secrets.has(SYSTEM_TENANT_ID, STRIPE_API_KEY_CONFIG_QN)
73
73
  : false;
74
74
 
75
75
  const sub = await ctx.db.fetchOne<{
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.390.0",
4
+ "type": "fix",
5
+ "title": "Revoking one termination no longer resumes a subscription another termination still cancels",
6
+ "detail": "A revocation only resumes the subscription at the provider when no other recorded termination still cancels it. Recording and revoking a termination now run one at a time per tenant, so provider calls can no longer interleave with the event stream."
7
+ },
2
8
  {
3
9
  "version": "0.388.0",
4
10
  "type": "fix",
@@ -172,7 +172,7 @@ export async function recordConsent(
172
172
  };
173
173
  const tenantId = ctx.user.tenantId;
174
174
  const isSubscription = prepared.mode === "subscription";
175
- await ctx.unsafeAppendEvent({
175
+ await ctx.events.unsafeAppend({
176
176
  aggregateId: isSubscription ? subscriptionAggregateId(tenantId) : paymentAggregateId(tenantId),
177
177
  aggregateType: isSubscription ? SUBSCRIPTION_AGGREGATE_TYPE : PAYMENT_AGGREGATE_TYPE,
178
178
  type: CHECKOUT_CONSENT_RECORDED_EVENT_QN,
@@ -102,7 +102,7 @@ export function createIssueContractConfirmationHandler(
102
102
  });
103
103
  }
104
104
 
105
- const stream = await ctx.fetchForWriting({
105
+ const stream = await ctx.events.fetchForWriting({
106
106
  aggregateId: payload.sourceAggregateId,
107
107
  aggregateType: isSubscription ? SUBSCRIPTION_AGGREGATE_TYPE : PAYMENT_AGGREGATE_TYPE,
108
108
  });
@@ -119,7 +119,7 @@ export function createIssueContractConfirmationHandler(
119
119
  }
120
120
  }
121
121
  if (!consentEvent || !consent) {
122
- ctx.log?.warn(
122
+ ctx.obs.log?.warn(
123
123
  `[billing-foundation:issue-contract-confirmation] no recorded consent ${payload.consentId} on the stream; no mail sent`,
124
124
  );
125
125
  return {
@@ -150,7 +150,7 @@ export function createIssueContractConfirmationHandler(
150
150
  // Everything that can fail runs before the append, so a failed run
151
151
  // leaves no marker behind and the job retry starts clean.
152
152
  const recipient = recipientRowSchema.safeParse(
153
- await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, {
153
+ await ctx.dispatch.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, {
154
154
  id: consent.actorUserId,
155
155
  }),
156
156
  );
@@ -162,7 +162,7 @@ export function createIssueContractConfirmationHandler(
162
162
  )
163
163
  : null;
164
164
  if (!recipient.success || recipientEmail === null) {
165
- ctx.log?.warn(
165
+ ctx.obs.log?.warn(
166
166
  `[billing-foundation:issue-contract-confirmation] recipient of consent ${payload.consentId} not found; no mail sent`,
167
167
  );
168
168
  return {
@@ -182,7 +182,7 @@ export function createIssueContractConfirmationHandler(
182
182
  locale,
183
183
  });
184
184
  if (terms.version !== consent.termsTemplateVersion) {
185
- ctx.log?.warn(
185
+ ctx.obs.log?.warn(
186
186
  `[billing-foundation:issue-contract-confirmation] terms text block changed since consent ${payload.consentId} (version ${consent.termsTemplateVersion} -> ${terms.version}); the mail carries the current text`,
187
187
  );
188
188
  }
@@ -76,13 +76,16 @@ export function registerConsumerProtection(
76
76
  // user), never from payload data.
77
77
  const tenantId = ctx.systemUser.tenantId;
78
78
  const currentPeriodEndIso = payload["currentPeriodEndIso"];
79
- const result = await ctx.write(SubscriptionFoundationHandlers.issueContractConfirmation, {
80
- consentId,
81
- sourceAggregateId: isSubscriptionEvent
82
- ? subscriptionAggregateId(tenantId)
83
- : paymentAggregateId(tenantId),
84
- ...(typeof currentPeriodEndIso === "string" && { currentPeriodEndIso }),
85
- });
79
+ const result = await ctx.dispatch.write(
80
+ SubscriptionFoundationHandlers.issueContractConfirmation,
81
+ {
82
+ consentId,
83
+ sourceAggregateId: isSubscriptionEvent
84
+ ? subscriptionAggregateId(tenantId)
85
+ : paymentAggregateId(tenantId),
86
+ ...(typeof currentPeriodEndIso === "string" && { currentPeriodEndIso }),
87
+ },
88
+ );
86
89
  if (!result.isSuccess) {
87
90
  throw new Error(
88
91
  `billing-foundation:${trigger.jobName}: issue-contract-confirmation failed: ${JSON.stringify(result.error)}`,
@@ -80,18 +80,21 @@ export function registerContractTermination(
80
80
  const declared = contractTerminationDeclaredPayloadSchema.safeParse(payload);
81
81
  // skip: trigger event carries no usable declaration
82
82
  if (!declared.success) return;
83
- const result = await ctx.write(SubscriptionFoundationHandlers.recordContractTermination, {
84
- requestId: declared.data.requestId,
85
- declarationType: declared.data.declarationType,
86
- terminationKind: declared.data.terminationKind,
87
- channel: "public",
88
- when:
89
- declared.data.declarationType === "withdrawal"
90
- ? "none"
91
- : SubscriptionCancelTimings.periodEnd,
92
- receivedAtIso: declared.data.receivedAtIso,
93
- locale: declared.data.locale,
94
- });
83
+ const result = await ctx.dispatch.write(
84
+ SubscriptionFoundationHandlers.recordContractTermination,
85
+ {
86
+ requestId: declared.data.requestId,
87
+ declarationType: declared.data.declarationType,
88
+ terminationKind: declared.data.terminationKind,
89
+ channel: "public",
90
+ when:
91
+ declared.data.declarationType === "withdrawal"
92
+ ? "none"
93
+ : SubscriptionCancelTimings.periodEnd,
94
+ receivedAtIso: declared.data.receivedAtIso,
95
+ locale: declared.data.locale,
96
+ },
97
+ );
95
98
  if (!result.isSuccess) {
96
99
  throw new Error(
97
100
  `billing-foundation:cancel-on-public-termination-declared: record-contract-termination failed: ${JSON.stringify(result.error)}`,
@@ -0,0 +1,22 @@
1
+ import {
2
+ CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
3
+ contractTerminationRequestedPayloadSchema,
4
+ } from "../events.ts";
5
+ import { hasRevocation } from "./termination-record.ts";
6
+
7
+ // A revoked declaration must not resume a subscription another declaration still cancels at the provider.
8
+ export function hasOtherOpenProviderCancel(
9
+ events: readonly { type: string; payload: unknown }[],
10
+ requestId: string,
11
+ ): boolean {
12
+ return events.some((e) => {
13
+ if (e.type !== CONTRACT_TERMINATION_REQUESTED_EVENT_QN) return false;
14
+ const recorded = contractTerminationRequestedPayloadSchema.safeParse(e.payload).data;
15
+ return (
16
+ recorded !== undefined &&
17
+ recorded.requestId !== requestId &&
18
+ recorded.providerCancel !== "none" &&
19
+ !hasRevocation(events, recorded.requestId)
20
+ );
21
+ });
22
+ }
@@ -0,0 +1,4 @@
1
+ // pg_advisory_xact_lock namespace (int4): 'btpl' as ASCII, disjoint from the other fixed advisory-lock keys.
2
+ export const TERMINATION_PROVIDER_LOCK_NAMESPACE = 0x6274706c;
3
+
4
+ export const TERMINATION_PROVIDER_LOCK_KEY = "subscription";
@@ -24,22 +24,22 @@ export async function mayMailReceipt(
24
24
  email: string,
25
25
  ): Promise<ReceiptMailVerdict> {
26
26
  // skip: no rate limiter wired, nothing to count against
27
- if (!ctx.rateLimit) return "allowed";
27
+ if (!ctx.services.rateLimit) return "allowed";
28
28
  try {
29
29
  const bucketKey = receiptBucketKey(ctx, email);
30
30
  if (bucketKey === undefined) {
31
- ctx.log?.warn(
31
+ ctx.obs.log?.warn(
32
32
  "[billing-foundation:termination] no rate-limit digest available, receipt mail withheld",
33
33
  );
34
34
  return "unavailable";
35
35
  }
36
- const decision = await ctx.rateLimit.check(bucketKey, {
36
+ const decision = await ctx.services.rateLimit.check(bucketKey, {
37
37
  limit: RECEIPT_MAILS_PER_RECIPIENT,
38
38
  windowSeconds: RECEIPT_WINDOW_SECONDS,
39
39
  });
40
40
  return decision.allowed ? "allowed" : "limited";
41
41
  } catch (error) {
42
- ctx.log?.warn(
42
+ ctx.obs.log?.warn(
43
43
  `[billing-foundation:termination] receipt rate limiter failed: ${error instanceof Error ? error.message : String(error)}`,
44
44
  );
45
45
  return "unavailable";
@@ -39,6 +39,10 @@ import {
39
39
  FALLBACK_CONSENT_LOCALE,
40
40
  } from "../web/consumer-protection/consent-locale.ts";
41
41
  import { notifyOperator } from "./termination-notify.ts";
42
+ import {
43
+ TERMINATION_PROVIDER_LOCK_KEY,
44
+ TERMINATION_PROVIDER_LOCK_NAMESPACE,
45
+ } from "./termination-provider-lock.ts";
42
46
  import type { OperatorNoticeReason } from "./termination-texts.ts";
43
47
  import { isWithinWithdrawalPeriod } from "./withdrawal-period.ts";
44
48
 
@@ -141,7 +145,7 @@ async function cancelAtProvider(
141
145
  when,
142
146
  });
143
147
  } catch (error) {
144
- ctx.log?.warn(
148
+ ctx.obs.log?.warn(
145
149
  `[billing-foundation:record-contract-termination] provider cancel failed for tenant ${tenantId}: ${error instanceof Error ? error.message : String(error)}`,
146
150
  );
147
151
  return { providerCancel: "none", effectiveAtIso: null, noticeReason: "provider_error" };
@@ -165,7 +169,7 @@ export function createDeclareContractTerminationHandler(): WriteHandlerDef {
165
169
  handler: async (event, ctx) => {
166
170
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
167
171
  const payload = event.payload as z.infer<typeof declareContractTerminationSchema>;
168
- const stream = await ctx.fetchForWriting({
172
+ const stream = await ctx.events.fetchForWriting({
169
173
  aggregateId: subscriptionAggregateId(event.user.tenantId),
170
174
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
171
175
  });
@@ -189,38 +193,6 @@ function revokedRecordResult(receivedAtIso: string) {
189
193
  };
190
194
  }
191
195
 
192
- // A revocation that landed during the provider cancel found nothing to take back yet.
193
- async function takeBackLateRevokedCancel(
194
- ctx: HandlerContext,
195
- consumerProtection: ConsumerProtectionOptions,
196
- args: {
197
- readonly payload: z.infer<typeof recordContractTerminationSchema>;
198
- readonly tenantId: string;
199
- readonly receivedAtIso: string;
200
- readonly providerCancel: ProviderCancelOutcome;
201
- },
202
- ): Promise<void> {
203
- const { payload, tenantId, receivedAtIso, providerCancel } = args;
204
- // skip: nothing was cancelled at the provider, nothing to resume
205
- if (providerCancel !== "period-end") return;
206
- const reasons = await resumeAtProvider(ctx, await getSubscriptionForTenant(ctx, tenantId));
207
- // skip: resumed cleanly, the revocation notice already tells the operator
208
- if (reasons.length === 0) return;
209
- await notifyOperator(ctx, consumerProtection, {
210
- locale: payload.locale ?? FALLBACK_CONSENT_LOCALE,
211
- requestId: payload.requestId,
212
- receivedAtIso,
213
- channel: "public",
214
- declaration: {
215
- declarationType: payload.declarationType,
216
- terminationKind: payload.terminationKind,
217
- },
218
- reasons,
219
- tenantId,
220
- providerCancel,
221
- });
222
- }
223
-
224
196
  export function createRecordContractTerminationHandler(
225
197
  options: ResolvedBillingFoundationOptions,
226
198
  consumerProtection: ConsumerProtectionOptions,
@@ -234,6 +206,11 @@ export function createRecordContractTerminationHandler(
234
206
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
235
207
  const payload = event.payload as z.infer<typeof recordContractTerminationSchema>;
236
208
  const tenantId = event.user.tenantId;
209
+ // The provider cancel/resume and the stream append must not interleave across declarations of the same subscription.
210
+ await ctx.db.advisoryXactLock(
211
+ TERMINATION_PROVIDER_LOCK_NAMESPACE,
212
+ TERMINATION_PROVIDER_LOCK_KEY,
213
+ );
237
214
  if (payload.channel === "public" && payload.when === SubscriptionCancelTimings.immediately) {
238
215
  throw new UnprocessableError("immediate_cancel_not_allowed", {
239
216
  message:
@@ -242,7 +219,7 @@ export function createRecordContractTerminationHandler(
242
219
  }
243
220
 
244
221
  const receivedAtIso = payload.receivedAtIso ?? options.now().toString();
245
- const stream = await ctx.fetchForWriting({
222
+ const stream = await ctx.events.fetchForWriting({
246
223
  aggregateId: subscriptionAggregateId(tenantId),
247
224
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
248
225
  });
@@ -277,30 +254,12 @@ export function createRecordContractTerminationHandler(
277
254
  effectiveAtIso,
278
255
  providerCancel,
279
256
  };
280
- // Re-fetch after the external call: a webhook appended meanwhile would
281
- // otherwise fail the append on a stale expectedVersion although the
282
- // provider already cancelled.
283
- const streamAfterProviderCall = await ctx.fetchForWriting({
257
+ // Re-fetch after the external call: webhooks append to this stream without the lock, so a
258
+ // stale expectedVersion would fail the append although the provider already cancelled.
259
+ const streamAfterProviderCall = await ctx.events.fetchForWriting({
284
260
  aggregateId: subscriptionAggregateId(tenantId),
285
261
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
286
262
  });
287
- // A concurrent run with the same requestId may have recorded while we were
288
- // at the provider; a second append would duplicate the event and the notice.
289
- const recordedMeanwhile = findRecordedTermination(
290
- streamAfterProviderCall.events,
291
- payload.requestId,
292
- );
293
- if (recordedMeanwhile) return recordedTerminationResult(recordedMeanwhile);
294
- // A revocation that landed during the provider call found no cancel to take back.
295
- if (hasRevocation(streamAfterProviderCall.events, payload.requestId)) {
296
- await takeBackLateRevokedCancel(ctx, consumerProtection, {
297
- payload,
298
- tenantId,
299
- receivedAtIso,
300
- providerCancel,
301
- });
302
- return revokedRecordResult(receivedAtIso);
303
- }
304
263
  await streamAfterProviderCall.appendOne({
305
264
  type: CONTRACT_TERMINATION_REQUESTED_EVENT_QN,
306
265
  payload: recorded,
@@ -389,7 +348,7 @@ export async function resumeAtProvider(
389
348
  providerSubscriptionId: subscription.providerSubscriptionId,
390
349
  });
391
350
  } catch (error) {
392
- ctx.log?.warn(
351
+ ctx.obs.log?.warn(
393
352
  `[billing-foundation:revoke-contract-termination] provider resume failed: ${error instanceof Error ? error.message : String(error)}`,
394
353
  );
395
354
  return ["revocation_resume_failed"];
@@ -414,7 +373,7 @@ export function createRecordUnmatchedContractTerminationHandler(): WriteHandlerD
414
373
  handler: async (event, ctx) => {
415
374
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
416
375
  const payload = event.payload as z.infer<typeof recordUnmatchedContractTerminationSchema>;
417
- const stream = await ctx.fetchForWriting({
376
+ const stream = await ctx.events.fetchForWriting({
418
377
  aggregateId: terminationUnmatchedAggregateId(payload.requestId),
419
378
  aggregateType: CONTRACT_TERMINATION_UNMATCHED_AGGREGATE_TYPE,
420
379
  });
@@ -160,11 +160,13 @@ async function findTerminableTenantIds(
160
160
  "anonymous declarant has no session — finds the contract by email via user:find-for-auth and tenant:memberships as a system user; the calling handler declares its own escapeHatch",
161
161
  });
162
162
  const user = authUserRowSchema.safeParse(
163
- await ctx.queryAs(createSystemUser(requestTenantId), UserQueries.findForAuth, { email }),
163
+ await ctx.dispatch.queryAs(createSystemUser(requestTenantId), UserQueries.findForAuth, {
164
+ email,
165
+ }),
164
166
  );
165
167
  if (!user.success || user.data.isDeleted === true) return [];
166
168
  const memberships = membershipRowsSchema.safeParse(
167
- await ctx.queryAs(createSystemUser(requestTenantId), TenantQueries.memberships, {
169
+ await ctx.dispatch.queryAs(createSystemUser(requestTenantId), TenantQueries.memberships, {
168
170
  userId: user.data.id,
169
171
  }),
170
172
  );
@@ -180,7 +182,7 @@ async function findTerminableTenantIds(
180
182
  const terminable: string[] = [];
181
183
  for (const tenantId of adminTenantIds) {
182
184
  const probe = terminableResultSchema.safeParse(
183
- await ctx.queryAs(
185
+ await ctx.dispatch.queryAs(
184
186
  createSystemUser(tenantId),
185
187
  SubscriptionFoundationQueries.terminableSubscription,
186
188
  {},
@@ -205,7 +207,7 @@ export function createRequestContractTerminationHandler(
205
207
  ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
206
208
  escapeHatch: {
207
209
  reason:
208
- "Anonymous declarant has no session — finds the contract by email via ctx.queryAs(system, user:find-for-auth) and tenant:memberships; the tenant written to comes only from those lookups.",
210
+ "Anonymous declarant has no session — finds the contract by email via ctx.dispatch.queryAs(system, user:find-for-auth) and tenant:memberships; the tenant written to comes only from those lookups.",
209
211
  },
210
212
  handler: async (event, ctx) => {
211
213
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
@@ -228,7 +230,7 @@ export function createRequestContractTerminationHandler(
228
230
  if (candidates.length === 1 && onlyCandidate !== undefined) {
229
231
  matchedTenantId = onlyCandidate;
230
232
  const revocation = createRevocationToken();
231
- const declared = await ctx.writeAs(
233
+ const declared = await ctx.dispatch.writeAs(
232
234
  createSystemUser(onlyCandidate),
233
235
  SubscriptionFoundationHandlers.declareContractTermination,
234
236
  {
@@ -253,7 +255,7 @@ export function createRequestContractTerminationHandler(
253
255
  }
254
256
  } else {
255
257
  reasons.push(candidates.length === 0 ? "unmatched" : "ambiguous");
256
- const recorded = await ctx.writeAs(
258
+ const recorded = await ctx.dispatch.writeAs(
257
259
  createSystemUser(SYSTEM_TENANT_ID),
258
260
  SubscriptionFoundationHandlers.recordUnmatchedContractTermination,
259
261
  {
@@ -318,7 +320,7 @@ export function createTerminateContractHandler(
318
320
  const { declarationType, terminationKind } = payload;
319
321
 
320
322
  const caller = authUserRowSchema.safeParse(
321
- await ctx.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, {
323
+ await ctx.dispatch.queryAs(createSystemUser(tenantId), UserQueries.findForAuth, {
322
324
  id: event.user.id,
323
325
  }),
324
326
  );
@@ -332,7 +334,7 @@ export function createTerminateContractHandler(
332
334
  });
333
335
  }
334
336
 
335
- const recorded = await ctx.writeAs(
337
+ const recorded = await ctx.dispatch.writeAs(
336
338
  createSystemUser(tenantId),
337
339
  SubscriptionFoundationHandlers.recordContractTermination,
338
340
  {
@@ -24,6 +24,11 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
24
24
  import { SubscriptionFoundationHandlers } from "../web/constants.ts";
25
25
  import type { ConsentLocale } from "./consent-text.ts";
26
26
  import { notifyOperator } from "./termination-notify.ts";
27
+ import { hasOtherOpenProviderCancel } from "./termination-open-cancels.ts";
28
+ import {
29
+ TERMINATION_PROVIDER_LOCK_KEY,
30
+ TERMINATION_PROVIDER_LOCK_NAMESPACE,
31
+ } from "./termination-provider-lock.ts";
27
32
  import { findRecordedTermination, hasRevocation, resumeAtProvider } from "./termination-record.ts";
28
33
  import type { OperatorNoticeReason } from "./termination-texts.ts";
29
34
 
@@ -103,7 +108,7 @@ export function createRevokeContractTerminationHandler(): WriteHandlerDef {
103
108
  handler: async (event, ctx) => {
104
109
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
105
110
  const payload = event.payload as z.infer<typeof revokeContractTerminationSchema>;
106
- const result = await ctx.writeAs(
111
+ const result = await ctx.dispatch.writeAs(
107
112
  createSystemUser(payload.tenantId),
108
113
  SubscriptionFoundationHandlers.recordContractTerminationRevocation,
109
114
  { requestId: payload.requestId, token: payload.token },
@@ -129,11 +134,16 @@ export function createRecordContractTerminationRevocationHandler(
129
134
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
130
135
  const payload = event.payload as z.infer<typeof recordContractTerminationRevocationSchema>;
131
136
  const tenantId = event.user.tenantId;
137
+ // The provider cancel/resume and the stream append must not interleave across declarations of the same subscription.
138
+ await ctx.db.advisoryXactLock(
139
+ TERMINATION_PROVIDER_LOCK_NAMESPACE,
140
+ TERMINATION_PROVIDER_LOCK_KEY,
141
+ );
132
142
  const outcome = (value: RevocationOutcome) => ({
133
143
  isSuccess: true as const,
134
144
  data: { outcome: value },
135
145
  });
136
- const stream = await ctx.fetchForWriting({
146
+ const stream = await ctx.events.fetchForWriting({
137
147
  aggregateId: subscriptionAggregateId(tenantId),
138
148
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
139
149
  });
@@ -162,25 +172,18 @@ export function createRecordContractTerminationRevocationHandler(
162
172
  }
163
173
 
164
174
  const reasons: OperatorNoticeReason[] = ["revoked"];
165
- if (requested?.providerCancel === "period-end") {
175
+ if (
176
+ requested?.providerCancel === "period-end" &&
177
+ !hasOtherOpenProviderCancel(stream.events, payload.requestId)
178
+ ) {
166
179
  reasons.push(...(await resumeAtProvider(ctx, subscription)));
167
180
  }
168
181
  // Re-fetch after the external call, like record-contract-termination.
169
- const streamAfterProviderCall = await ctx.fetchForWriting({
182
+ const streamAfterProviderCall = await ctx.events.fetchForWriting({
170
183
  aggregateId: subscriptionAggregateId(tenantId),
171
184
  aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
172
185
  });
173
- // A cancel recorded between the first fetch and now was never resumed above.
174
- const requestedMeanwhile =
175
- requested === undefined
176
- ? findRecordedTermination(streamAfterProviderCall.events, payload.requestId)
177
- : undefined;
178
- if (requestedMeanwhile?.providerCancel === "period-end") {
179
- reasons.push(
180
- ...(await resumeAtProvider(ctx, await getSubscriptionForTenant(ctx, tenantId))),
181
- );
182
- }
183
- const providerCancel = (requested ?? requestedMeanwhile)?.providerCancel;
186
+ const providerCancel = requested?.providerCancel;
184
187
  const revoked: ContractTerminationRevokedPayload = {
185
188
  requestId: payload.requestId,
186
189
  revokedAtIso: now.toString(),