@cosmicdrift/kumiko-bundled-features 0.366.0 → 0.368.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 (365) hide show
  1. package/dist/audit/handlers/details.query.js +1 -1
  2. package/dist/audit/handlers/list.query.js +1 -1
  3. package/dist/audit/run-escape-hatch-retention.js +1 -1
  4. package/dist/auth-email-password/bootstrap.js +1 -1
  5. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +6 -3
  6. package/dist/auth-email-password/handlers/invite-accept.write.js +6 -3
  7. package/dist/auth-email-password/handlers/invite-info.query.js +1 -1
  8. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +6 -3
  9. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
  10. package/dist/auth-email-password/seeding.js +2 -2
  11. package/dist/auth-foundation/feature.d.ts +4 -0
  12. package/dist/auth-foundation/feature.js +37 -33
  13. package/dist/auth-foundation/index.d.ts +1 -1
  14. package/dist/auth-foundation/index.js +1 -1
  15. package/dist/auth-mfa/db/queries.js +1 -2
  16. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  17. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
  18. package/dist/auth-mfa/handlers/reencrypt.job.js +2 -3
  19. package/dist/auth-mfa/handlers/status.query.js +1 -1
  20. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  21. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  22. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  23. package/dist/auth-mfa-user-data/index.d.ts +4 -0
  24. package/dist/auth-mfa-user-data/index.js +13 -9
  25. package/dist/billing-foundation/billing-info-query.js +1 -1
  26. package/dist/billing-foundation/feature.d.ts +1 -0
  27. package/dist/billing-foundation/feature.js +5 -6
  28. package/dist/billing-foundation/handlers/create-portal-session.write.js +1 -1
  29. package/dist/billing-foundation/projection.js +1 -2
  30. package/dist/cap-counter/calendar-cap-usage-reservation.d.ts +7 -0
  31. package/dist/cap-counter/calendar-cap-usage-reservation.js +28 -2
  32. package/dist/cap-counter/cap-reservation.d.ts +16 -0
  33. package/dist/cap-counter/cap-reservation.js +144 -14
  34. package/dist/cap-counter/feature.d.ts +4 -0
  35. package/dist/cap-counter/feature.js +74 -68
  36. package/dist/cap-counter/handlers/increment-rolling.write.js +3 -4
  37. package/dist/cap-counter/index.d.ts +3 -2
  38. package/dist/cap-counter/index.js +3 -2
  39. package/dist/cap-counter/tables.js +10 -3
  40. package/dist/cap-overview/handlers/caps-usage.query.js +1 -2
  41. package/dist/cap-overview/handlers/tenant-caps-list.query.js +1 -2
  42. package/dist/cap-overview/handlers/tenant-options.query.js +1 -1
  43. package/dist/channel-email/email-channel.d.ts +5 -1
  44. package/dist/channel-email/email-channel.js +8 -2
  45. package/dist/channel-email/feature.js +1 -1
  46. package/dist/channel-in-app/handlers/inbox.query.js +1 -1
  47. package/dist/channel-in-app/handlers/mark-all-read.write.js +1 -1
  48. package/dist/channel-in-app/handlers/mark-read.write.js +1 -1
  49. package/dist/channel-in-app/handlers/unread-count.query.js +1 -1
  50. package/dist/channel-in-app/tables.js +1 -2
  51. package/dist/compliance-profiles/handlers/for-tenant.query.js +1 -1
  52. package/dist/compliance-profiles/handlers/needs-profile.query.js +1 -1
  53. package/dist/compliance-profiles/handlers/set-profile.write.js +1 -2
  54. package/dist/compliance-profiles/resolve-for-tenant.js +1 -1
  55. package/dist/compliance-profiles/seeding.js +3 -5
  56. package/dist/compliance-profiles-ops/index.d.ts +8 -0
  57. package/dist/compliance-profiles-ops/index.js +19 -15
  58. package/dist/config/db/queries/resolver.js +1 -1
  59. package/dist/config/handlers/reencrypt.job.js +2 -2
  60. package/dist/config/resolver.js +1 -1
  61. package/dist/config/write-helpers.js +1 -1
  62. package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -3
  63. package/dist/custom-fields/db/queries/field-access.js +1 -1
  64. package/dist/custom-fields/db/queries/projection.js +1 -1
  65. package/dist/custom-fields/db/queries/quota.js +1 -1
  66. package/dist/custom-fields/db/queries/retention.js +1 -2
  67. package/dist/custom-fields/db/queries/user-data-rights.js +1 -1
  68. package/dist/custom-fields/feature.d.ts +20 -2
  69. package/dist/custom-fields/feature.js +9 -13
  70. package/dist/custom-fields/handlers/clear-custom-field.write.js +2 -2
  71. package/dist/custom-fields/handlers/delete-system-field.write.js +2 -2
  72. package/dist/custom-fields/handlers/delete-tenant-field.write.js +2 -2
  73. package/dist/custom-fields/handlers/set-custom-field.write.js +2 -2
  74. package/dist/custom-fields/wire-for-entity.js +4 -4
  75. package/dist/data-retention/resolve-for-tenant.js +1 -1
  76. package/dist/data-retention/run-retention-cleanup.js +17 -13
  77. package/dist/delivery/address-opt-out.js +1 -2
  78. package/dist/delivery/attempt-log-retention.js +1 -1
  79. package/dist/delivery/channel-context.js +2 -1
  80. package/dist/delivery/db/queries/address-opt-outs.js +1 -1
  81. package/dist/delivery/db/queries/preferences.js +1 -1
  82. package/dist/delivery/delivery-service.d.ts +2 -0
  83. package/dist/delivery/delivery-service.js +46 -11
  84. package/dist/delivery/feature.js +2 -2
  85. package/dist/delivery/handlers/log.query.js +2 -3
  86. package/dist/delivery/handlers/preferences.query.js +1 -1
  87. package/dist/delivery/handlers/unsubscribe-address.write.d.ts +6 -0
  88. package/dist/delivery/handlers/unsubscribe-address.write.js +8 -5
  89. package/dist/delivery/index.d.ts +3 -0
  90. package/dist/delivery/index.js +2 -0
  91. package/dist/delivery/testing.d.ts +2 -0
  92. package/dist/delivery/types.d.ts +2 -1
  93. package/dist/delivery/unsubscribe-link.d.ts +25 -0
  94. package/dist/delivery/unsubscribe-link.js +40 -0
  95. package/dist/delivery/unsubscribe-page-strings.d.ts +11 -0
  96. package/dist/delivery/unsubscribe-page-strings.js +15 -0
  97. package/dist/delivery/unsubscribe.d.ts +3 -0
  98. package/dist/delivery/unsubscribe.js +65 -29
  99. package/dist/delivery/upsert-preference.js +1 -2
  100. package/dist/derivatives-sharp/feature.d.ts +4 -0
  101. package/dist/derivatives-sharp/feature.js +15 -11
  102. package/dist/derivatives-sharp/index.d.ts +1 -1
  103. package/dist/derivatives-sharp/index.js +1 -1
  104. package/dist/document-ingest-foundation/feature.d.ts +7 -0
  105. package/dist/document-ingest-foundation/feature.js +94 -90
  106. package/dist/document-ingest-foundation/forget-extract-with-file-ref.js +3 -3
  107. package/dist/document-ingest-foundation/index.d.ts +1 -1
  108. package/dist/document-ingest-foundation/index.js +1 -1
  109. package/dist/feature-toggles/global-feature-state-table.js +1 -2
  110. package/dist/feature-toggles/handlers/set.write.js +3 -5
  111. package/dist/feature-toggles/toggle-runtime.js +1 -1
  112. package/dist/file-derivatives/feature.d.ts +1 -0
  113. package/dist/file-derivatives/feature.js +2 -1
  114. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  115. package/dist/file-derivatives/handlers/public-variant.query.js +1 -1
  116. package/dist/file-foundation/feature.d.ts +6 -0
  117. package/dist/file-foundation/feature.js +29 -25
  118. package/dist/file-foundation/index.d.ts +1 -1
  119. package/dist/file-foundation/index.js +1 -1
  120. package/dist/file-foundation/read-file-ref-content.js +1 -1
  121. package/dist/file-provider-inmemory/feature.d.ts +4 -0
  122. package/dist/file-provider-inmemory/feature.js +24 -20
  123. package/dist/file-provider-inmemory/index.d.ts +1 -1
  124. package/dist/file-provider-inmemory/index.js +1 -1
  125. package/dist/file-provider-s3/env-feature.d.ts +3 -0
  126. package/dist/file-provider-s3/env-feature.js +46 -0
  127. package/dist/file-provider-s3/feature.d.ts +26 -0
  128. package/dist/file-provider-s3/feature.js +84 -72
  129. package/dist/file-provider-s3/index.d.ts +1 -1
  130. package/dist/file-provider-s3/index.js +1 -1
  131. package/dist/file-provider-s3/lib/index.d.ts +3 -0
  132. package/dist/file-provider-s3/lib/index.js +2 -0
  133. package/dist/{files-provider-s3 → file-provider-s3/lib}/s3-provider.js +19 -19
  134. package/dist/file-provider-s3-env/index.d.ts +8 -1
  135. package/dist/file-provider-s3-env/index.js +7 -2
  136. package/dist/files-provider-s3/index.d.ts +5 -3
  137. package/dist/files-provider-s3/index.js +5 -2
  138. package/dist/files-tenant-data/handlers/sweep-orphaned-derivatives.job.js +1 -1
  139. package/dist/files-tenant-data/hooks.js +1 -2
  140. package/dist/folders/feature.d.ts +6 -2
  141. package/dist/folders/feature.js +4 -2
  142. package/dist/folders-user-data/index.d.ts +4 -0
  143. package/dist/folders-user-data/index.js +21 -17
  144. package/dist/form-draft/db/queries/cleanup.js +1 -1
  145. package/dist/form-draft/db/queries/draft-count.js +1 -1
  146. package/dist/form-draft/db/queries/owned-file-refs.js +1 -2
  147. package/dist/form-draft/feature.d.ts +4 -0
  148. package/dist/form-draft/feature.js +5 -1
  149. package/dist/form-draft/handlers/cleanup.job.js +2 -2
  150. package/dist/form-draft/index.d.ts +1 -1
  151. package/dist/form-draft/index.js +1 -1
  152. package/dist/form-draft/lookup.js +1 -1
  153. package/dist/form-draft-user-data/index.d.ts +4 -0
  154. package/dist/form-draft-user-data/index.js +13 -9
  155. package/dist/inbound-mail-foundation/feature.d.ts +4 -0
  156. package/dist/inbound-mail-foundation/feature.js +151 -147
  157. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -2
  158. package/dist/inbound-mail-foundation/index.d.ts +1 -1
  159. package/dist/inbound-mail-foundation/index.js +1 -1
  160. package/dist/inbound-mail-foundation/oauth-access-token.js +1 -2
  161. package/dist/inbound-mail-foundation/projection.js +1 -2
  162. package/dist/inbound-mail-foundation/watch-supervisor.js +1 -1
  163. package/dist/inbound-provider-imap/feature.d.ts +4 -0
  164. package/dist/inbound-provider-imap/feature.js +16 -12
  165. package/dist/inbound-provider-imap/index.d.ts +1 -1
  166. package/dist/inbound-provider-imap/index.js +1 -1
  167. package/dist/inbound-provider-inmemory/feature.d.ts +4 -0
  168. package/dist/inbound-provider-inmemory/feature.js +14 -10
  169. package/dist/inbound-provider-inmemory/index.d.ts +1 -1
  170. package/dist/inbound-provider-inmemory/index.js +1 -1
  171. package/dist/jobs/db/queries/retention.js +1 -1
  172. package/dist/jobs/db/queries/stale-run-sweep.js +1 -1
  173. package/dist/jobs/handlers/detail.query.js +1 -1
  174. package/dist/jobs/handlers/list.query.js +1 -2
  175. package/dist/jobs/handlers/retry.write.js +1 -1
  176. package/dist/jobs/handlers/tenant-failures.query.js +1 -1
  177. package/dist/jobs/handlers/tenant-runs.query.js +1 -1
  178. package/dist/jobs/job-run-logger.js +1 -2
  179. package/dist/jobs/tenant-job-failure-table.js +1 -2
  180. package/dist/jobs/tenant-job-run-table.js +1 -2
  181. package/dist/ledger/feature.d.ts +6 -2
  182. package/dist/ledger/feature.js +4 -2
  183. package/dist/mail-foundation/feature.d.ts +23 -1
  184. package/dist/mail-foundation/feature.js +76 -49
  185. package/dist/mail-foundation/index.d.ts +1 -1
  186. package/dist/mail-foundation/index.js +1 -1
  187. package/dist/mail-transport-inmemory/feature.d.ts +4 -0
  188. package/dist/mail-transport-inmemory/feature.js +20 -16
  189. package/dist/mail-transport-inmemory/index.d.ts +1 -1
  190. package/dist/mail-transport-inmemory/index.js +1 -1
  191. package/dist/mail-transport-smtp/feature.d.ts +16 -0
  192. package/dist/mail-transport-smtp/feature.js +85 -79
  193. package/dist/mail-transport-smtp/index.d.ts +1 -1
  194. package/dist/mail-transport-smtp/index.js +1 -1
  195. package/dist/managed-pages/handlers/by-slug.query.js +1 -1
  196. package/dist/managed-pages/handlers/by-tenant-published.query.js +1 -2
  197. package/dist/managed-pages/handlers/set.write.js +1 -2
  198. package/dist/managed-pages/seeding.js +3 -5
  199. package/dist/metrics/compute.d.ts +1 -1
  200. package/dist/metrics/filters.d.ts +1 -1
  201. package/dist/metrics/filters.js +1 -1
  202. package/dist/metrics/validate.js +1 -1
  203. package/dist/notes-history/feature.d.ts +7 -2
  204. package/dist/notes-history/feature.js +4 -2
  205. package/dist/notes-history/handlers/add-note.write.js +1 -1
  206. package/dist/notes-history-user-data/hooks.js +1 -1
  207. package/dist/notes-history-user-data/index.d.ts +4 -0
  208. package/dist/notes-history-user-data/index.js +21 -17
  209. package/dist/personal-access-tokens/feature.d.ts +4 -1
  210. package/dist/personal-access-tokens/feature.js +10 -2
  211. package/dist/personal-access-tokens/handlers/create.write.js +1 -1
  212. package/dist/personal-access-tokens/handlers/list.query.js +2 -2
  213. package/dist/personal-access-tokens/handlers/revoke.write.js +1 -1
  214. package/dist/personal-access-tokens/resolver.js +1 -1
  215. package/dist/personal-access-tokens/revoke-for-user.js +1 -1
  216. package/dist/personal-access-tokens/roles.js +3 -5
  217. package/dist/personal-access-tokens/screens.js +2 -0
  218. package/dist/readiness/feature.d.ts +8 -0
  219. package/dist/readiness/feature.js +18 -14
  220. package/dist/readiness/index.d.ts +1 -1
  221. package/dist/readiness/index.js +1 -1
  222. package/dist/secrets/db/queries/read.js +1 -1
  223. package/dist/secrets/feature.d.ts +3 -0
  224. package/dist/secrets/feature.js +3 -2
  225. package/dist/secrets/handlers/rotate.job.js +2 -3
  226. package/dist/secrets/secrets-context.js +3 -4
  227. package/dist/sessions/db/queries/cleanup.js +1 -1
  228. package/dist/sessions/handlers/detail.query.js +1 -1
  229. package/dist/sessions/handlers/list.query.js +2 -2
  230. package/dist/sessions/handlers/mine.query.js +1 -1
  231. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
  232. package/dist/sessions/handlers/revoke-all-others.write.js +1 -1
  233. package/dist/sessions/handlers/revoke.write.js +1 -1
  234. package/dist/sessions/session-callbacks.js +4 -6
  235. package/dist/sessions/testing.d.ts +1 -1
  236. package/dist/tags/feature.d.ts +6 -2
  237. package/dist/tags/feature.js +4 -2
  238. package/dist/template-resolver/api.js +1 -1
  239. package/dist/template-resolver/compile-document-body.js +47 -11
  240. package/dist/template-resolver/document-sanitizer.d.ts +2 -0
  241. package/dist/template-resolver/document-sanitizer.js +16 -1
  242. package/dist/template-resolver/document-schema.d.ts +2 -0
  243. package/dist/template-resolver/document-schema.js +31 -0
  244. package/dist/template-resolver/handlers/bundle-import.js +4 -3
  245. package/dist/template-resolver/handlers/by-slug.query.js +1 -1
  246. package/dist/template-resolver/handlers/by-tenant.query.js +1 -2
  247. package/dist/template-resolver/handlers/collection-item.query.js +1 -1
  248. package/dist/template-resolver/handlers/collection-list.query.js +1 -2
  249. package/dist/template-resolver/handlers/collection-set.write.js +1 -1
  250. package/dist/template-resolver/handlers/copy-locale.write.js +4 -3
  251. package/dist/template-resolver/handlers/diff-to-system.query.js +1 -1
  252. package/dist/template-resolver/handlers/export-bundle.query.js +1 -1
  253. package/dist/template-resolver/handlers/find-by-id.query.js +6 -2
  254. package/dist/template-resolver/handlers/layout-source.query.js +1 -1
  255. package/dist/template-resolver/handlers/list.query.js +4 -2
  256. package/dist/template-resolver/handlers/overview.query.js +5 -3
  257. package/dist/template-resolver/handlers/reset-to-system.write.js +1 -1
  258. package/dist/template-resolver/handlers/resource-remove.write.js +1 -1
  259. package/dist/template-resolver/handlers/set.write.js +1 -1
  260. package/dist/template-resolver/handlers/template-write.d.ts +2 -1
  261. package/dist/template-resolver/handlers/template-write.js +13 -2
  262. package/dist/template-resolver/handlers/tenant-overrides.query.js +1 -1
  263. package/dist/template-resolver/handlers/toggle-status.write.js +1 -1
  264. package/dist/template-resolver/handlers/usage.query.js +1 -1
  265. package/dist/template-resolver/handlers/validate.query.js +1 -1
  266. package/dist/template-resolver/index.d.ts +3 -2
  267. package/dist/template-resolver/index.js +3 -2
  268. package/dist/template-resolver/qualified-names.d.ts +1 -0
  269. package/dist/template-resolver/qualified-names.js +1 -0
  270. package/dist/template-resolver/seeding.d.ts +4 -0
  271. package/dist/template-resolver/seeding.js +43 -9
  272. package/dist/template-resolver/store-template-resource.js +1 -1
  273. package/dist/template-resolver/template-preview.js +3 -1
  274. package/dist/template-resolver/template-resources.js +1 -1
  275. package/dist/template-resolver/template-system-only.d.ts +8 -0
  276. package/dist/template-resolver/template-system-only.js +11 -0
  277. package/dist/template-resolver/template-validation.js +42 -28
  278. package/dist/template-resolver/testing.js +1 -1
  279. package/dist/template-resolver/web/i18n.js +1 -0
  280. package/dist/template-resolver-user-data/index.d.ts +4 -0
  281. package/dist/template-resolver-user-data/index.js +17 -13
  282. package/dist/tenant/handlers/active-tenant-ids.query.js +1 -1
  283. package/dist/tenant/handlers/add-member.write.js +1 -2
  284. package/dist/tenant/handlers/cancel-invitation.write.js +1 -1
  285. package/dist/tenant/handlers/invitations.query.js +1 -1
  286. package/dist/tenant/handlers/me.query.js +1 -1
  287. package/dist/tenant/handlers/member-directory.query.js +1 -1
  288. package/dist/tenant/handlers/members.query.js +1 -1
  289. package/dist/tenant/handlers/memberships.query.js +1 -1
  290. package/dist/tenant/handlers/remove-member.write.js +1 -2
  291. package/dist/tenant/handlers/resolve-user-ids.query.js +1 -1
  292. package/dist/tenant/handlers/team-list.query.js +1 -1
  293. package/dist/tenant/handlers/tenant-directory.query.js +1 -1
  294. package/dist/tenant/handlers/update-member-roles.write.js +1 -2
  295. package/dist/tenant/invited-membership.js +2 -3
  296. package/dist/tenant/last-tenant-admin.js +1 -2
  297. package/dist/tenant/seeding.js +12 -6
  298. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -2
  299. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -2
  300. package/dist/tenant-lifecycle/lib/revoke-tenant-sessions.js +1 -1
  301. package/dist/tenant-lifecycle/lifecycle-gate.js +1 -1
  302. package/dist/tenant-lifecycle/run-tenant-destroy.js +3 -5
  303. package/dist/tenant-lifecycle/stages.js +10 -6
  304. package/dist/tenant-lifecycle/testing.d.ts +1 -1
  305. package/dist/tenant-lifecycle/testing.js +2 -2
  306. package/dist/tenant-settings/feature.d.ts +1 -0
  307. package/dist/tenant-settings/feature.js +2 -1
  308. package/dist/tier-engine/feature.d.ts +1 -8
  309. package/dist/tier-engine/feature.js +71 -78
  310. package/dist/tier-engine/handlers/get-tenant-tier.query.js +2 -3
  311. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -2
  312. package/dist/user/db/queries/stream-tenant-backfill.js +1 -1
  313. package/dist/user/handlers/create.write.js +1 -2
  314. package/dist/user/handlers/find-for-auth.query.js +1 -1
  315. package/dist/user/handlers/list.query.js +1 -1
  316. package/dist/user/handlers/update-roles.js +1 -2
  317. package/dist/user/index.d.ts +1 -1
  318. package/dist/user/index.js +1 -1
  319. package/dist/user/invited-global-roles.js +2 -3
  320. package/dist/user/principal-status.d.ts +4 -0
  321. package/dist/user/principal-status.js +9 -3
  322. package/dist/user/resolve-display-names.js +1 -1
  323. package/dist/user/seeding.js +5 -6
  324. package/dist/user-data-rights/audit-download.js +2 -2
  325. package/dist/user-data-rights/db/queries/export-jobs.js +1 -1
  326. package/dist/user-data-rights/db/queries/forget-cleanup.js +1 -1
  327. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  328. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  329. package/dist/user-data-rights/handlers/export-status.query.js +1 -1
  330. package/dist/user-data-rights/handlers/list-download-attempts.query.js +1 -1
  331. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
  332. package/dist/user-data-rights/handlers/request-export.write.js +1 -2
  333. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +3 -3
  334. package/dist/user-data-rights/lib/deny-if-target-outside-admin-tenant.d.ts +1 -1
  335. package/dist/user-data-rights/lib/deny-if-target-outside-admin-tenant.js +1 -1
  336. package/dist/user-data-rights/lib/update-user-lifecycle.d.ts +1 -0
  337. package/dist/user-data-rights/lib/update-user-lifecycle.js +3 -3
  338. package/dist/user-data-rights/run-export-jobs.js +2 -3
  339. package/dist/user-data-rights/run-forget-cleanup.js +9 -5
  340. package/dist/user-data-rights/run-user-export.js +9 -5
  341. package/dist/waitlist/handlers/invite.write.js +1 -2
  342. package/dist/waitlist/handlers/reject.write.js +1 -1
  343. package/dist/waitlist/lib.js +1 -2
  344. package/dist/workflow-runner/event-subscriber.js +1 -1
  345. package/dist/workflow-runner/feature.d.ts +4 -0
  346. package/dist/workflow-runner/feature.js +44 -40
  347. package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
  348. package/dist/workflow-runner/index.d.ts +1 -1
  349. package/dist/workflow-runner/index.js +1 -1
  350. package/dist/workflow-runner/pending-projection.js +1 -1
  351. package/package.json +28 -11
  352. package/src/cap-counter/changes.json +13 -0
  353. package/src/channel-email/changes.json +6 -0
  354. package/src/crypto-shredding/changes.json +6 -0
  355. package/src/delivery/changes.json +12 -0
  356. package/src/file-provider-s3/changes.json +10 -1
  357. package/src/personal-access-tokens/changes.json +6 -0
  358. package/src/template-resolver/changes.json +25 -0
  359. package/src/user/changes.json +6 -0
  360. package/src/user-data-rights/changes.json +6 -0
  361. package/dist/file-provider-s3-env/feature.d.ts +0 -3
  362. package/dist/file-provider-s3-env/feature.js +0 -46
  363. /package/dist/{files-provider-s3 → file-provider-s3/lib}/env-helper.d.ts +0 -0
  364. /package/dist/{files-provider-s3 → file-provider-s3/lib}/env-helper.js +0 -0
  365. /package/dist/{files-provider-s3 → file-provider-s3/lib}/s3-provider.d.ts +0 -0
@@ -1,19 +1,19 @@
1
1
  import { createSystemDbView, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
- import { assertNotificationVariables, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { assertNotificationVariables, bridgeStub, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
4
  import { assertNotifyTargetsOwnTenant, createEscapeHatchReporter, } from "@cosmicdrift/kumiko-framework/pipeline";
5
- import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing/handler-context";
6
5
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
7
6
  import { fileContextFromResolver } from "../file-foundation/index.js";
8
7
  import { redactErrorText } from "../shared/index.js";
9
8
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
10
9
  import { appendAttemptEvent, logAttempt } from "./attempt-log.js";
11
10
  import { buildChannelContext } from "./channel-context.js";
12
- import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, } from "./constants.js";
11
+ import { DELIVERY_CHANNEL_EXTENSION, DeliveryJobs, deliveryIdempotencyRedisKey, deliveryPriorityRank, EMAIL_CHANNEL_NAME, } from "./constants.js";
13
12
  import { addressOptOutKey, selectOptedOutAddresses, } from "./db/queries/address-opt-outs.js";
14
13
  import { selectNotificationPreferences } from "./db/queries/preferences.js";
15
14
  import { notificationTypeNames } from "./notification-type-aliases.js";
16
15
  import { isDeliveryChannelPlugin, } from "./types.js";
16
+ import { buildUnsubscribeUrl, unsubscribeLinkOrigin, } from "./unsubscribe-link.js";
17
17
  export function redactedMessageOf(err) {
18
18
  return redactErrorText(err instanceof Error ? err.message : String(err));
19
19
  }
@@ -28,7 +28,7 @@ export function collectChannels(registry) {
28
28
  });
29
29
  }
30
30
  export function createDeliveryService(options) {
31
- const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, } = options;
31
+ const { db, registry, sseBroker, channels, tenantUserIdsQuery, rateLimit, isChannelKilled, idempotencyRedis, jobRunner, secrets, filesForTenant, tenantConfigFor, escapeHatchAuditSink, log, unsubscribeLinks, } = options;
32
32
  const idemRedis = idempotencyRedis ?? rateLimit?.redis;
33
33
  const logError = createFallbackLogger("delivery", log);
34
34
  // Rate limit check: atomic INCR + TTL + over-limit rollback via server-side
@@ -124,17 +124,27 @@ export function createDeliveryService(options) {
124
124
  // path a cross-tenant read the dispatcher never allows. Cross-tenant reach only
125
125
  // exists through the grant-carrying systemDb view below.
126
126
  const isSystem = registry.isHandlerSystemScoped(tenantUserIdsQuery);
127
- const tenantDb = createTenantDb(run.db, tenantId, "tenant", undefined, undefined, undefined, {
128
- report,
127
+ const tenantDb = createTenantDb({
128
+ db: run.db,
129
+ tenantId,
130
+ mode: "tenant",
131
+ grants: {
132
+ report,
133
+ },
129
134
  });
130
135
  // The dispatcher grants a systemScope handler's ctx.systemDb.unsafeRaw() ungated —
131
136
  // the grant comes from the handler's own r.systemScope() registration, not from this
132
137
  // caller. Only the systemDb view's own source TenantDb carries the grant, so it
133
138
  // can't leak onto ctx.db.
134
139
  const systemDbSourceTenantDb = isSystem
135
- ? createTenantDb(run.db, tenantId, "system", undefined, undefined, undefined, {
136
- report,
137
- unsafeRaw: { reason: `r.systemScope() handler "${tenantUserIdsQuery}"` },
140
+ ? createTenantDb({
141
+ db: run.db,
142
+ tenantId,
143
+ mode: "system",
144
+ grants: {
145
+ report,
146
+ unsafeRaw: { reason: `r.systemScope() handler "${tenantUserIdsQuery}"` },
147
+ },
138
148
  })
139
149
  : undefined;
140
150
  // Hand-built context, not routed through the dispatcher — this needs both
@@ -255,6 +265,31 @@ export function createDeliveryService(options) {
255
265
  });
256
266
  }
257
267
  }
268
+ // A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
269
+ // context, never from the notification data.
270
+ async function withUnsubscribeUrl(message, channelName, recipient, channelCtx) {
271
+ if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME)
272
+ return message;
273
+ if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
274
+ return message;
275
+ }
276
+ const supplied = message.data?.["unsubscribeUrl"];
277
+ if (typeof supplied === "string" && supplied !== "")
278
+ return message;
279
+ const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
280
+ tenantId: channelCtx.tenantId,
281
+ notificationType: message.notificationType,
282
+ }));
283
+ const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
284
+ tenantId: channelCtx.tenantId,
285
+ notificationType: message.notificationType,
286
+ channel: channelName,
287
+ locale: message.locale,
288
+ });
289
+ return unsubscribeUrl === undefined
290
+ ? message
291
+ : { ...message, data: { ...message.data, unsubscribeUrl } };
292
+ }
258
293
  function buildMessage(notificationType, data, channelName, locale) {
259
294
  // Look up per-channel template from notification definition
260
295
  const notifDef = registry.getAllNotifications().get(notificationType);
@@ -435,7 +470,7 @@ export function createDeliveryService(options) {
435
470
  run,
436
471
  channel,
437
472
  address,
438
- message,
473
+ message: await withUnsubscribeUrl(message, channel.name, { kind: "user", userId }, channelCtx),
439
474
  channelCtx,
440
475
  tenantId,
441
476
  recipientId: userId,
@@ -508,7 +543,7 @@ export function createDeliveryService(options) {
508
543
  run,
509
544
  channel,
510
545
  address,
511
- message,
546
+ message: await withUnsubscribeUrl(message, channel.name, { kind: "address", address }, channelCtx),
512
547
  channelCtx,
513
548
  tenantId,
514
549
  recipientId,
@@ -1,4 +1,4 @@
1
- import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { updateMany, upsertByPk } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { access, defineFeature, i18nKey, } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { ConsumerLagError } from "@cosmicdrift/kumiko-framework/pipeline";
4
4
  import { resolveAttemptLogRetentionDays, runAttemptLogRetention } from "./attempt-log-retention.js";
@@ -19,7 +19,7 @@ export function createDeliveryFeature(options) {
19
19
  const resolvedAccess = options?.access ?? access.admin;
20
20
  const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
21
21
  return defineFeature("delivery", (r) => {
22
- r.describe('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.');
22
+ r.describe("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` (`runProdApp` 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. 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.");
23
23
  r.uiHints({
24
24
  displayLabel: "Notifications \u00b7 Dispatch Core",
25
25
  category: "notifications",
@@ -1,5 +1,4 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { decodeKeysetCursor, encodeKeysetCursor, } from "@cosmicdrift/kumiko-framework/db";
1
+ import { decodeKeysetCursor, encodeKeysetCursor, selectMany, } from "@cosmicdrift/kumiko-framework/db";
3
2
  import { access, definePagedQueryHandler, isDeliveryErrorCode, isUuid, MAX_LIST_LIMIT, SYSTEM_USER_ID, } from "@cosmicdrift/kumiko-framework/engine";
4
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
5
4
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
@@ -30,7 +29,7 @@ function isDeliveryLogSortField(value) {
30
29
  return value in DELIVERY_LOG_SORT_COLUMNS;
31
30
  }
32
31
  // createdAt round-trips through Temporal.Instant (matches how the WHERE
33
- // builder coerces timestamptz values — see bun-db/query.ts prepareValue);
32
+ // builder coerces timestamptz values — see db/query.ts prepareValue);
34
33
  // every other whitelisted column is a plain text column. Keyed by the
35
34
  // PHYSICAL column, not the display alias — row only has the DB field names
36
35
  // (e.g. notificationType), never the alias (e.g. "type").
@@ -1,4 +1,4 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ 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";
@@ -1,4 +1,10 @@
1
1
  import * as z from "zod";
2
+ export declare const unsubscribeAddressPayloadSchema: z.ZodObject<{
3
+ addressHash: z.ZodString;
4
+ notificationType: z.ZodString;
5
+ channel: z.ZodString;
6
+ }, z.core.$strip>;
7
+ export type UnsubscribeAddressPayload = z.infer<typeof unsubscribeAddressPayloadSchema>;
2
8
  export declare const unsubscribeAddressWrite: import("@cosmicdrift/kumiko-types/define-handler").WriteHandlerDefinition<"unsubscribeAddress", z.ZodObject<{
3
9
  addressHash: z.ZodString;
4
10
  notificationType: z.ZodString;
@@ -3,13 +3,16 @@ import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
3
3
  import * as z from "zod";
4
4
  import { upsertAddressOptOut } from "../address-opt-out.js";
5
5
  import { currentNotificationType } from "../notification-type-aliases.js";
6
+ // Also the payload an app job receives with `trigger: { on: DeliveryHandlers.unsubscribeAddress }`
7
+ // after a committed address opt-out (the tenant is the job's `ctx.triggeredBy.tenantId`).
8
+ export const unsubscribeAddressPayloadSchema = z.object({
9
+ addressHash: z.string().min(1),
10
+ notificationType: z.string().min(1),
11
+ channel: z.string().min(1),
12
+ });
6
13
  export const unsubscribeAddressWrite = defineWriteHandler({
7
14
  name: "unsubscribeAddress",
8
- schema: z.object({
9
- addressHash: z.string().min(1),
10
- notificationType: z.string().min(1),
11
- channel: z.string().min(1),
12
- }),
15
+ schema: unsubscribeAddressPayloadSchema,
13
16
  access: { roles: access.systemAdmin },
14
17
  // Only reachable via createUnsubscribeRoutes' POST dispatchSystemWrite, once
15
18
  // verify() has already proven the address-token signature — not a surface
@@ -9,8 +9,11 @@ export type { DeliveryStatusValue } from "./constants.js";
9
9
  export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
10
10
  export { collectChannels, createDeliveryService, type DeliveryServiceOptions, type KillSwitchResolver, type RateLimitConfig, } from "./delivery-service.js";
11
11
  export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.js";
12
+ export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
12
13
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
13
14
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
14
15
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.js";
15
16
  export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.js";
16
17
  export { type AddressUnsubscribeTokenPayload, createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, type UnsubscribeRouteOptions, type UnsubscribeTokenPayload, } from "./unsubscribe.js";
18
+ export type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
19
+ export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -8,8 +8,10 @@ export { chatConnectionNameSchema, chatWebhookUrlSchema, checkChatWebhookTarget,
8
8
  export { DELIVERY_ATTEMPT_ADDRESS_ERASED_EVENT, DELIVERY_CHANNEL_EXTENSION, DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, DeliveryJobs, DeliveryQueries, DeliveryStatus, EMAIL_CHANNEL_NAME, } from "./constants.js";
9
9
  export { collectChannels, createDeliveryService, } from "./delivery-service.js";
10
10
  export { createDeliveryFeature } from "./feature.js";
11
+ export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
11
12
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
12
13
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
13
14
  export { createDeliveryTestContext } from "./testing.js";
14
15
  export { isDeliveryChannelPlugin, } from "./types.js";
15
16
  export { createUnsubscribeRoutes, DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE, signAddressUnsubscribeToken, signUnsubscribeToken, } from "./unsubscribe.js";
17
+ export { UNSUBSCRIBE_PAGE_EN } from "./unsubscribe-page-strings.js";
@@ -8,6 +8,7 @@ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
8
8
  import type { Redis } from "ioredis";
9
9
  import type { KillSwitchResolver, RateLimitConfig } from "./delivery-service.js";
10
10
  import type { DeliveryService } from "./types.js";
11
+ import type { UnsubscribeLinkConfig } from "./unsubscribe-link.js";
11
12
  export type CreateDeliveryTestContextOptions = {
12
13
  readonly tenantUserIdsQuery?: string;
13
14
  readonly rateLimit?: RateLimitConfig;
@@ -19,6 +20,7 @@ export type CreateDeliveryTestContextOptions = {
19
20
  readonly idempotencyRedis?: Redis;
20
21
  readonly filesForTenant?: (tenantId: string) => FileContext | undefined;
21
22
  readonly tenantConfigFor?: (tenantId: TenantId) => ConfigAccessor;
23
+ readonly unsubscribeLinks?: UnsubscribeLinkConfig;
22
24
  };
23
25
  /**
24
26
  * Helper for setupTestStack: creates a DeliveryService + _notifyFactory for the test context.
@@ -1,6 +1,6 @@
1
1
  import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { DbConnection, TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
- import type { DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { ConfigAccessor, DeliveryErrorCode, NotifyDeliveryStatus, NotifyDispatched, NotifyJobDispatcher, NotifyOptions, NotifyPriority, Registry, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
5
5
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
6
6
  import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
@@ -23,6 +23,7 @@ export type ChannelContext = {
23
23
  readonly sseBroker: SseBroker | undefined;
24
24
  readonly tenantId: TenantId;
25
25
  readonly secrets?: SecretsContext | undefined;
26
+ readonly config?: ConfigAccessor | undefined;
26
27
  readonly renderMail?: (payload: MailPayload) => Promise<RenderedMail>;
27
28
  readonly readAttachment?: (ref: EmailAttachmentRef, origin?: AttachmentOrigin) => Promise<{
28
29
  readonly content: Uint8Array;
@@ -0,0 +1,25 @@
1
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { ChannelContext } from "./types.js";
3
+ /** Secret shared with `createUnsubscribeRoutes` plus the public origin the links point at. */
4
+ export type UnsubscribeLinkConfig = {
5
+ readonly secret: string;
6
+ readonly baseUrl: string;
7
+ /** The sending tenant's own public origin (subdomain or custom domain), null when it has
8
+ * none. The host must serve this app's `/api` routes. Null, a throw or an unusable value
9
+ * fall back to `baseUrl`, so a missing tenant origin never fails a send. */
10
+ readonly tenantOrigin?: (ctx: ChannelContext) => Promise<string | null | undefined>;
11
+ };
12
+ export type UnsubscribeLinkRecipient = {
13
+ readonly kind: "user";
14
+ readonly userId: string;
15
+ } | {
16
+ readonly kind: "address";
17
+ readonly address: string;
18
+ };
19
+ export declare function unsubscribeLinkOrigin(config: UnsubscribeLinkConfig, ctx: ChannelContext, onFallback: (reason: string) => void): Promise<string>;
20
+ export declare function buildUnsubscribeUrl(config: UnsubscribeLinkConfig, origin: string, recipient: UnsubscribeLinkRecipient, scope: {
21
+ tenantId: TenantId;
22
+ notificationType: string;
23
+ channel: string;
24
+ locale: string | undefined;
25
+ }): Promise<string | undefined>;
@@ -0,0 +1,40 @@
1
+ import { publicOriginOf } from "@cosmicdrift/kumiko-framework/api";
2
+ import { hashUnsubscribeAddress } from "./address-opt-out.js";
3
+ import { DELIVERY_UNSUBSCRIBE_PATH } from "./constants.js";
4
+ import { signAddressUnsubscribeToken, signUnsubscribeToken } from "./unsubscribe.js";
5
+ // https only, unless the app itself runs on plain http (local dev): a tenant origin must not
6
+ // downgrade the link that carries the token.
7
+ function isUsableTenantOrigin(origin, baseUrl) {
8
+ return (origin.startsWith("https://") || (baseUrl.startsWith("http://") && origin.startsWith("http://")));
9
+ }
10
+ export async function unsubscribeLinkOrigin(config, ctx, onFallback) {
11
+ if (config.tenantOrigin === undefined)
12
+ return config.baseUrl;
13
+ let resolved;
14
+ try {
15
+ resolved = await config.tenantOrigin(ctx);
16
+ }
17
+ catch (err) {
18
+ onFallback(`tenantOrigin threw (${err instanceof Error ? err.name : typeof err})`);
19
+ return config.baseUrl;
20
+ }
21
+ if (resolved === null || resolved === undefined)
22
+ return config.baseUrl;
23
+ const origin = publicOriginOf(resolved);
24
+ if (origin === undefined || !isUsableTenantOrigin(origin, config.baseUrl)) {
25
+ onFallback("tenantOrigin returned no usable https origin");
26
+ return config.baseUrl;
27
+ }
28
+ return origin;
29
+ }
30
+ // Undefined means "no link": without a blind-index key an address opt-out could never be
31
+ // matched again, and a mail without a link is better than a failed send.
32
+ export async function buildUnsubscribeUrl(config, origin, recipient, scope) {
33
+ if (recipient.kind === "address" && hashUnsubscribeAddress(recipient.address) === undefined) {
34
+ return undefined;
35
+ }
36
+ const token = recipient.kind === "user"
37
+ ? await signUnsubscribeToken({ ...scope, userId: recipient.userId }, config.secret)
38
+ : await signAddressUnsubscribeToken({ ...scope, address: recipient.address }, config.secret);
39
+ return `${origin}${DELIVERY_UNSUBSCRIBE_PATH}?token=${encodeURIComponent(token)}`;
40
+ }
@@ -0,0 +1,11 @@
1
+ export declare const UNSUBSCRIBE_PAGE_EN: {
2
+ readonly "mail.unsubscribe.confirmTitle": "Unsubscribe";
3
+ readonly "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?";
4
+ readonly "mail.unsubscribe.confirmButton": "Unsubscribe";
5
+ readonly "mail.unsubscribe.doneTitle": "Unsubscribed";
6
+ readonly "mail.unsubscribe.doneMessage": "You have been unsubscribed.";
7
+ readonly "mail.unsubscribe.undoButton": "Undo";
8
+ readonly "mail.unsubscribe.resubscribedTitle": "Subscribed again";
9
+ readonly "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.";
10
+ readonly "mail.unsubscribe.failed": "That did not work. Please try again later.";
11
+ };
@@ -0,0 +1,15 @@
1
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
2
+ // The pages behind a mailed unsubscribe link speak the mail's language, so their texts live in
3
+ // the mail translations: English here, other languages in the locale packages.
4
+ export const UNSUBSCRIBE_PAGE_EN = {
5
+ "mail.unsubscribe.confirmTitle": "Unsubscribe",
6
+ "mail.unsubscribe.confirmQuestion": "Unsubscribe from these emails?",
7
+ "mail.unsubscribe.confirmButton": "Unsubscribe",
8
+ "mail.unsubscribe.doneTitle": "Unsubscribed",
9
+ "mail.unsubscribe.doneMessage": "You have been unsubscribed.",
10
+ "mail.unsubscribe.undoButton": "Undo",
11
+ "mail.unsubscribe.resubscribedTitle": "Subscribed again",
12
+ "mail.unsubscribe.resubscribedMessage": "You have been subscribed again.",
13
+ "mail.unsubscribe.failed": "That did not work. Please try again later.",
14
+ };
15
+ registerMailTranslations("en", UNSUBSCRIBE_PAGE_EN);
@@ -1,16 +1,19 @@
1
1
  import { type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import "./unsubscribe-page-strings.js";
3
4
  export type UnsubscribeTokenPayload = {
4
5
  readonly userId: string;
5
6
  readonly tenantId: TenantId;
6
7
  readonly notificationType: string;
7
8
  readonly channel: string;
9
+ readonly locale?: string | undefined;
8
10
  };
9
11
  export type AddressUnsubscribeTokenPayload = {
10
12
  readonly tenantId: TenantId;
11
13
  readonly address: string;
12
14
  readonly notificationType: string;
13
15
  readonly channel: string;
16
+ readonly locale?: string | undefined;
14
17
  };
15
18
  /**
16
19
  * `secret` must be a value dedicated to unsubscribe-token signing — do NOT
@@ -1,10 +1,18 @@
1
1
  import { ExtraRouteRejection, signatureRoute, } from "@cosmicdrift/kumiko-framework/api";
2
+ import { mailT, resolveMailLocale } from "@cosmicdrift/kumiko-framework/i18n";
2
3
  import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
3
4
  import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
4
5
  import * as jose from "jose";
5
6
  import * as z from "zod";
6
7
  import { hashUnsubscribeAddress } from "./address-opt-out.js";
7
8
  import { DELIVERY_RESUBSCRIBE_PATH, DELIVERY_UNSUBSCRIBE_PATH, DeliveryErrors, DeliveryHandlers, } from "./constants.js";
9
+ import "./unsubscribe-page-strings.js";
10
+ // The mail's locale, so the pages behind the link speak the mail's language. Optional: links
11
+ // signed before the claim existed keep verifying and fall back to English.
12
+ const localeClaimSchema = z
13
+ .string()
14
+ .regex(/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/)
15
+ .optional();
8
16
  // Shape des verified-JWT-payloads. tenantId kommt als string aus jose und
9
17
  // wird NACH erfolgreichem parse() zur Branded TenantId — kein blind-cast.
10
18
  //
@@ -19,6 +27,7 @@ const unsubscribeJwtPayloadSchema = z.object({
19
27
  tenantId: z.string().min(1),
20
28
  notificationType: z.string().min(1),
21
29
  channel: z.string().min(1),
30
+ locale: localeClaimSchema,
22
31
  });
23
32
  // Address-token payload. `sub` and `addressHash` are the same value (the
24
33
  // subject claim IS the blind-index hash) — kept as two fields because the
@@ -31,6 +40,7 @@ const addressUnsubscribeJwtPayloadSchema = z.object({
31
40
  notificationType: z.string().min(1),
32
41
  channel: z.string().min(1),
33
42
  addressHash: z.string().min(1),
43
+ locale: localeClaimSchema,
34
44
  });
35
45
  const UNSUBSCRIBE_EXPIRY = "7d";
36
46
  const RESUBSCRIBE_EXPIRY = "1h";
@@ -42,6 +52,10 @@ function assertUnsubscribeSecret(secret, caller) {
42
52
  throw new Error(`${caller}: secret must be ≥${MIN_UNSUBSCRIBE_SECRET_LENGTH} chars (HMAC-SHA256 token signing)`);
43
53
  }
44
54
  }
55
+ // An unusable locale is left out instead of failing the send; the pages then fall back to English.
56
+ function localeClaim(locale) {
57
+ return locale !== undefined && localeClaimSchema.safeParse(locale).success ? { locale } : {};
58
+ }
45
59
  const UNSUBSCRIBE_TOKEN_INVALID_BODY = {
46
60
  error: { code: "unsubscribe_token_invalid", message: "Invalid or expired token" },
47
61
  };
@@ -52,6 +66,7 @@ export async function signUnsubscribeToken(payload, secret) {
52
66
  tenantId: payload.tenantId,
53
67
  notificationType: payload.notificationType,
54
68
  channel: payload.channel,
69
+ ...localeClaim(payload.locale),
55
70
  })
56
71
  .setProtectedHeader({ alg: "HS256" })
57
72
  .setSubject(String(payload.userId))
@@ -80,6 +95,7 @@ export async function signAddressUnsubscribeToken(payload, secret) {
80
95
  channel: payload.channel,
81
96
  addressHash,
82
97
  kind: "address",
98
+ ...localeClaim(payload.locale),
83
99
  })
84
100
  .setProtectedHeader({ alg: "HS256" })
85
101
  .setSubject(addressHash)
@@ -104,6 +120,7 @@ async function signResubscribeToken(verified, encodedSecret) {
104
120
  tenantId: verified.tenantId,
105
121
  notificationType: verified.notificationType,
106
122
  channel: verified.channel,
123
+ ...localeClaim(verified.locale),
107
124
  };
108
125
  const jwt = verified.kind === "address"
109
126
  ? new jose.SignJWT({
@@ -154,6 +171,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
154
171
  addressHash: parsed.addressHash,
155
172
  notificationType: parsed.notificationType,
156
173
  channel: parsed.channel,
174
+ locale: parsed.locale,
157
175
  };
158
176
  }
159
177
  const userAttempt = unsubscribeJwtPayloadSchema.safeParse(verifiedPayload);
@@ -169,6 +187,7 @@ async function verifyUnsubscribeToken(token, encodedSecret, purpose) {
169
187
  userId: parsed.sub,
170
188
  notificationType: parsed.notificationType,
171
189
  channel: parsed.channel,
190
+ locale: parsed.locale,
172
191
  };
173
192
  }
174
193
  catch (err) {
@@ -228,39 +247,50 @@ async function dispatchResubscribeWrite(verified, deps) {
228
247
  return "success";
229
248
  return isResubscribeLimitReached(dispatched.error) ? "limit_reached" : "failed";
230
249
  }
231
- function confirmationPage(token) {
250
+ function pageLocale(locale) {
251
+ return resolveMailLocale(locale ?? "en");
252
+ }
253
+ function htmlPage(locale, title, bodyHtml) {
232
254
  return `<!doctype html>
233
- <html>
255
+ <html lang="${escapeHtmlAttr(locale)}">
234
256
  <head>
235
257
  <meta charset="utf-8">
236
258
  <meta name="robots" content="noindex">
237
- <title>Unsubscribe</title>
259
+ <meta name="viewport" content="width=device-width, initial-scale=1">
260
+ <title>${escapeHtmlAttr(title)}</title>
238
261
  </head>
239
262
  <body>
240
- <p>Unsubscribe from these emails?</p>
241
- <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
242
- <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
243
- <button type="submit">Unsubscribe</button>
244
- </form>
263
+ ${bodyHtml}
245
264
  </body>
246
265
  </html>`;
247
266
  }
248
- function unsubscribedPage(resubscribeToken) {
249
- return `<!doctype html>
250
- <html>
251
- <head>
252
- <meta charset="utf-8">
253
- <meta name="robots" content="noindex">
254
- <title>Unsubscribed</title>
255
- </head>
256
- <body>
257
- <p>You have been unsubscribed.</p>
267
+ function confirmationPage(token, locale) {
268
+ const lang = pageLocale(locale);
269
+ return htmlPage(lang, mailT(lang, "mail.unsubscribe.confirmTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmQuestion"))}</p>
270
+ <form method="post" action="${DELIVERY_UNSUBSCRIBE_PATH}">
271
+ <input type="hidden" name="token" value="${escapeHtmlAttr(token)}">
272
+ <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.confirmButton"))}</button>
273
+ </form>`);
274
+ }
275
+ function unsubscribedPage(resubscribeToken, locale) {
276
+ const lang = pageLocale(locale);
277
+ return htmlPage(lang, mailT(lang, "mail.unsubscribe.doneTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.doneMessage"))}</p>
258
278
  <form method="post" action="${DELIVERY_RESUBSCRIBE_PATH}">
259
279
  <input type="hidden" name="token" value="${escapeHtmlAttr(resubscribeToken)}">
260
- <button type="submit">Undo</button>
261
- </form>
262
- </body>
263
- </html>`;
280
+ <button type="submit">${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.undoButton"))}</button>
281
+ </form>`);
282
+ }
283
+ function resubscribedPage(locale) {
284
+ const lang = pageLocale(locale);
285
+ return htmlPage(lang, mailT(lang, "mail.unsubscribe.resubscribedTitle"), `<p>${escapeHtmlAttr(mailT(lang, "mail.unsubscribe.resubscribedMessage"))}</p>`);
286
+ }
287
+ function failedPage(locale) {
288
+ const lang = pageLocale(locale);
289
+ const message = mailT(lang, "mail.unsubscribe.failed");
290
+ return htmlPage(lang, message, `<p>${escapeHtmlAttr(message)}</p>`);
291
+ }
292
+ function isFormRequest(contentType) {
293
+ return contentType?.includes("application/x-www-form-urlencoded") === true;
264
294
  }
265
295
  // One-click clients put the token in the query string; only the
266
296
  // confirmation-page form submits it in the body.
@@ -304,10 +334,10 @@ export function createUnsubscribeRoutes(options) {
304
334
  if (!token) {
305
335
  throw new ExtraRouteRejection(400, UNSUBSCRIBE_TOKEN_INVALID_BODY, "missing unsubscribe token");
306
336
  }
307
- await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
308
- return { token };
337
+ const verified = await verifyUnsubscribeToken(token, encodedSecret, "unsubscribe");
338
+ return { token, locale: verified.locale };
309
339
  },
310
- handler: async (c, { token }) => c.html(confirmationPage(token), 200, CONFIRMATION_PAGE_HEADERS),
340
+ handler: async (c, { token, locale }) => c.html(confirmationPage(token, locale), 200, CONFIRMATION_PAGE_HEADERS),
311
341
  });
312
342
  const writeRoute = signatureRoute({
313
343
  method: "POST",
@@ -320,11 +350,9 @@ export function createUnsubscribeRoutes(options) {
320
350
  // (e.g. events-table missing, DB down) behind a misleading 400.
321
351
  const succeeded = await dispatchUnsubscribeWrite(verified, deps);
322
352
  if (!succeeded) {
323
- return c.html("Unsubscribe failed", 500, { "Cache-Control": "no-store" });
353
+ return c.html(failedPage(verified.locale), 500, { "Cache-Control": "no-store" });
324
354
  }
325
- return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret)), 200, {
326
- "Cache-Control": "no-store",
327
- });
355
+ return c.html(unsubscribedPage(await signResubscribeToken(verified, encodedSecret), verified.locale), 200, { "Cache-Control": "no-store" });
328
356
  },
329
357
  });
330
358
  // Undo direction: token only from the JSON/form body, never the query
@@ -337,6 +365,14 @@ export function createUnsubscribeRoutes(options) {
337
365
  verify: async (request) => verifyUnsubscribeToken(tokenFromRequestBody(request), encodedSecret, "resubscribe"),
338
366
  handler: async (c, verified, deps) => {
339
367
  const outcome = await dispatchResubscribeWrite(verified, deps);
368
+ // The undo button on the unsubscribed page posts a form; a browser should land on a page.
369
+ if (isFormRequest(c.req.header("content-type"))) {
370
+ return outcome === "success"
371
+ ? c.html(resubscribedPage(verified.locale), 200, { "Cache-Control": "no-store" })
372
+ : c.html(failedPage(verified.locale), outcome === "limit_reached" ? 409 : 500, {
373
+ "Cache-Control": "no-store",
374
+ });
375
+ }
340
376
  if (outcome === "limit_reached") {
341
377
  return c.json({ error: { code: DeliveryErrors.resubscribeLimitReached } }, 409, {
342
378
  "Cache-Control": "no-store",
@@ -12,8 +12,7 @@
12
12
  // loser's create() comes back as a `version_conflict` failure; re-lookup and
13
13
  // fall through to update, or no-op if the winner already set the target
14
14
  // `enabled` state.
15
- import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
16
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
15
+ import { createEventStoreExecutor, fetchOne, } from "@cosmicdrift/kumiko-framework/db";
17
16
  import { generateDeterministicId } from "@cosmicdrift/kumiko-framework/utils";
18
17
  import { notificationPreferenceEntity, notificationPreferencesTable } from "./tables.js";
19
18
  const executor = createEventStoreExecutor(notificationPreferencesTable, notificationPreferenceEntity, { entityName: "notification-preference" });
@@ -1,3 +1,7 @@
1
+ export declare function createDerivativesSharpFeature(): import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
2
+ readonly exports: void;
3
+ };
4
+ /** @deprecated Use createDerivativesSharpFeature(). Run `bun kumiko-upgrade --apply`. Removed in the first release after 2026-10-29. */
1
5
  export declare const derivativesSharpFeature: import("@cosmicdrift/kumiko-types/feature").FeatureDefinition & {
2
6
  readonly exports: void;
3
7
  };
@@ -2,14 +2,18 @@
2
2
  import { defineFeature, EXT_DERIVATIVE_RENDERER } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { renderImage } from "./render.js";
4
4
  const FEATURE_NAME = "derivatives-sharp";
5
- export const derivativesSharpFeature = defineFeature(FEATURE_NAME, (r) => {
6
- r.describe("Registers an `image/*` renderer against the `derivativeRenderer` extension point declared by `file-derivatives`, backed by sharp. Supports resize/fit (cover, inside, contain), format conversion with quality (webp, avif, jpeg), whole-image blur, and blurring individual regions burned in before resize (see BlurRegion). EXIF orientation is applied and then normalized away, and all other EXIF (including GPS) is dropped. Server-only: sharp is a native binding — never import this from client code.");
7
- r.uiHints({
8
- displayLabel: "File Derivatives · Sharp",
9
- category: "storage",
10
- recommended: false,
11
- });
12
- r.requires({ features: ["file-derivatives"] });
13
- const plugin = { render: renderImage };
14
- r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
15
- });
5
+ export function createDerivativesSharpFeature() {
6
+ return defineFeature(FEATURE_NAME, (r) => {
7
+ r.describe("Registers an `image/*` renderer against the `derivativeRenderer` extension point declared by `file-derivatives`, backed by sharp. Supports resize/fit (cover, inside, contain), format conversion with quality (webp, avif, jpeg), whole-image blur, and blurring individual regions burned in before resize (see BlurRegion). EXIF orientation is applied and then normalized away, and all other EXIF (including GPS) is dropped. Server-only: sharp is a native binding — never import this from client code.");
8
+ r.uiHints({
9
+ displayLabel: "File Derivatives · Sharp",
10
+ category: "storage",
11
+ recommended: false,
12
+ });
13
+ r.requires({ features: ["file-derivatives"] });
14
+ const plugin = { render: renderImage };
15
+ r.useExtension(EXT_DERIVATIVE_RENDERER, "image/*", plugin);
16
+ }, { dedupeOptions: {} });
17
+ }
18
+ /** @deprecated Use createDerivativesSharpFeature(). Run `bun kumiko-upgrade --apply`. Removed in the first release after 2026-10-29. */
19
+ export const derivativesSharpFeature = createDerivativesSharpFeature();
@@ -1,2 +1,2 @@
1
- export { derivativesSharpFeature } from "./feature.js";
1
+ export { createDerivativesSharpFeature, derivativesSharpFeature } from "./feature.js";
2
2
  export { imageMetadata } from "./render.js";