@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,5 +1,6 @@
1
1
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
3
4
  import * as z from "zod";
4
5
  import {
5
6
  type AttachmentOrigin,
@@ -15,7 +16,7 @@ import {
15
16
  type RenderedMessage,
16
17
  renderedPdfAttachmentRefSchema,
17
18
  } from "../delivery/index.js";
18
- import { decryptStoredPii } from "../shared/index.js";
19
+ import { decryptStoredPii, extensionForResourceContentType } from "../shared/index.js";
19
20
  import { guardEmailMessage } from "./pii-guard.js";
20
21
  import type { EmailAttachment, EmailTransport } from "./types.js";
21
22
 
@@ -39,7 +40,8 @@ const UNSUBSCRIBE_HEADER_NAMES: ReadonlySet<string> = new Set([
39
40
  "list-unsubscribe-post",
40
41
  ]);
41
42
 
42
- // Only the framework's own unsubscribe route can honor a one-click POST.
43
+ // Only the framework's own unsubscribe route can honor a one-click POST; an app's
44
+ // own unsubscribe page still gets the plain List-Unsubscribe link (RFC 2369).
43
45
  function listUnsubscribeHeaders(
44
46
  data: Readonly<Record<string, unknown>> | undefined,
45
47
  ): Readonly<Record<string, string>> | undefined {
@@ -52,7 +54,9 @@ function listUnsubscribeHeaders(
52
54
  return undefined;
53
55
  }
54
56
  if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
55
- if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH) return undefined;
57
+ if (url.pathname !== DELIVERY_UNSUBSCRIBE_PATH) {
58
+ return { "List-Unsubscribe": `<${url.toString()}>` };
59
+ }
56
60
  return {
57
61
  "List-Unsubscribe": `<${url.toString()}>`,
58
62
  "List-Unsubscribe-Post": DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
@@ -110,20 +114,22 @@ export function fileAttachmentsFrom(
110
114
  );
111
115
  }
112
116
 
113
- const INLINE_EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
114
- "image/png": "png",
115
- "image/jpeg": "jpg",
116
- "image/gif": "gif",
117
- "image/webp": "webp",
118
- };
119
-
120
117
  type AttachmentSource = { readonly ref: EmailAttachmentRef; readonly origin: AttachmentOrigin };
121
118
 
119
+ function inlineFileName(cid: string, contentType: string): string {
120
+ const extension = extensionForResourceContentType(contentType);
121
+ return extension === undefined ? cid : `${cid}.${extension}`;
122
+ }
123
+
124
+ type ResolvedAttachments =
125
+ | { readonly kind: "resolved"; readonly attachments: readonly EmailAttachment[] }
126
+ | { readonly kind: "missing-file"; readonly fileRefId: string };
127
+
122
128
  async function resolveAttachments(
123
129
  sources: readonly AttachmentSource[],
124
130
  ctx: ChannelContext,
125
- ): Promise<readonly EmailAttachment[]> {
126
- if (sources.length === 0) return [];
131
+ ): Promise<ResolvedAttachments> {
132
+ if (sources.length === 0) return { kind: "resolved", attachments: [] };
127
133
  if (ctx.readAttachment === undefined) {
128
134
  throw new Error(
129
135
  "email channel: the message has attachments but the channel context cannot read files",
@@ -148,17 +154,13 @@ async function resolveAttachments(
148
154
  if (found === null) {
149
155
  // A missing inline image only degrades the look; a missing file is content the recipient expects.
150
156
  if (ref.kind === "inline") continue;
151
- throw new Error(`email channel: attachment file ${ref.fileRefId} was not found`);
157
+ return { kind: "missing-file", fileRefId: ref.fileRefId };
152
158
  }
153
159
  const fileName =
154
- ref.kind === "file"
155
- ? ref.fileName
156
- : INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]
157
- ? `${ref.cid}.${INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]}`
158
- : ref.cid;
160
+ ref.kind === "file" ? ref.fileName : inlineFileName(ref.cid, found.contentType);
159
161
  resolved.push({ ...ref, fileName, content: found.content, contentType: found.contentType });
160
162
  }
161
- return resolved;
163
+ return { kind: "resolved", attachments: resolved };
162
164
  }
163
165
 
164
166
  export type EmailChannelOptions = {
@@ -191,13 +193,20 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
191
193
  );
192
194
  }
193
195
  const unsubscribeUrl = message.data?.["unsubscribeUrl"];
194
- const { subject, html, text, attachments } = await ctx.renderMail({
196
+ const { subject, html, text, attachments, fromName, replyTo } = await ctx.renderMail({
195
197
  slug: notificationType,
196
198
  locale: message.locale ?? "en",
197
199
  variables: message.data ?? {},
198
200
  ...(typeof unsubscribeUrl === "string" && { unsubscribeUrl }),
199
201
  });
200
- return { html, subject, text, ...(attachments !== undefined && { attachments }) };
202
+ return {
203
+ html,
204
+ subject,
205
+ text,
206
+ ...(fromName !== undefined && { fromName }),
207
+ ...(replyTo !== undefined && { replyTo }),
208
+ ...(attachments !== undefined && { attachments }),
209
+ };
201
210
  }
202
211
 
203
212
  return {
@@ -217,9 +226,15 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
217
226
  },
218
227
 
219
228
  async send(address, message, ctx, rendered) {
220
- const { html, subject, text, attachments } = rendered ?? (await renderMessage(message, ctx));
221
- const envelope = emailEnvelopeFrom(message.data);
222
- const resolvedAttachments = await resolveAttachments(
229
+ const { html, subject, text, attachments, fromName, replyTo } =
230
+ rendered ?? (await renderMessage(message, ctx));
231
+ // Template-level sender defaults; notify data wins.
232
+ const envelope = {
233
+ ...(fromName && { fromName }),
234
+ ...(replyTo && { replyTo }),
235
+ ...emailEnvelopeFrom(message.data),
236
+ };
237
+ const resolution = await resolveAttachments(
223
238
  [
224
239
  ...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
225
240
  ...fileAttachmentsFrom(message.data, message.locale).map(
@@ -228,6 +243,14 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
228
243
  ],
229
244
  ctx,
230
245
  );
246
+ if (resolution.kind === "missing-file") {
247
+ // A missing file is permanent, a retry cannot bring it back.
248
+ createFallbackLogger("delivery").warn("mail attachment file not found, send failed", {
249
+ fileRefId: resolution.fileRefId,
250
+ });
251
+ return { status: "failed", error: "send_failed", address };
252
+ }
253
+ const resolvedAttachments = resolution.attachments;
231
254
  await transport.send(
232
255
  guardEmailMessage({
233
256
  to: address,
@@ -54,11 +54,17 @@ function isControlCharacter(char: string): boolean {
54
54
  return code <= 0x1f || code === 0x7f;
55
55
  }
56
56
 
57
+ // Mail clients often show only the display name: an embedded address ("<", ">", "@") would
58
+ // pass for a foreign sender on the platform's own SPF/DKIM-signed domain.
59
+ function isUnsafeDisplayNameCharacter(char: string): boolean {
60
+ return isControlCharacter(char) || char === "<" || char === ">" || char === "@";
61
+ }
62
+
57
63
  // Control characters in a display name would let a caller inject headers.
58
64
  function sanitizedDisplayName(name: string | undefined): string | undefined {
59
65
  const cleaned = name
60
66
  ?.split("")
61
- .map((char) => (isControlCharacter(char) ? " " : char))
67
+ .map((char) => (isUnsafeDisplayNameCharacter(char) ? " " : char))
62
68
  .join("")
63
69
  .trim();
64
70
  return cleaned === undefined || cleaned === "" ? undefined : cleaned;
@@ -7,9 +7,11 @@ import {
7
7
 
8
8
  export type SlackChannelOptions = ChatWebhookChannelOptions;
9
9
 
10
+ const SLACK_WEBHOOK_PREFIX = "channel-slack:webhooks.";
11
+
10
12
  export const SLACK_SECRET_KEYS = {
11
- webhookPrefix: "channel-slack:webhooks.",
12
- webhookKeyFor: (connection: string) => `channel-slack:webhooks.${connection}`,
13
+ webhookPrefix: SLACK_WEBHOOK_PREFIX,
14
+ webhookKeyFor: (connection: string) => `${SLACK_WEBHOOK_PREFIX}${connection}`,
13
15
  } as const;
14
16
 
15
17
  export const SLACK_DEFAULT_ALLOWED_HOSTS = ["hooks.slack.com"] as const;
@@ -8,9 +8,11 @@ import {
8
8
 
9
9
  export type TeamsChannelOptions = ChatWebhookChannelOptions;
10
10
 
11
+ const TEAMS_WEBHOOK_PREFIX = "channel-teams:webhooks.";
12
+
11
13
  export const TEAMS_SECRET_KEYS = {
12
- webhookPrefix: "channel-teams:webhooks.",
13
- webhookKeyFor: (connection: string) => `channel-teams:webhooks.${connection}`,
14
+ webhookPrefix: TEAMS_WEBHOOK_PREFIX,
15
+ webhookKeyFor: (connection: string) => `${TEAMS_WEBHOOK_PREFIX}${connection}`,
14
16
  } as const;
15
17
 
16
18
  export const TEAMS_DEFAULT_ALLOWED_HOSTS = [
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Config key validate() receives a db scoped to the written tenant",
6
+ "detail": "A SystemAdmin writing a config value for another tenant previously handed the validator a system-mode db that could read every tenant. `ConfigValidateContext.db` is now a `TenantDb` locked to the tenant the value is written for. The validator runs inside the write transaction, so a slow query there should set its own timeout."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "improvement",
@@ -4,7 +4,7 @@ import {
4
4
  encryptPiiValueForSubject,
5
5
  type KmsContext,
6
6
  } from "@cosmicdrift/kumiko-framework/crypto";
7
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
+ import { createEventStoreExecutor, scopeTenantDbToTenant } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  ConfigScopes,
10
10
  defineWriteHandler,
@@ -111,7 +111,7 @@ export const setWrite = defineWriteHandler({
111
111
  scope,
112
112
  tenantId,
113
113
  userId,
114
- db,
114
+ db: scopeTenantDbToTenant(db, tenantId),
115
115
  });
116
116
  } catch (error) {
117
117
  if (isKumikoError(error)) return writeFailure(error);
@@ -119,6 +119,19 @@ async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<s
119
119
  return decryptPiiValueForSubject(kms, raw, kmsCtx, qualifiedKey);
120
120
  }
121
121
 
122
+ // Plaintext form of a stored config_values.value for every read path (resolver,
123
+ // bootstrap idempotency check).
124
+ export async function decryptStoredValue(
125
+ raw: string,
126
+ keyDef: ConfigKeyDefinition,
127
+ cipher: EnvelopeCipher | undefined,
128
+ qualifiedKey: string,
129
+ ): Promise<string> {
130
+ if (keyDef.encrypted) return decryptEncrypted(cipher, raw, qualifiedKey);
131
+ if (keyDef.piiEncrypted) return decryptPiiEncrypted(raw, qualifiedKey);
132
+ return raw;
133
+ }
134
+
122
135
  // backing="secrets" keys store their value in the secrets store (flat per
123
136
  // (tenant,key) at SYSTEM_TENANT_ID), not in config_values. Both read paths
124
137
  // (getWithSource + buildCascade) route the system rung here. Missing reader =
@@ -230,12 +243,7 @@ async function buildCascade(
230
243
 
231
244
  const row = await fetchRow(lookup.tenantId, lookup.userId);
232
245
  if (row?.value !== null && row?.value !== undefined) {
233
- let raw = row.value;
234
- if (keyDef.encrypted) {
235
- raw = await decryptEncrypted(cipher, raw, qualifiedKey);
236
- } else if (keyDef.piiEncrypted) {
237
- raw = await decryptPiiEncrypted(raw, qualifiedKey);
238
- }
246
+ const raw = await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey);
239
247
  if (activeIndex === -1) activeIndex = levels.length;
240
248
  levels.push({
241
249
  label: lookup.label,
@@ -409,12 +417,7 @@ export function createConfigResolver(options: ConfigResolverOptions = {}): Confi
409
417
  for (const lookup of lookups) {
410
418
  const row = await findRow(qualifiedKey, lookup.tenantId, lookup.userId, db);
411
419
  if (row?.value !== null && row?.value !== undefined) {
412
- let raw = row.value;
413
- if (keyDef.encrypted) {
414
- raw = await decryptEncrypted(cipher, raw, qualifiedKey);
415
- } else if (keyDef.piiEncrypted) {
416
- raw = await decryptPiiEncrypted(raw, qualifiedKey);
417
- }
420
+ const raw = await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey);
418
421
  return { value: deserializeValue(raw, keyDef.type), source: lookup.source };
419
422
  }
420
423
  }
@@ -28,8 +28,10 @@ import {
28
28
  type WriteFailure,
29
29
  writeFailure,
30
30
  } from "@cosmicdrift/kumiko-framework/errors";
31
+ import type { EnvelopeCipher } from "@cosmicdrift/kumiko-framework/secrets";
31
32
  import { assertUnreachable } from "@cosmicdrift/kumiko-framework/utils";
32
33
  import { ConfigErrors } from "./constants.js";
34
+ import { decryptStoredValue } from "./resolver.js";
33
35
  import { configValuesTable } from "./table.js";
34
36
 
35
37
  export type ConfigRowLookup = {
@@ -62,6 +64,27 @@ export async function findConfigRow(
62
64
  };
63
65
  }
64
66
 
67
+ // Idempotency check for declarative writers: does the stored row already hold
68
+ // `serializedValue` (JSON of the plaintext)? Encrypted rows are decrypted
69
+ // first, since their ciphertext differs on every encrypt.
70
+ export async function configRowHoldsValue(
71
+ row: ConfigRowLookup | null,
72
+ keyDef: ConfigKeyDefinition | undefined,
73
+ cipher: EnvelopeCipher | undefined,
74
+ qualifiedKey: string,
75
+ serializedValue: string,
76
+ ): Promise<boolean> {
77
+ if (row?.value === null || row?.value === undefined) return false;
78
+ if (!keyDef?.encrypted && !keyDef?.piiEncrypted) return row.value === serializedValue;
79
+ if (!keyDef) return false;
80
+ try {
81
+ return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
82
+ } catch {
83
+ // Unreadable (rotated key, missing cipher/KMS): the caller rewrites the row, which is the repair.
84
+ return false;
85
+ }
86
+ }
87
+
65
88
  // Three-stage pre-write gate that set + reset both need: resolve the key
66
89
  // definition (404 when unknown), check the user's roles can write it (403),
67
90
  // and map the target scope to the (tenantId | null, userId | null) pair the
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "forget-subject keeps the user status flip and PAT revocation when the sweep fails",
6
+ "detail": "The user is set to Deleted and its personal access tokens are revoked right after the key erase, in their own transaction on the outside-transaction db. A failing blind-index or search sweep still makes the handler throw so the cleanup can be retried, but it no longer rolls the login door back. Without an outside-transaction db the previous in-transaction savepoint path is used. A failure while closing the login door of an existing user now fails the forget (retry is safe) instead of passing silently; subjects without a user row are skipped."
7
+ },
2
8
  {
3
9
  "version": "0.357.0",
4
10
  "type": "improvement",
@@ -1,6 +1,11 @@
1
1
  import { requestContext } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
3
- import { runInSavepointIfSupported } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import {
4
+ fetchOne,
5
+ runInSavepointIfSupported,
6
+ runnerCanBeginTransaction,
7
+ transaction,
8
+ } from "@cosmicdrift/kumiko-framework/bun-db";
4
9
  import {
5
10
  computeBlindIndex,
6
11
  configuredBlindIndexKey,
@@ -16,6 +21,7 @@ import {
16
21
  recordRowExistsInTenant,
17
22
  recordRowOwningTenantId,
18
23
  subjectRowExistsInTenant,
24
+ WritePurposes,
19
25
  } from "@cosmicdrift/kumiko-framework/db";
20
26
  import {
21
27
  defineWriteHandler,
@@ -42,7 +48,7 @@ import * as z from "zod";
42
48
  import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
43
49
  import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
44
50
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
45
- import { USER_STATUS } from "../../user/index.js";
51
+ import { USER_STATUS, userTable } from "../../user/index.js";
46
52
  import {
47
53
  denyIfTargetOutsideAdminTenant,
48
54
  isSystemAdminActor,
@@ -337,25 +343,38 @@ async function sweepDerivedSubjectData(
337
343
  }
338
344
  }
339
345
 
340
- // Graceful fail: email-subscribers and other non-user entities may use
341
- // user-style subject keys without having an actual user row — the key erase is
342
- // the important part for GDPR compliance; the lifecycle update is best-effort for
343
- // real users. The savepoint confines the failure so the handler transaction
344
- // stays usable for the audit event.
345
- async function closeUserLoginDoorBestEffort(
346
+ // Email subscribers and other non-user entities use user-style subject keys
347
+ // without a read_users row — the key erase is the GDPR-relevant part, so no row
348
+ // means nothing to close. For an existing row, failures propagate: the handler
349
+ // throws and the retry re-runs the (idempotent) erase plus the door close. With
350
+ // an outside-transaction runner that can open its own transaction, the door
351
+ // closes there and stays committed when the handler throws afterwards (sweep
352
+ // error). Otherwise a savepoint confines a failure to the handler transaction.
353
+ async function closeUserLoginDoor(
346
354
  ctx: HandlerContext,
347
355
  userId: string,
348
- lifecycleRunner: DbRunner,
356
+ outsideRunner: DbRunner | undefined,
357
+ handlerTxRunner: DbRunner,
349
358
  ): Promise<void> {
350
- try {
351
- await runInSavepointIfSupported(lifecycleRunner, async (userLifecycleRunner) => {
352
- await updateUserLifecycle(userLifecycleRunner, userId, { status: USER_STATUS.Deleted });
353
- if (ctx.registry.features.has("personal-access-tokens")) {
354
- await revokeAllPatTokensForUser(userLifecycleRunner, userId);
355
- }
356
- });
357
- } catch {
358
- // skip: no user row for this subject key (e.g. email subscribers); key erase and sweeps already ran.
359
+ const userRow = await fetchOne(handlerTxRunner, userTable, { id: userId });
360
+ // skip: no user row for this subject key (e.g. email subscribers); there is no door to close.
361
+ if (!userRow) return;
362
+
363
+ const closeDoor = async (runner: DbRunner): Promise<void> => {
364
+ await updateUserLifecycle(
365
+ runner,
366
+ userId,
367
+ { status: USER_STATUS.Deleted },
368
+ { writePurpose: WritePurposes.erasure },
369
+ );
370
+ if (ctx.registry.features.has("personal-access-tokens")) {
371
+ await revokeAllPatTokensForUser(runner, userId);
372
+ }
373
+ };
374
+ if (outsideRunner && runnerCanBeginTransaction(outsideRunner)) {
375
+ await transaction(outsideRunner, closeDoor);
376
+ } else {
377
+ await runInSavepointIfSupported(handlerTxRunner, closeDoor);
359
378
  }
360
379
  }
361
380
 
@@ -490,14 +509,6 @@ export const forgetSubjectWrite = defineWriteHandler({
490
509
  eraseReason: event.payload.reason,
491
510
  });
492
511
 
493
- const cleanupError = await sweepDerivedSubjectData(
494
- ctx,
495
- subject,
496
- subjectKey,
497
- ctx.db.unsafeRaw(),
498
- { requestId, erasedBy: event.user.id },
499
- );
500
-
501
512
  // User subject: close the login door. DEK-erase makes the passwordHash
502
513
  // ciphertext unreadable, but status + PATs are standalone credentials —
503
514
  // the PAT resolver only checks revokedAt/expiresAt, NOT user.status
@@ -513,10 +524,26 @@ export const forgetSubjectWrite = defineWriteHandler({
513
524
  // crash recovery is safe. User-feature guard: without the user feature
514
525
  // read_users doesn't exist (crypto-only stack). Tenant subjects have no
515
526
  // credentials.
527
+ // Runs before the sweep and in its own transaction: a failing sweep rolls the
528
+ // handler transaction back, and the handler tx holds no lock on this user's
529
+ // read_users row yet (only read gates ran), so there is no lock conflict.
516
530
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
517
- await closeUserLoginDoorBestEffort(ctx, raw.userId, ctx.db.unsafeRaw());
531
+ await closeUserLoginDoor(
532
+ ctx,
533
+ raw.userId,
534
+ ctx.dbOutsideTransaction?.unsafeRaw(),
535
+ ctx.db.unsafeRaw(),
536
+ );
518
537
  }
519
538
 
539
+ const cleanupError = await sweepDerivedSubjectData(
540
+ ctx,
541
+ subject,
542
+ subjectKey,
543
+ ctx.db.unsafeRaw(),
544
+ { requestId, erasedBy: event.user.id },
545
+ );
546
+
520
547
  await appendSubjectForgotten(
521
548
  ctx,
522
549
  event,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "update-tenant-field no longer hides `type` from the handler schema",
6
+ "detail": "The `type` field is part of the documented payload; the 422 check against changing the stored type remains the protection."
7
+ },
2
8
  {
3
9
  "version": "0.359.0",
4
10
  "type": "improvement",
@@ -1,5 +1,5 @@
1
1
  import { asRawClient, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
2
+ import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
3
3
 
4
4
  export async function selectHostRowsWithCustomFields(
5
5
  db: DbRunner,
@@ -38,6 +38,6 @@ export async function applyRetentionRemovals(
38
38
  ELSE custom_fields
39
39
  END
40
40
  WHERE id = $3`,
41
- [deleteKeys, anonymizeKeys, rowId],
41
+ [toPostgresArrayLiteral(deleteKeys), toPostgresArrayLiteral(anonymizeKeys), rowId],
42
42
  );
43
43
  }
@@ -46,7 +46,6 @@ export function createUpdateTenantFieldHandler(
46
46
  access: { roles: opts.roles ?? DEFAULT_FIELD_DEFINITION_WRITE_ROLES },
47
47
  description:
48
48
  "Replaces a tenant-owned custom-field definition with a complete new spec; serializedField.type may be omitted, in which case the stored type applies, and a different type is refused. Use it to relabel or re-constrain an existing field rather than to create or remove one.",
49
- excludedFields: ["type"],
50
49
  handler: async (event, ctx) => {
51
50
  const payload = event.payload as UpdateFieldPayload; // @cast-boundary engine-payload
52
51
  const tenantId = event.user.tenantId;
@@ -57,7 +57,7 @@ import {
57
57
  type WhereObject,
58
58
  WritePurposes,
59
59
  } from "@cosmicdrift/kumiko-framework/db";
60
- import { deleteStoredFileAndDerivatives } from "@cosmicdrift/kumiko-framework/derivatives";
60
+ import { deleteFileRefBytes } from "@cosmicdrift/kumiko-framework/derivatives";
61
61
  import {
62
62
  createSystemUser,
63
63
  type EntityDefinition,
@@ -386,11 +386,11 @@ async function isFileRefSharedByOtherRow(
386
386
  // not abort the whole tenant run.
387
387
  async function deleteFileFailingClosed(
388
388
  key: string,
389
- store: Parameters<typeof deleteStoredFileAndDerivatives>[1],
389
+ files: FileContext,
390
390
  logContext: string,
391
391
  ): Promise<readonly string[]> {
392
392
  try {
393
- return await deleteStoredFileAndDerivatives(key, store, "data-retention:hardDelete");
393
+ return await deleteFileRefBytes(files, { storageKey: key }, "data-retention:hardDelete");
394
394
  } catch (err) {
395
395
  // biome-ignore lint/suspicious/noConsole: operator-visibility for storage-cleanup failures
396
396
  console.warn(
@@ -477,7 +477,6 @@ async function purgeHardDeleteRowWithFiles(args: {
477
477
  return false;
478
478
  }
479
479
  const files = args.files;
480
- const store = { list: files.list, delete: (key: string) => files.ref(key).delete() };
481
480
 
482
481
  let anyFailed = false;
483
482
  for (const fileRef of toDelete) {
@@ -485,7 +484,7 @@ async function purgeHardDeleteRowWithFiles(args: {
485
484
  if (typeof key !== "string" || key.length === 0) continue;
486
485
  const failedKeys = await deleteFileFailingClosed(
487
486
  key,
488
- store,
487
+ files,
489
488
  `tenant=${args.tenantId} entity=${args.entityName} row=${rowId}`,
490
489
  );
491
490
  if (failedKeys.length > 0) {
@@ -14,6 +14,27 @@ export const MAX_EMAIL_ATTACHMENTS = 20;
14
14
 
15
15
  export const MAX_RENDERED_PDF_VARIABLES_BYTES = 64_000;
16
16
 
17
+ // Channel-data keys that steer delivery itself (envelope, headers, attachments);
18
+ // they are not document variables and must not be copied into attached PDF refs.
19
+ export const DELIVERY_ENVELOPE_DATA_KEYS = [
20
+ "attachments",
21
+ "from",
22
+ "fromName",
23
+ "replyTo",
24
+ "headers",
25
+ "unsubscribeUrl",
26
+ ] as const;
27
+
28
+ const DELIVERY_ENVELOPE_DATA_KEY_SET: ReadonlySet<string> = new Set(DELIVERY_ENVELOPE_DATA_KEYS);
29
+
30
+ export function withoutDeliveryEnvelopeKeys(
31
+ variables: Readonly<Record<string, unknown>>,
32
+ ): Readonly<Record<string, unknown>> {
33
+ return Object.fromEntries(
34
+ Object.entries(variables).filter(([key]) => !DELIVERY_ENVELOPE_DATA_KEY_SET.has(key)),
35
+ );
36
+ }
37
+
17
38
  const MAX_ID_LENGTH = 64;
18
39
 
19
40
  const fileRefIdSchema = z.string().min(1).max(MAX_ID_LENGTH);
@@ -34,14 +55,31 @@ export const fileAttachmentRefSchema = z
34
55
  })
35
56
  .strict();
36
57
 
58
+ // BigInt values and cycles make JSON.stringify throw; that must surface as a validation issue,
59
+ // not a raw TypeError that a retrying job hits again on the same data.
60
+ function isJsonSerializable(value: unknown): boolean {
61
+ try {
62
+ JSON.stringify(value);
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function jsonByteLength(value: unknown): number {
70
+ return new TextEncoder().encode(JSON.stringify(value)).byteLength;
71
+ }
72
+
37
73
  export const renderedPdfAttachmentRefSchema = z
38
74
  .object({
39
75
  kind: z.literal("rendered-pdf"),
40
76
  templateSlug: templateSlugSchema,
41
77
  variables: z
42
78
  .record(z.string(), z.unknown())
79
+ .refine(isJsonSerializable, { message: "variables must be JSON-serializable" })
43
80
  .refine(
44
- (value) => Buffer.byteLength(JSON.stringify(value)) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
81
+ (value) =>
82
+ !isJsonSerializable(value) || jsonByteLength(value) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
45
83
  {
46
84
  message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
47
85
  },
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Rendered-PDF attachment variables must be JSON-serializable",
6
+ "detail": "BigInt values or cycles in rendered-pdf variables now fail validation instead of throwing a TypeError that a retrying job hits again."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Delivery checks opt-outs for addresses resolved on the retry path and logs idempotency release failures",
12
+ "detail": "The kill switch, preference and rate-limit result per recipient channel is computed once in the prefetch and reused when delivering; the rate limit now runs before the address is resolved. When the prefetch resolve failed and the address is resolved again, the suppression list is reloaded for exactly that address, so an unsubscribed address no longer receives the mail. A failing idempotency-key release is logged and swallowed instead of masking the delivery outcome."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "A failed final delivery attempt frees the idempotency key",
18
+ "detail": "A soft send failure and any error thrown on the final render or send attempt release the idempotency key, so the caller's retry is not dropped as a duplicate."
19
+ },
20
+ {
21
+ "version": "0.364.0",
22
+ "type": "fix",
23
+ "title": "A mail without a readable inline image is sent without it",
24
+ "detail": "An inline image whose bytes could not be read (for example a system template logo while the job context has no provider resolver) failed the whole mail send. The attachment reader now logs a warning with the fileRef id and skips the image."
25
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "Delivery exports the envelope-key filter for mail variables",
30
+ "detail": "withoutDeliveryEnvelopeKeys strips reserved delivery keys from template variables."
31
+ },
2
32
  {
3
33
  "version": "0.363.0",
4
34
  "type": "breaking",