@cosmicdrift/kumiko-samples 0.387.0 → 0.389.0

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 (274) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
  6. package/packages/bundled-features/src/audit/changes.json +12 -0
  7. package/packages/bundled-features/src/audit/feature.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  15. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  16. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  17. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  18. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  19. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  20. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  21. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  22. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  23. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  24. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  25. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  26. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  27. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  28. package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
  29. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  30. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  32. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  33. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  37. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  39. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  40. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  51. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  52. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  53. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  55. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  57. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  58. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  59. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  60. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  61. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  62. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  63. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  64. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  65. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  66. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  67. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  68. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  69. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  70. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  71. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
  72. package/packages/bundled-features/src/cap-counter/changes.json +30 -0
  73. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  74. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  75. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  76. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  77. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  78. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  79. package/packages/bundled-features/src/config/changes.json +18 -0
  80. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  81. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  82. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  83. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  84. package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
  85. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
  86. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  87. package/packages/bundled-features/src/delivery/changes.json +42 -0
  88. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  89. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  90. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  91. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  92. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  93. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  94. package/packages/bundled-features/src/delivery/index.ts +5 -0
  95. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  96. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  97. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  98. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  99. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  100. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  101. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  102. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  103. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  104. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  105. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  106. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  107. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  108. package/packages/bundled-features/src/files/README.md +1 -1
  109. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  110. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  111. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  112. package/packages/bundled-features/src/host-routing/README.md +1 -1
  113. package/packages/bundled-features/src/host-routing/changes.json +12 -0
  114. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  115. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  116. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  118. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  120. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  122. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  123. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  124. package/packages/bundled-features/src/jobs/changes.json +13 -0
  125. package/packages/bundled-features/src/jobs/feature.ts +15 -4
  126. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  127. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  128. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  129. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  130. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  131. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  132. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  133. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  134. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  135. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  136. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  137. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  138. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  139. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  140. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  141. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  142. package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
  143. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  144. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  145. package/packages/bundled-features/src/secrets/changes.json +6 -0
  146. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  147. package/packages/bundled-features/src/sessions/changes.json +7 -0
  148. package/packages/bundled-features/src/sessions/feature.ts +8 -2
  149. package/packages/bundled-features/src/sessions/index.ts +1 -1
  150. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  151. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  152. package/packages/bundled-features/src/shared/index.ts +1 -1
  153. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  154. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  155. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  156. package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
  157. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  158. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  159. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  160. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  161. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  162. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  163. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  164. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  165. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  166. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  167. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  168. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  169. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  171. package/packages/bundled-features/src/tags/feature.ts +1 -0
  172. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  173. package/packages/bundled-features/src/template-resolver/changes.json +84 -0
  174. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  175. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  176. package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
  177. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  178. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  179. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  182. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  183. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  184. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
  185. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  186. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  187. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  188. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  189. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  190. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  191. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  192. package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
  193. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
  194. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  195. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  196. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  197. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  198. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  199. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  200. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  201. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  202. package/packages/bundled-features/src/tenant/changes.json +6 -0
  203. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  204. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  205. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  206. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  207. package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
  208. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  209. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  210. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  211. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  212. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  213. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  214. package/packages/bundled-features/src/user/feature.ts +1 -0
  215. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  216. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  217. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  218. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  219. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  220. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  221. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  222. package/packages/bundled-features/src/waitlist/changes.json +36 -0
  223. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  224. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
  226. package/packages/bundled-features/src/waitlist/import.ts +28 -3
  227. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
  228. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  229. package/packages/bundled-features/src/waitlist/options.ts +5 -1
  230. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  231. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  232. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  233. package/samples/apps/styleguide/src/app/server.ts +4 -1
  234. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  235. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  236. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  237. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  238. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  239. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  240. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  241. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  242. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  243. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  244. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  245. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  246. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  247. package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
  248. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  249. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  250. package/samples/apps/use-all-bundled/package.json +2 -1
  251. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  252. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  253. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  254. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  255. package/samples/recipes/auth-mfa/README.md +5 -0
  256. package/samples/recipes/auth-mfa/package.json +5 -2
  257. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  258. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  259. package/samples/recipes/env-composition/README.md +3 -1
  260. package/samples/recipes/env-composition/src/env.ts +2 -0
  261. package/samples/recipes/golden-path/src/feature.ts +2 -1
  262. package/samples/recipes/legal-pages/README.md +1 -1
  263. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  264. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  265. package/samples/recipes/list-table-options/README.md +50 -0
  266. package/samples/recipes/list-table-options/package.json +11 -0
  267. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  268. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  269. package/samples/recipes/rate-limiting/README.md +18 -0
  270. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  271. package/samples/recipes/state-machine/src/events.ts +3 -2
  272. package/samples/recipes/tenant-isolation/README.md +1 -0
  273. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
  274. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -324,11 +324,6 @@ async function sweepDerivedSubjectData(
324
324
  audit: { readonly requestId: string; readonly erasedBy: string },
325
325
  rawSubject: SubjectIdInput,
326
326
  ): Promise<string | undefined> {
327
- const recordTenantId = await owningTenantOfRecordSubject(
328
- crossTenantRunner,
329
- ctx.registry.features,
330
- rawSubject,
331
- );
332
327
  const failureLabels: string[] = [];
333
328
  const runStep = async (step: (runner: DbRunner) => Promise<void>): Promise<void> => {
334
329
  try {
@@ -367,8 +362,14 @@ async function sweepDerivedSubjectData(
367
362
  });
368
363
 
369
364
  // File columns hold only the fileRef id, so the key erase never reaches the bytes.
370
- await runStep((crossTenantSubjectRunner) =>
371
- purgeFilesReachingSubject({
365
+ await runStep(async (crossTenantSubjectRunner) => {
366
+ // Inside the step: a failing owner lookup must land in the audit label, not abort the handler.
367
+ const recordTenantId = await owningTenantOfRecordSubject(
368
+ crossTenantSubjectRunner,
369
+ ctx.registry.features,
370
+ rawSubject,
371
+ );
372
+ await purgeFilesReachingSubject({
372
373
  registry: ctx.registry,
373
374
  db: crossTenantSubjectRunner,
374
375
  subject,
@@ -376,8 +377,8 @@ async function sweepDerivedSubjectData(
376
377
  ...(recordTenantId !== undefined && { recordTenantId }),
377
378
  resolveDeleteBytes: resolveFileBytesDeleter(ctx),
378
379
  missingStorage: { mode: "fail" },
379
- }),
380
- );
380
+ });
381
+ });
381
382
 
382
383
  return failureLabels[0];
383
384
  }
@@ -561,7 +562,7 @@ export const forgetSubjectWrite = defineWriteHandler({
561
562
  // - user.updated as lifecycle event (updateUserLifecycle), so a
562
563
  // read_users rebuild doesn't wipe the flip
563
564
  // - sessions don't need active revocation — session-callbacks
564
- // re-validate user.status on every request (isPrincipalBlocked
565
+ // re-validate the user row on every request (isPrincipalRowBlocked
565
566
  // blocks Deleted).
566
567
  // Both idempotent (status set / revokedAt IS NULL filter), retry after
567
568
  // crash recovery is safe. User-feature guard: without the user feature
@@ -1,7 +1,7 @@
1
1
  // Centralised parser for `fieldDefinition.serializedField`.
2
2
  //
3
3
  // The column is stored as `text` in B1 (entity.ts) — what the db row hands
4
- // back depends on the driver: postgres-js returns text as a `string`, but
4
+ // back depends on the driver: text normally arrives as a `string`, but
5
5
  // jsonb-tolerant drivers and middleware can deliver an already-parsed
6
6
  // object. This helper normalises both, validates the shape, and centralises
7
7
  // the single boundary cast so callers don't sprinkle `as { … }`-narrowings
@@ -1,4 +1,46 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "Unsubscribe link origin is looked up once per notify call",
6
+ "detail": "The tenantOrigin callback runs once per tenant for a notify call with many recipients, and a fallback warning is logged once. A callback that hangs is still not guarded by a timeout, so keep it fast."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "improvement",
11
+ "title": "buildSystemChannelContext for system tenant mails outside a handler",
12
+ "detail": "buildSystemChannelContext(deps, { config, secrets }) returns a ChannelContext for the system tenant whose readAttachment reads file and inline attachments. SystemWireDeps (wire hook and runWorkerApp wireComponents) has a new required filesForTenant that deps can pass straight in."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Preferences query reports renamed notification types under the current name",
18
+ "detail": "Rows stored under a former notification name are returned with the current name and collapsed per channel with the delivery precedence (current-name row wins, otherwise any disabled row disables), so settings screens show the effective state."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "Inline mail image retries on transient storage errors",
24
+ "detail": "Only a missing file storage for the file's tenant sends the mail without the inline image; any other read error (timeout, provider failure, DB) is rethrown so the send job retries instead of mailing without the logo."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Unsubscribe tenant origin resolved once per notify call",
30
+ "detail": "The app's tenantOrigin callback runs once per notify call and tenant instead of once per recipient and channel, so a newsletter no longer causes one lookup and one fallback warning per address. The docs state the callback must only return verified, active domains."
31
+ },
32
+ {
33
+ "version": "0.388.0",
34
+ "type": "fix",
35
+ "title": "One-click unsubscribe POST keeps the JSON rejection",
36
+ "detail": "RFC 8058 one-click POSTs (form body List-Unsubscribe=One-Click) answer an invalid or expired token with the JSON 400 again and skip the branding lookup; only browser form submits get the HTML page."
37
+ },
38
+ {
39
+ "version": "0.388.0",
40
+ "type": "fix",
41
+ "title": "Resubscribe limit page has its own text",
42
+ "detail": "A browser that hits the resubscribe limit sees that the address cannot be subscribed again here, instead of the generic \"try again later\" error (new key mail.unsubscribe.resubscribeLimitReached in en, de, es, fr, it)."
43
+ },
2
44
  {
3
45
  "version": "0.383.0",
4
46
  "type": "fix",
@@ -5,7 +5,11 @@ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
6
6
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
7
7
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
- import { type FileRefContent, readFileRefContent } from "../file-foundation/index.ts";
8
+ import {
9
+ type FileRefContent,
10
+ FileStorageUnavailableError,
11
+ readFileRefContent,
12
+ } from "../file-foundation/index.ts";
9
13
  import {
10
14
  collectRendererPlugins,
11
15
  createRendererFoundationApi,
@@ -86,7 +90,8 @@ function createPdfRenderer(
86
90
  };
87
91
  }
88
92
 
89
- // A decorative inline image (logo) that cannot be read must not fail the whole mail.
93
+ // A decorative inline image (logo) of a tenant without file storage must not fail the whole mail;
94
+ // transient read errors rethrow so the send job retries.
90
95
  async function readInlineImageOrNull(
91
96
  fileRefId: string,
92
97
  read: () => Promise<FileRefContent | null>,
@@ -94,9 +99,10 @@ async function readInlineImageOrNull(
94
99
  try {
95
100
  return await read();
96
101
  } catch (err) {
102
+ if (!(err instanceof FileStorageUnavailableError)) throw err;
97
103
  createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
98
104
  fileRefId,
99
- errorName: err instanceof Error ? err.name : typeof err,
105
+ errorName: err.name,
100
106
  });
101
107
  return null;
102
108
  }
@@ -1,5 +1,5 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
 
5
5
  export type NotificationPreferenceRow = {
@@ -27,6 +27,6 @@ export async function selectNotificationPreferences(
27
27
  OR (notification_type = '*' AND channel = $4)
28
28
  OR (notification_type = ANY($3::text[]) AND channel = '*')
29
29
  )`,
30
- [tenantId, userId, notificationTypeNames, channelName],
30
+ [tenantId, userId, toPostgresArrayLiteral(notificationTypeNames), channelName],
31
31
  );
32
32
  }
@@ -484,9 +484,37 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
484
484
  }
485
485
  }
486
486
 
487
+ // The app's tenantOrigin callback typically hits the DB: once per run and tenant, not per recipient.
488
+ const unsubscribeOriginsByRun = new WeakMap<NotifyRun, Map<TenantId, Promise<string>>>();
489
+
490
+ function unsubscribeOriginOnce(
491
+ run: NotifyRun,
492
+ links: UnsubscribeLinkConfig,
493
+ channelCtx: ChannelContext,
494
+ notificationType: string,
495
+ ): Promise<string> {
496
+ let originsByTenant = unsubscribeOriginsByRun.get(run);
497
+ if (originsByTenant === undefined) {
498
+ originsByTenant = new Map();
499
+ unsubscribeOriginsByRun.set(run, originsByTenant);
500
+ }
501
+ let origin = originsByTenant.get(channelCtx.tenantId);
502
+ if (origin === undefined) {
503
+ origin = unsubscribeLinkOrigin(links, channelCtx, (reason) =>
504
+ logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
505
+ tenantId: channelCtx.tenantId,
506
+ notificationType,
507
+ }),
508
+ );
509
+ originsByTenant.set(channelCtx.tenantId, origin);
510
+ }
511
+ return origin;
512
+ }
513
+
487
514
  // A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
488
515
  // context, never from the notification data.
489
516
  async function withUnsubscribeUrl(
517
+ run: NotifyRun,
490
518
  message: ChannelMessage,
491
519
  channelName: string,
492
520
  recipient: UnsubscribeLinkRecipient,
@@ -498,11 +526,11 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
498
526
  }
499
527
  const supplied = message.data?.["unsubscribeUrl"];
500
528
  if (typeof supplied === "string" && supplied !== "") return message;
501
- const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) =>
502
- logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
503
- tenantId: channelCtx.tenantId,
504
- notificationType: message.notificationType,
505
- }),
529
+ const origin = await unsubscribeOriginOnce(
530
+ run,
531
+ unsubscribeLinks,
532
+ channelCtx,
533
+ message.notificationType,
506
534
  );
507
535
  const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
508
536
  tenantId: channelCtx.tenantId,
@@ -811,6 +839,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
811
839
  channel,
812
840
  address,
813
841
  message: await withUnsubscribeUrl(
842
+ run,
814
843
  message,
815
844
  channel.name,
816
845
  { kind: "user", userId },
@@ -914,6 +943,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
914
943
  channel,
915
944
  address,
916
945
  message: await withUnsubscribeUrl(
946
+ run,
917
947
  message,
918
948
  channel.name,
919
949
  { kind: "address", address },
@@ -62,6 +62,7 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
62
62
  const resolvedAccess = options?.access ?? access.admin;
63
63
  const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
64
64
  return defineFeature("delivery", (r) => {
65
+ r.adoptsBundledLists();
65
66
  r.describe(
66
67
  "The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.",
67
68
  );
@@ -2,6 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { currentNotificationType } from "../notification-type-aliases.ts";
5
6
  import { notificationPreferencesTable } from "../tables.ts";
6
7
 
7
8
  export const preferencesQuery = defineQueryHandler({
@@ -37,6 +38,24 @@ export const preferencesQuery = defineQueryHandler({
37
38
  userId: query.user.id,
38
39
  });
39
40
 
40
- return { rows };
41
+ // Rows stored under a former notification name are reported under the current
42
+ // one, collapsed with the same precedence as delivery: an exact row wins,
43
+ // otherwise any disabled former-name row disables.
44
+ const collapsed = new Map<string, { row: (typeof rows)[number]; isExact: boolean }>();
45
+ for (const row of rows) {
46
+ const notificationType = currentNotificationType(ctx.registry, row.notificationType);
47
+ const key = `${notificationType}\u0000${row.channel}`;
48
+ const isExact = row.notificationType === notificationType;
49
+ const existing = collapsed.get(key);
50
+ if (existing?.isExact) continue;
51
+ const formerNameRowDisablesAlready =
52
+ existing !== undefined &&
53
+ !isExact &&
54
+ (existing.row.enabled === false || row.enabled !== false);
55
+ if (formerNameRowDisablesAlready) continue;
56
+ collapsed.set(key, { row: { ...row, notificationType }, isExact });
57
+ }
58
+
59
+ return { rows: [...collapsed.values()].map((entry) => entry.row) };
41
60
  },
42
61
  });
@@ -33,7 +33,7 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
33
33
  // or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
34
34
  agent: { expose: false },
35
35
  description:
36
- "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail, not meant for UI callers.",
36
+ "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route or by createUnsubscribePageChromeReader for a tenant the app resolved server-side, to dress its pages like the mail; not meant for UI callers.",
37
37
  handler: async (query, ctx): Promise<MailChrome | null> => {
38
38
  const { tenantId } = query.user;
39
39
  if (!ctx.systemDb) {
@@ -73,6 +73,11 @@ export {
73
73
  } from "./handlers/unsubscribe-address.write.ts";
74
74
  export { currentNotificationType } from "./notification-type-aliases.ts";
75
75
  export { createDeliveryNotifyFactory } from "./notify-factory.ts";
76
+ export {
77
+ buildSystemChannelContext,
78
+ type SystemChannelContextDeps,
79
+ type SystemChannelContextOptions,
80
+ } from "./system-channel-context.ts";
76
81
  export {
77
82
  deliveryAttemptsTable,
78
83
  notificationAddressOptOutEntity,
@@ -0,0 +1,33 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { ConfigAccessor, Registry } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
5
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
+ import { buildChannelContext } from "./channel-context.ts";
7
+ import type { ChannelContext } from "./types.ts";
8
+
9
+ export type SystemChannelContextDeps = {
10
+ readonly db: DbConnection;
11
+ readonly registry: Registry;
12
+ readonly filesForTenant: (tenantId: string) => FileContext | undefined;
13
+ };
14
+
15
+ export type SystemChannelContextOptions = {
16
+ readonly config?: ConfigAccessor;
17
+ readonly secrets?: SecretsContext;
18
+ };
19
+
20
+ export function buildSystemChannelContext(
21
+ deps: SystemChannelContextDeps,
22
+ options: SystemChannelContextOptions = {},
23
+ ): ChannelContext {
24
+ return buildChannelContext(
25
+ deps.db,
26
+ deps.registry,
27
+ undefined,
28
+ SYSTEM_TENANT_ID,
29
+ options.secrets,
30
+ options.config,
31
+ deps.filesForTenant,
32
+ );
33
+ }
@@ -10,7 +10,9 @@ export type UnsubscribeLinkConfig = {
10
10
  readonly secret: string;
11
11
  readonly baseUrl: string;
12
12
  /** The sending tenant's own public origin (subdomain or custom domain), null when it has
13
- * none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
13
+ * none. The host must serve this app's `/api` routes, and the callback must only return a
14
+ * verified, active domain of the tenant (the check that lets the app serve under it), else null:
15
+ * the link carries a bearer token that unsubscribes and yields a resubscribe token. Null, a throw or an unusable value
14
16
  * fall back to `baseUrl`, so a missing tenant origin never fails a send. */
15
17
  readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
16
18
  };
@@ -14,6 +14,7 @@ export const UNSUBSCRIBE_PAGE_EN = {
14
14
  "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
15
15
  "mail.unsubscribe.failed": "That did not work. Please try again later.",
16
16
  "mail.unsubscribe.failedTitle": "Something went wrong",
17
+ "mail.unsubscribe.resubscribeLimitReached": "This address cannot be subscribed again here.",
17
18
  "mail.unsubscribe.invalidTitle": "Link no longer valid",
18
19
  "mail.unsubscribe.invalidMessage":
19
20
  "This link is invalid or has expired. Please use the unsubscribe link in a more recent email.",
@@ -11,6 +11,7 @@ export type UnsubscribePageState =
11
11
  | { readonly kind: "unsubscribed"; readonly resubscribeToken?: string }
12
12
  | { readonly kind: "resubscribed" }
13
13
  | { readonly kind: "failed" }
14
+ | { readonly kind: "resubscribeLimitReached" }
14
15
  | { readonly kind: "invalid" };
15
16
 
16
17
  export type UnsubscribePageInput = {
@@ -162,6 +163,11 @@ ${tokenForm(DELIVERY_RESUBSCRIBE_PATH, state.resubscribeToken, mailT(lang, "mail
162
163
  title: mailT(lang, "mail.unsubscribe.failedTitle"),
163
164
  bodyHtml: paragraph("mail.unsubscribe.failed"),
164
165
  };
166
+ case "resubscribeLimitReached":
167
+ return {
168
+ title: mailT(lang, "mail.unsubscribe.failedTitle"),
169
+ bodyHtml: paragraph("mail.unsubscribe.resubscribeLimitReached"),
170
+ };
165
171
  case "invalid":
166
172
  return {
167
173
  title: mailT(lang, "mail.unsubscribe.invalidTitle"),
@@ -569,11 +569,20 @@ async function verifyPost(
569
569
  encodedSecret: Uint8Array,
570
570
  purpose: TokenPurpose,
571
571
  ): Promise<VerifiedUnsubscribe | RejectedForPage> {
572
- return isFormRequest(request.headers["content-type"])
572
+ return isFormRequest(request.headers["content-type"]) && !isOneClickPost(request)
573
573
  ? verifyForPage(token, encodedSecret, purpose, request.headers)
574
574
  : verifyUnsubscribeToken(token, encodedSecret, purpose);
575
575
  }
576
576
 
577
+ // RFC 8058 mail-provider POSTs are form-encoded too, but nobody looks at the answer: they keep
578
+ // the JSON rejection and get no branded page.
579
+ function isOneClickPost(request: SignatureExtraRouteVerifyRequest): boolean {
580
+ return (
581
+ isFormRequest(request.headers["content-type"]) &&
582
+ request.rawBody.trim() === DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE
583
+ );
584
+ }
585
+
577
586
  function isFormRequest(contentType: string | undefined): boolean {
578
587
  return contentType?.includes("application/x-www-form-urlencoded") === true;
579
588
  }
@@ -635,22 +644,37 @@ export function createUnsubscribeRoutes(
635
644
  : pageResponse(c, deps, { kind: "confirm", token: verified.token }, verified, 200),
636
645
  });
637
646
 
638
- const writeRoute = signatureRoute<VerifiedUnsubscribe | RejectedForPage>({
647
+ const writeRoute = signatureRoute<
648
+ (VerifiedUnsubscribe & { readonly oneClick: boolean }) | RejectedForPage
649
+ >({
639
650
  method: "POST",
640
651
  path: DELIVERY_UNSUBSCRIBE_PATH,
641
652
  entry: "signature",
642
- verify: async (request) =>
643
- verifyPost(request, tokenFromPostRequest(request), encodedSecret, "unsubscribe"),
653
+ verify: async (request) => {
654
+ const verified = await verifyPost(
655
+ request,
656
+ tokenFromPostRequest(request),
657
+ encodedSecret,
658
+ "unsubscribe",
659
+ );
660
+ return verified.kind === "rejected"
661
+ ? verified
662
+ : { ...verified, oneClick: isOneClickPost(request) };
663
+ },
644
664
  handler: async (c, verified, deps) => {
645
665
  if (verified.kind === "rejected") {
646
666
  return pageResponse(c, deps, { kind: "invalid" }, verified, 400);
647
667
  }
668
+ // No chrome lookup for a one-click client: it never renders the page.
669
+ const pageScope: PageScope = verified.oneClick
670
+ ? { tenantId: undefined, locale: verified.locale }
671
+ : verified;
648
672
  // Token-verify passed — everything below is a legitimate write. Don't
649
673
  // swallow write-errors as "invalid token", that would mask real bugs
650
674
  // (e.g. events-table missing, DB down) behind a misleading 400.
651
675
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
652
676
  if (!succeeded) {
653
- return pageResponse(c, deps, { kind: "failed" }, verified, 500);
677
+ return pageResponse(c, deps, { kind: "failed" }, pageScope, 500);
654
678
  }
655
679
  return pageResponse(
656
680
  c,
@@ -659,7 +683,7 @@ export function createUnsubscribeRoutes(
659
683
  kind: "unsubscribed",
660
684
  resubscribeToken: await signResubscribeToken(verified, encodedSecret),
661
685
  },
662
- verified,
686
+ pageScope,
663
687
  200,
664
688
  );
665
689
  },
@@ -681,15 +705,12 @@ export function createUnsubscribeRoutes(
681
705
  const outcome = await dispatchResubscribeWrite(verified, deps);
682
706
  // The undo button on the unsubscribed page posts a form; a browser should land on a page.
683
707
  if (isFormRequest(c.req.header("content-type"))) {
684
- return outcome === "success"
685
- ? pageResponse(c, deps, { kind: "resubscribed" }, verified, 200)
686
- : pageResponse(
687
- c,
688
- deps,
689
- { kind: "failed" },
690
- verified,
691
- outcome === "limit_reached" ? 409 : 500,
692
- );
708
+ if (outcome === "success") {
709
+ return pageResponse(c, deps, { kind: "resubscribed" }, verified, 200);
710
+ }
711
+ return outcome === "limit_reached"
712
+ ? pageResponse(c, deps, { kind: "resubscribeLimitReached" }, verified, 409)
713
+ : pageResponse(c, deps, { kind: "failed" }, verified, 500);
693
714
  }
694
715
  if (outcome === "limit_reached") {
695
716
  return c.json(
@@ -0,0 +1,84 @@
1
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { Registry, SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
+ import {
5
+ appendEventInTenantDb,
6
+ getStreamVersionInTenantDb,
7
+ } from "@cosmicdrift/kumiko-framework/event-store";
8
+ import {
9
+ DOCUMENT_EXTRACT_AGGREGATE_TYPE,
10
+ documentExtractFailedPayloadSchema,
11
+ documentExtractReadyPayloadSchema,
12
+ } from "./events.ts";
13
+
14
+ export type ExistingDocumentExtract = {
15
+ readonly id: string;
16
+ readonly meta: { readonly error?: unknown } | null;
17
+ };
18
+
19
+ export type AppendMissingExtractEventDeps = {
20
+ readonly tenantDb: TenantDb;
21
+ readonly registry: Registry;
22
+ readonly actor: SessionUser;
23
+ readonly tenantId: string;
24
+ };
25
+
26
+ export type AppendMissingExtractEventOptions = {
27
+ readonly readyEventQn: string;
28
+ readonly failedEventQn: string;
29
+ readonly mapError?: (error: string) => string;
30
+ };
31
+
32
+ export type AppendMissingExtractEventResult =
33
+ | "appended-ready"
34
+ | "appended-failed"
35
+ | "already-terminal";
36
+
37
+ // Version 1 of the stream is the extract's created event; anything beyond it is the terminal event.
38
+ const CREATED_ONLY_STREAM_VERSION = 1;
39
+ const UNKNOWN_EXTRACT_ERROR = "unknown extract error";
40
+
41
+ // A previous delivery created the extract but died before appending its ready/failed event.
42
+ // Without the event, consumers waiting on it hang forever.
43
+ export async function appendMissingDocumentExtractEvent(
44
+ deps: AppendMissingExtractEventDeps,
45
+ existing: ExistingDocumentExtract,
46
+ fileRefId: string,
47
+ options: AppendMissingExtractEventOptions,
48
+ ): Promise<AppendMissingExtractEventResult> {
49
+ if (deps.tenantDb.tenantId !== deps.tenantId) {
50
+ throw new InternalError({
51
+ message: `document-ingest-foundation: tenantDb is bound to tenant ${deps.tenantDb.tenantId}, not ${deps.tenantId}`,
52
+ });
53
+ }
54
+ const version = await getStreamVersionInTenantDb(deps.tenantDb, existing.id);
55
+ // A missing stream means a wrong id; appending would make the terminal event the first event of a new stream.
56
+ if (version === 0) {
57
+ throw new InternalError({
58
+ message: `document-ingest-foundation: no document extract stream for id ${existing.id}`,
59
+ });
60
+ }
61
+ // skip: the terminal event was already written, nothing to catch up
62
+ if (version > CREATED_ONLY_STREAM_VERSION) return "already-terminal";
63
+ const metaError = existing.meta?.error;
64
+ const failed = typeof metaError === "string";
65
+ await appendEventInTenantDb(
66
+ deps.tenantDb,
67
+ {
68
+ aggregateId: existing.id,
69
+ aggregateType: DOCUMENT_EXTRACT_AGGREGATE_TYPE,
70
+ expectedVersion: version,
71
+ type: failed ? options.failedEventQn : options.readyEventQn,
72
+ payload: failed
73
+ ? documentExtractFailedPayloadSchema.parse({
74
+ documentExtractId: existing.id,
75
+ fileRefId,
76
+ error: options.mapError?.(metaError) || metaError || UNKNOWN_EXTRACT_ERROR,
77
+ })
78
+ : documentExtractReadyPayloadSchema.parse({ documentExtractId: existing.id, fileRefId }),
79
+ metadata: { userId: deps.actor.id },
80
+ },
81
+ { registry: deps.registry },
82
+ );
83
+ return failed ? "appended-failed" : "appended-ready";
84
+ }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "appendMissingDocumentExtractEvent and shared ready/failed payload schemas",
6
+ "detail": "A provider whose job died after creating the extract calls appendMissingDocumentExtractEvent(deps, existing, fileRefId, { readyEventQn, failedEventQn, mapError }) to append the missing terminal event; it returns appended-ready, appended-failed or already-terminal. documentExtractReadyPayloadSchema and documentExtractFailedPayloadSchema are exported so providers register their own event names with the shared payload shape."
7
+ },
2
8
  {
3
9
  "version": "0.379.0",
4
10
  "type": "fix",
@@ -47,3 +47,18 @@ export const documentIngestSkippedPayloadSchema = z.object({
47
47
  reason: z.enum(["file-too-large", "unsupported-mime-type"]),
48
48
  });
49
49
  export type DocumentIngestSkippedPayload = z.infer<typeof documentIngestSkippedPayloadSchema>;
50
+
51
+ // Terminal events of a document-extract stream. The QNs belong to the provider that registers
52
+ // them; the foundation only owns the shared payload shape.
53
+ export const documentExtractReadyPayloadSchema = z.object({
54
+ documentExtractId: z.string().min(1),
55
+ fileRefId: z.string().min(1),
56
+ });
57
+ export type DocumentExtractReadyPayload = z.infer<typeof documentExtractReadyPayloadSchema>;
58
+
59
+ export const documentExtractFailedPayloadSchema = z.object({
60
+ documentExtractId: z.string().min(1),
61
+ fileRefId: z.string().min(1),
62
+ error: z.string().min(1),
63
+ });
64
+ export type DocumentExtractFailedPayload = z.infer<typeof documentExtractFailedPayloadSchema>;
@@ -1,6 +1,13 @@
1
1
  // Public API of the document-ingest-foundation bundled-feature.
2
2
 
3
3
  export { documentIngestAggregateId } from "./aggregate-id.ts";
4
+ export {
5
+ type AppendMissingExtractEventDeps,
6
+ type AppendMissingExtractEventOptions,
7
+ type AppendMissingExtractEventResult,
8
+ appendMissingDocumentExtractEvent,
9
+ type ExistingDocumentExtract,
10
+ } from "./append-missing-extract-event.ts";
4
11
  export {
5
12
  type DocumentExtractMeta,
6
13
  documentExtractEntity,
@@ -14,8 +21,12 @@ export {
14
21
  DOCUMENT_INGEST_REQUESTED_EVENT_SHORT,
15
22
  DOCUMENT_INGEST_SKIPPED_EVENT_QN,
16
23
  DOCUMENT_INGEST_SKIPPED_EVENT_SHORT,
24
+ type DocumentExtractFailedPayload,
25
+ type DocumentExtractReadyPayload,
17
26
  type DocumentIngestRequestedPayload,
18
27
  type DocumentIngestSkippedPayload,
28
+ documentExtractFailedPayloadSchema,
29
+ documentExtractReadyPayloadSchema,
19
30
  documentIngestRequestedPayloadSchema,
20
31
  documentIngestSkippedPayloadSchema,
21
32
  } from "./events.ts";
@@ -44,6 +44,7 @@ export function createFeatureTogglesFeature(
44
44
  options: FeatureTogglesOptions = {},
45
45
  ): FeatureDefinition {
46
46
  return defineFeature("feature-toggles", (r) => {
47
+ r.adoptsBundledLists();
47
48
  r.describe(
48
49
  'Persists per-feature enabled/disabled state in the `store_global_feature_state` table and exposes a `set` write-handler plus `list`/`registered` query-handlers so operators can flip features at runtime without redeploying. Each API instance keeps an in-memory `GlobalFeatureToggleRuntime` snapshot (initialize it via `createFeatureToggleRuntime`, pass a `() => runtime` accessor to `createFeatureTogglesFeature`) that the dispatcher gate reads on every request; a `toggle-cache-sync` multi-stream projection with `delivery: "shared"` syncs the snapshot across instances whenever a `toggle-set` event is appended.',
49
50
  );
@@ -10,6 +10,7 @@ export {
10
10
  export {
11
11
  type FileRefContent,
12
12
  FileRefTooLargeError,
13
+ FileStorageUnavailableError,
13
14
  fileContextForTenant,
14
15
  fileContextFromResolver,
15
16
  readFileRefContent,