@cosmicdrift/kumiko-samples 0.364.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 (143) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  33. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  34. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  35. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  36. package/packages/bundled-features/src/delivery/changes.json +12 -0
  37. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  38. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  39. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  40. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  41. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  42. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  43. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  44. package/packages/bundled-features/src/jobs/changes.json +12 -0
  45. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  46. package/packages/bundled-features/src/metrics/changes.json +6 -0
  47. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  48. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  49. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  50. package/packages/bundled-features/src/secrets/changes.json +6 -0
  51. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  52. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  53. package/packages/bundled-features/src/shared/index.ts +8 -0
  54. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  55. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  56. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  57. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  58. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  59. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  60. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  61. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  62. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  63. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  64. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  65. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  66. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  67. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  68. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  69. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  70. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  71. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  72. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  73. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  74. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  75. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  76. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  77. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  78. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  79. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  80. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  81. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  82. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  83. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  84. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  85. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  86. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  87. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  88. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  89. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  91. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  92. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  93. package/packages/bundled-features/src/tenant/changes.json +6 -0
  94. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  95. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  96. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  97. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  98. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  99. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  100. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  101. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  102. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  103. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  104. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  105. package/packages/bundled-features/src/user/constants.ts +1 -0
  106. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  107. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  108. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  109. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  112. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  113. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  114. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  115. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  116. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  117. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  118. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  119. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  120. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  121. package/samples/apps/use-all-bundled/README.md +3 -13
  122. package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
  123. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  124. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  125. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  126. package/samples/recipes/auth-mfa/README.md +2 -1
  127. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  128. package/samples/recipes/custom-handlers/README.md +4 -3
  129. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  130. package/samples/recipes/dashboard-stats/README.md +38 -0
  131. package/samples/recipes/dashboard-stats/package.json +8 -0
  132. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  133. package/samples/recipes/legal-pages/README.md +2 -2
  134. package/samples/recipes/managed-pages/README.md +2 -2
  135. package/samples/recipes/pdf-audit-export/README.md +2 -1
  136. package/samples/recipes/pdf-audit-export/package.json +1 -1
  137. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  138. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  139. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  140. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  141. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  142. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  143. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "SMTP display names drop address-like characters, inline images get matching extensions",
6
+ "detail": "Control characters, angle brackets and at signs in a From display name are replaced by spaces so it cannot smuggle a second address; inline image file names derive their extension from the shared image format list."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "List-Unsubscribe accepts an app-owned unsubscribe URL",
12
+ "detail": "When unsubscribeUrl in the mail data points to an http(s) URL outside the framework unsubscribe route, the email channel sets List-Unsubscribe to that URL without the one-click List-Unsubscribe-Post header, because the framework cannot vouch for a foreign endpoint accepting POST. Other schemes set no header."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "fix",
17
+ "title": "A mail whose file attachment is missing fails at once instead of retrying",
18
+ "detail": "A missing or foreign-tenant file ref ends the delivery attempt as failed (send_failed) without a retry. The send job no longer throws, so the idempotency key is released and the caller can resend. A template test mail whose template file is missing now answers not_sent instead of channel_error."
19
+ },
2
20
  {
3
21
  "version": "0.363.0",
4
22
  "type": "breaking",
@@ -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 = {
@@ -232,7 +234,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
232
234
  ...(replyTo && { replyTo }),
233
235
  ...emailEnvelopeFrom(message.data),
234
236
  };
235
- const resolvedAttachments = await resolveAttachments(
237
+ const resolution = await resolveAttachments(
236
238
  [
237
239
  ...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
238
240
  ...fileAttachmentsFrom(message.data, message.locale).map(
@@ -241,6 +243,14 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
241
243
  ],
242
244
  ctx,
243
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;
244
254
  await transport.send(
245
255
  guardEmailMessage({
246
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 = [
@@ -21,6 +21,7 @@ import {
21
21
  recordRowExistsInTenant,
22
22
  recordRowOwningTenantId,
23
23
  subjectRowExistsInTenant,
24
+ WritePurposes,
24
25
  } from "@cosmicdrift/kumiko-framework/db";
25
26
  import {
26
27
  defineWriteHandler,
@@ -360,7 +361,12 @@ async function closeUserLoginDoor(
360
361
  if (!userRow) return;
361
362
 
362
363
  const closeDoor = async (runner: DbRunner): Promise<void> => {
363
- await updateUserLifecycle(runner, userId, { status: USER_STATUS.Deleted });
364
+ await updateUserLifecycle(
365
+ runner,
366
+ userId,
367
+ { status: USER_STATUS.Deleted },
368
+ { writePurpose: WritePurposes.erasure },
369
+ );
364
370
  if (ctx.registry.features.has("personal-access-tokens")) {
365
371
  await revokeAllPatTokensForUser(runner, userId);
366
372
  }
@@ -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
  }
@@ -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) {
@@ -55,14 +55,31 @@ export const fileAttachmentRefSchema = z
55
55
  })
56
56
  .strict();
57
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
+
58
73
  export const renderedPdfAttachmentRefSchema = z
59
74
  .object({
60
75
  kind: z.literal("rendered-pdf"),
61
76
  templateSlug: templateSlugSchema,
62
77
  variables: z
63
78
  .record(z.string(), z.unknown())
79
+ .refine(isJsonSerializable, { message: "variables must be JSON-serializable" })
64
80
  .refine(
65
- (value) => Buffer.byteLength(JSON.stringify(value)) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
81
+ (value) =>
82
+ !isJsonSerializable(value) || jsonByteLength(value) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
66
83
  {
67
84
  message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
68
85
  },
@@ -1,4 +1,16 @@
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
+ },
2
14
  {
3
15
  "version": "0.364.0",
4
16
  "type": "fix",
@@ -205,14 +205,23 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
205
205
 
206
206
  // A key that guarded nothing must not turn the caller's retry into a skipped duplicate.
207
207
  // Partial success keeps it: a retry would re-send to those who got it.
208
+ // A failing release is logged and swallowed: it must neither replace the dispatch error the
209
+ // caller retries on nor fail a notify() that already dispatched.
208
210
  async function releaseIdempotencyIfNothingDelivered(
209
211
  tenantId: TenantId,
210
212
  key: string | undefined,
211
213
  run: NotifyRun,
214
+ notificationType: string,
212
215
  ): Promise<void> {
213
216
  // skip: no key claimed, or a recipient already got the notification
214
217
  if (!key || run.deliveries.some(isDeliveredOrQueued)) return;
215
- await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
218
+ try {
219
+ await idemRedis?.del(deliveryIdempotencyRedisKey(tenantId, key));
220
+ } catch (releaseErr) {
221
+ logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
222
+ notificationType,
223
+ });
224
+ }
216
225
  }
217
226
 
218
227
  // Only a single-channel route lets the queued job free the key on terminal failure
@@ -508,29 +517,53 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
508
517
 
509
518
  const recipientChannelKey = (userId: string, channelName: string) => `${userId}|${channelName}`;
510
519
 
511
- async function isChannelDeliverable(
520
+ type ChannelGate = "open" | "channel_disabled" | "preference_disabled" | "rate_limited";
521
+ type PrefetchedRecipients = {
522
+ readonly addresses: ReadonlyMap<string, string | null>;
523
+ readonly gates: ReadonlyMap<string, ChannelGate>;
524
+ };
525
+
526
+ // Kill switch, then the user's preference, then the rate limit — the order deliverToUser reports
527
+ // skips in. The rate limit consumes a token, so each recipient/channel pair is evaluated once.
528
+ async function evaluateChannelGate(
512
529
  run: NotifyRun,
513
530
  userId: string,
514
531
  tenantId: TenantId,
515
532
  notificationType: string,
516
533
  channelName: string,
517
- ): Promise<boolean> {
518
- if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) return false;
519
- return isChannelEnabled(run, userId, tenantId, notificationType, channelName);
534
+ priority: NotifyPriority,
535
+ ): Promise<ChannelGate> {
536
+ if (isChannelKilled && (await isChannelKilled(tenantId, channelName))) {
537
+ return "channel_disabled";
538
+ }
539
+ // Critical priority skips the preference check
540
+ if (
541
+ priority !== "critical" &&
542
+ !(await isChannelEnabled(run, userId, tenantId, notificationType, channelName))
543
+ ) {
544
+ return "preference_disabled";
545
+ }
546
+ if (rateLimit && !(await checkRateLimit(rateLimit, tenantId, channelName))) {
547
+ return "rate_limited";
548
+ }
549
+ return "open";
520
550
  }
521
551
 
522
552
  // Resolves every user's channel addresses up front so the opt-out lookup can
523
- // cover them all at once. A resolve that throws stays out of the map: the
524
- // delivery loop resolves it again and reports channel_error as before.
525
- // Channels the kill switch or the user's preferences disable are never resolved
526
- // here: resolve may have side effects and deliverToUser skips those channels.
527
- async function prefetchUserAddresses(
553
+ // cover them all at once. A resolve that throws stays out of the address map:
554
+ // the delivery loop resolves it again and reports channel_error as before.
555
+ // Channels that are gated (kill switch, preferences, rate limit) are never resolved
556
+ // here: resolve may have side effects and deliverToUser skips those channels. The
557
+ // gate verdict is kept so deliverToUser does not query it a second time.
558
+ async function prefetchRecipients(
528
559
  run: NotifyRun,
529
560
  userIds: readonly string[],
530
561
  tenantId: TenantId,
531
562
  notificationType: string,
532
- ): Promise<Map<string, string | null>> {
563
+ priority: NotifyPriority,
564
+ ): Promise<PrefetchedRecipients> {
533
565
  const addresses = new Map<string, string | null>();
566
+ const gates = new Map<string, ChannelGate>();
534
567
  const channelCtx = buildChannelContext(
535
568
  run.db,
536
569
  registry,
@@ -543,22 +576,25 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
543
576
  for (const userId of userIds) {
544
577
  for (const channel of channels) {
545
578
  if (!channel.resolve) continue;
579
+ const key = recipientChannelKey(userId, channel.name);
546
580
  try {
547
- if (
548
- !(await isChannelDeliverable(run, userId, tenantId, notificationType, channel.name))
549
- ) {
550
- continue;
551
- }
552
- addresses.set(
553
- recipientChannelKey(userId, channel.name),
554
- await channel.resolve(userId, channelCtx),
581
+ const gate = await evaluateChannelGate(
582
+ run,
583
+ userId,
584
+ tenantId,
585
+ notificationType,
586
+ channel.name,
587
+ priority,
555
588
  );
589
+ gates.set(key, gate);
590
+ if (gate !== "open") continue;
591
+ addresses.set(key, await channel.resolve(userId, channelCtx));
556
592
  } catch {
557
593
  // skip: re-resolved (and logged) by deliverToUser
558
594
  }
559
595
  }
560
596
  }
561
- return addresses;
597
+ return { addresses, gates };
562
598
  }
563
599
 
564
600
  // Check if user has disabled this notification+channel combo.
@@ -605,7 +641,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
605
641
  tenantId: TenantId,
606
642
  priority: NotifyPriority,
607
643
  run: NotifyRun,
608
- prefetchedAddresses: ReadonlyMap<string, string | null>,
644
+ prefetched: PrefetchedRecipients,
609
645
  suppressed: ReadonlySet<AddressOptOutKey>,
610
646
  ): Promise<void> {
611
647
  const channelCtx = buildChannelContext(
@@ -624,70 +660,35 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
624
660
 
625
661
  const message = buildMessage(notificationType, data, channel.name, run.locale);
626
662
 
627
- // Kill switch: tenant admin disabled this channel entirely
628
- if (isChannelKilled) {
629
- const killed = await isChannelKilled(tenantId, channel.name);
630
- if (killed) {
631
- await logDelivery(run, {
632
- tenantId,
633
- notificationType,
634
- channel: channel.name,
635
- recipientId: userId,
636
- recipientAddress: null,
637
- status: "skipped",
638
- error: "channel_disabled",
639
- priority,
640
- });
641
- continue;
642
- }
643
- }
644
-
645
- // Check preferences (critical priority skips preference check)
646
- if (priority !== "critical") {
647
- const enabled = await isChannelEnabled(
663
+ const prefetchKey = recipientChannelKey(userId, channel.name);
664
+ const gate =
665
+ prefetched.gates.get(prefetchKey) ??
666
+ (await evaluateChannelGate(
648
667
  run,
649
668
  userId,
650
669
  tenantId,
651
670
  notificationType,
652
671
  channel.name,
653
- );
654
- if (!enabled) {
655
- await logDelivery(run, {
656
- tenantId,
657
- notificationType,
658
- channel: channel.name,
659
- recipientId: userId,
660
- recipientAddress: null,
661
- status: "skipped",
662
- error: "preference_disabled",
663
- priority,
664
- });
665
- continue;
666
- }
667
- }
668
-
669
- // Rate limiting
670
- if (rateLimit) {
671
- const allowed = await checkRateLimit(rateLimit, tenantId, channel.name);
672
- if (!allowed) {
673
- await logDelivery(run, {
674
- tenantId,
675
- notificationType,
676
- channel: channel.name,
677
- recipientId: userId,
678
- recipientAddress: null,
679
- status: "skipped",
680
- error: "rate_limited",
681
- priority,
682
- });
683
- continue;
684
- }
672
+ priority,
673
+ ));
674
+ if (gate !== "open") {
675
+ await logDelivery(run, {
676
+ tenantId,
677
+ notificationType,
678
+ channel: channel.name,
679
+ recipientId: userId,
680
+ recipientAddress: null,
681
+ status: "skipped",
682
+ error: gate,
683
+ priority,
684
+ });
685
+ continue;
685
686
  }
686
687
 
687
688
  try {
688
- const prefetchKey = recipientChannelKey(userId, channel.name);
689
- const address = prefetchedAddresses.has(prefetchKey)
690
- ? prefetchedAddresses.get(prefetchKey)
689
+ const wasPrefetched = prefetched.addresses.has(prefetchKey);
690
+ const address = wasPrefetched
691
+ ? prefetched.addresses.get(prefetchKey)
691
692
  : await channel.resolve(userId, channelCtx);
692
693
  if (!address) {
693
694
  await logDelivery(run, {
@@ -703,7 +704,14 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
703
704
  continue;
704
705
  }
705
706
 
706
- if (priority !== "critical" && isAddressSuppressed(suppressed, address, channel.name)) {
707
+ // A fallback resolve yields an address the batched opt-out lookup never covered.
708
+ const suppressedForAddress = wasPrefetched
709
+ ? suppressed
710
+ : await loadSuppressedAddresses(run, tenantId, notificationType, priority, [address]);
711
+ if (
712
+ priority !== "critical" &&
713
+ isAddressSuppressed(suppressedForAddress, address, channel.name)
714
+ ) {
707
715
  await logDelivery(run, {
708
716
  tenantId,
709
717
  notificationType,
@@ -878,16 +886,16 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
878
886
  userIds = to;
879
887
  }
880
888
 
881
- const prefetchedAddresses =
889
+ const prefetched: PrefetchedRecipients =
882
890
  priority === "critical"
883
- ? new Map<string, string | null>()
884
- : await prefetchUserAddresses(run, userIds, tenantId, notificationType);
891
+ ? { addresses: new Map(), gates: new Map() }
892
+ : await prefetchRecipients(run, userIds, tenantId, notificationType, priority);
885
893
  const suppressed = await loadSuppressedAddresses(
886
894
  run,
887
895
  tenantId,
888
896
  notificationType,
889
897
  priority,
890
- [...prefetchedAddresses.values()].filter((address): address is string => !!address),
898
+ [...prefetched.addresses.values()].filter((address): address is string => !!address),
891
899
  );
892
900
 
893
901
  for (const userId of userIds) {
@@ -898,7 +906,7 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
898
906
  tenantId,
899
907
  priority,
900
908
  run,
901
- prefetchedAddresses,
909
+ prefetched,
902
910
  suppressed,
903
911
  );
904
912
  }
@@ -955,17 +963,10 @@ export function createDeliveryService(options: DeliveryServiceOptions): Delivery
955
963
  try {
956
964
  await dispatchNotification(notificationType, options, tenantId, priority, run);
957
965
  } catch (err) {
958
- // A failing release must not replace the dispatch error the caller retries on.
959
- await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run).catch(
960
- (releaseErr: unknown) => {
961
- logError.warn(`idempotency key release failed: ${redactedMessageOf(releaseErr)}`, {
962
- notificationType,
963
- });
964
- },
965
- );
966
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
966
967
  throw err;
967
968
  }
968
- await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run);
969
+ await releaseIdempotencyIfNothingDelivered(tenantId, idempotencyKey, run, notificationType);
969
970
  return dispatched(run);
970
971
  },
971
972
  };
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Ingest events: correlate by event type and payload.fileRefId",
6
+ "detail": "New documentIngest events live on their own stream while older ones stay on the fileRef stream (aggregateId = fileRefId). Consumers must not correlate by aggregateId or the fileRef stream version."
7
+ },
2
8
  {
3
9
  "version": "0.362.0",
4
10
  "type": "fix",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Overlapping toggle refreshes no longer drop a flip, and the runtime can reuse the server cache-sync bus",
6
+ "detail": "initialize() and refresh() now run one snapshot load at a time, so a flip applied while a second load is in flight is kept instead of being overwritten by older rows. createFeatureToggleRuntime takes an optional fourth cacheSyncBus argument; with it no second bus (and no extra Redis connections) is created and close() leaves the shared bus open.",
7
+ "migration": "Pass the server's cache-sync bus as the fourth argument of createFeatureToggleRuntime to avoid the additional Redis publisher and subscriber connections of the default bus; without it the runtime still builds and closes its own bus."
8
+ },
2
9
  {
3
10
  "version": "0.336.0",
4
11
  "type": "fix",