@cosmicdrift/kumiko-samples 0.363.0 → 0.364.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 (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -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,
@@ -42,7 +47,7 @@ import * as z from "zod";
42
47
  import { resolveRetentionPolicyForTenant } from "../../data-retention/index.js";
43
48
  import { eraseNoteBodiesReachingSubject } from "../../notes-history-user-data/index.js";
44
49
  import { revokeAllPatTokensForUser } from "../../personal-access-tokens/index.js";
45
- import { USER_STATUS } from "../../user/index.js";
50
+ import { USER_STATUS, userTable } from "../../user/index.js";
46
51
  import {
47
52
  denyIfTargetOutsideAdminTenant,
48
53
  isSystemAdminActor,
@@ -337,25 +342,33 @@ async function sweepDerivedSubjectData(
337
342
  }
338
343
  }
339
344
 
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(
345
+ // Email subscribers and other non-user entities use user-style subject keys
346
+ // without a read_users row — the key erase is the GDPR-relevant part, so no row
347
+ // means nothing to close. For an existing row, failures propagate: the handler
348
+ // throws and the retry re-runs the (idempotent) erase plus the door close. With
349
+ // an outside-transaction runner that can open its own transaction, the door
350
+ // closes there and stays committed when the handler throws afterwards (sweep
351
+ // error). Otherwise a savepoint confines a failure to the handler transaction.
352
+ async function closeUserLoginDoor(
346
353
  ctx: HandlerContext,
347
354
  userId: string,
348
- lifecycleRunner: DbRunner,
355
+ outsideRunner: DbRunner | undefined,
356
+ handlerTxRunner: DbRunner,
349
357
  ): 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.
358
+ const userRow = await fetchOne(handlerTxRunner, userTable, { id: userId });
359
+ // skip: no user row for this subject key (e.g. email subscribers); there is no door to close.
360
+ if (!userRow) return;
361
+
362
+ const closeDoor = async (runner: DbRunner): Promise<void> => {
363
+ await updateUserLifecycle(runner, userId, { status: USER_STATUS.Deleted });
364
+ if (ctx.registry.features.has("personal-access-tokens")) {
365
+ await revokeAllPatTokensForUser(runner, userId);
366
+ }
367
+ };
368
+ if (outsideRunner && runnerCanBeginTransaction(outsideRunner)) {
369
+ await transaction(outsideRunner, closeDoor);
370
+ } else {
371
+ await runInSavepointIfSupported(handlerTxRunner, closeDoor);
359
372
  }
360
373
  }
361
374
 
@@ -490,14 +503,6 @@ export const forgetSubjectWrite = defineWriteHandler({
490
503
  eraseReason: event.payload.reason,
491
504
  });
492
505
 
493
- const cleanupError = await sweepDerivedSubjectData(
494
- ctx,
495
- subject,
496
- subjectKey,
497
- ctx.db.unsafeRaw(),
498
- { requestId, erasedBy: event.user.id },
499
- );
500
-
501
506
  // User subject: close the login door. DEK-erase makes the passwordHash
502
507
  // ciphertext unreadable, but status + PATs are standalone credentials —
503
508
  // the PAT resolver only checks revokedAt/expiresAt, NOT user.status
@@ -513,10 +518,26 @@ export const forgetSubjectWrite = defineWriteHandler({
513
518
  // crash recovery is safe. User-feature guard: without the user feature
514
519
  // read_users doesn't exist (crypto-only stack). Tenant subjects have no
515
520
  // credentials.
521
+ // Runs before the sweep and in its own transaction: a failing sweep rolls the
522
+ // handler transaction back, and the handler tx holds no lock on this user's
523
+ // read_users row yet (only read gates ran), so there is no lock conflict.
516
524
  if (raw.kind === "user" && ctx.registry.features.has("user")) {
517
- await closeUserLoginDoorBestEffort(ctx, raw.userId, ctx.db.unsafeRaw());
525
+ await closeUserLoginDoor(
526
+ ctx,
527
+ raw.userId,
528
+ ctx.dbOutsideTransaction?.unsafeRaw(),
529
+ ctx.db.unsafeRaw(),
530
+ );
518
531
  }
519
532
 
533
+ const cleanupError = await sweepDerivedSubjectData(
534
+ ctx,
535
+ subject,
536
+ subjectKey,
537
+ ctx.db.unsafeRaw(),
538
+ { requestId, erasedBy: event.user.id },
539
+ );
540
+
520
541
  await appendSubjectForgotten(
521
542
  ctx,
522
543
  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",
@@ -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;
@@ -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);
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "A failed final delivery attempt frees the idempotency key",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "A mail without a readable inline image is sent without it",
12
+ "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."
13
+ },
14
+ {
15
+ "version": "0.364.0",
16
+ "type": "fix",
17
+ "title": "Delivery exports the envelope-key filter for mail variables",
18
+ "detail": "withoutDeliveryEnvelopeKeys strips reserved delivery keys from template variables."
19
+ },
2
20
  {
3
21
  "version": "0.363.0",
4
22
  "type": "breaking",
@@ -3,8 +3,9 @@ import { createTenantDb, type DbConnection, type TenantDb } from "@cosmicdrift/k
3
3
  import type { ConfigAccessor, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { FileContext } from "@cosmicdrift/kumiko-framework/files";
6
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
7
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
7
- import { readFileRefContent } from "../file-foundation/index.js";
8
+ import { type FileRefContent, readFileRefContent } from "../file-foundation/index.js";
8
9
  import {
9
10
  collectRendererPlugins,
10
11
  createRendererFoundationApi,
@@ -40,7 +41,10 @@ export function createTemplateMailRenderer(
40
41
  html: response.html,
41
42
  text: response.text,
42
43
  ...(response.previewText !== undefined && { previewText: response.previewText }),
44
+ ...(response.fromName !== undefined && { fromName: response.fromName }),
45
+ ...(response.replyTo !== undefined && { replyTo: response.replyTo }),
43
46
  ...(response.attachments !== undefined && { attachments: response.attachments }),
47
+ ...(response.layout !== undefined && { layout: response.layout }),
44
48
  };
45
49
  };
46
50
  }
@@ -82,6 +86,22 @@ function createPdfRenderer(
82
86
  };
83
87
  }
84
88
 
89
+ // A decorative inline image (logo) that cannot be read must not fail the whole mail.
90
+ async function readInlineImageOrNull(
91
+ fileRefId: string,
92
+ read: () => Promise<FileRefContent | null>,
93
+ ): Promise<FileRefContent | null> {
94
+ try {
95
+ return await read();
96
+ } catch (err) {
97
+ createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
98
+ fileRefId,
99
+ errorName: err instanceof Error ? err.name : typeof err,
100
+ });
101
+ return null;
102
+ }
103
+ }
104
+
85
105
  // Inline images and file attachments the renderer took from a template may come from the system
86
106
  // tenant (system template or tenant override of one). A file ref from notify data stays limited to
87
107
  // the job's own tenant, since a caller picks it. Inline content that is not an image is dropped, so
@@ -96,13 +116,16 @@ export function createEmailAttachmentReader(
96
116
  const renderPdf = createPdfRenderer(db, registry, tenantId, config, filesForTenant);
97
117
  return async (ref: EmailAttachmentRef, origin: AttachmentOrigin = "message") => {
98
118
  if (ref.kind === "rendered-pdf") return renderPdf(ref);
99
- const found = await readFileRefContent({
100
- db,
101
- filesForTenant,
102
- fileRefId: ref.fileRefId,
103
- allowedTenantIds:
104
- ref.kind === "file" && origin === "message" ? [tenantId] : [tenantId, SYSTEM_TENANT_ID],
105
- });
119
+ const readRef = () =>
120
+ readFileRefContent({
121
+ db,
122
+ filesForTenant,
123
+ fileRefId: ref.fileRefId,
124
+ allowedTenantIds:
125
+ ref.kind === "file" && origin === "message" ? [tenantId] : [tenantId, SYSTEM_TENANT_ID],
126
+ });
127
+ const found =
128
+ ref.kind === "inline" ? await readInlineImageOrNull(ref.fileRefId, readRef) : await readRef();
106
129
  if (found === null) return null;
107
130
  if (ref.kind === "inline" && !found.contentType.startsWith(INLINE_IMAGE_CONTENT_TYPE_PREFIX)) {
108
131
  return null;
@@ -1,6 +1,7 @@
1
1
  export { sanitizePdfAttachmentFileName } from "../shared/index.js";
2
2
  export { hashUnsubscribeAddress } from "./address-opt-out.js";
3
3
  export {
4
+ DELIVERY_ENVELOPE_DATA_KEYS,
4
5
  type EmailAttachmentRef,
5
6
  emailAttachmentRefSchema,
6
7
  type FileAttachmentRef,
@@ -12,6 +13,7 @@ export {
12
13
  type RenderedPdfAttachmentRef,
13
14
  renderedPdfAttachmentRefSchema,
14
15
  toAttachmentRef,
16
+ withoutDeliveryEnvelopeKeys,
15
17
  } from "./attachment-ref.js";
16
18
  export {
17
19
  type AttemptLogRetentionOptions,
@@ -59,6 +59,8 @@ const sendJobPayloadSchema = renderJobPayloadSchema.extend({
59
59
  html: z.string(),
60
60
  subject: z.string(),
61
61
  text: z.string().optional(),
62
+ fromName: z.string().optional(),
63
+ replyTo: z.string().optional(),
62
64
  attachments: z.array(emailAttachmentRefSchema).max(MAX_EMAIL_ATTACHMENTS).optional(),
63
65
  })
64
66
  .optional(),
@@ -96,6 +98,8 @@ function toSendRendered(
96
98
  html: rendered.html,
97
99
  subject: rendered.subject,
98
100
  ...(rendered.text !== undefined && { text: rendered.text }),
101
+ ...(rendered.fromName !== undefined && { fromName: rendered.fromName }),
102
+ ...(rendered.replyTo !== undefined && { replyTo: rendered.replyTo }),
99
103
  ...(rendered.attachments !== undefined && {
100
104
  attachments: rendered.attachments.map(toAttachmentRef),
101
105
  }),
@@ -138,15 +142,11 @@ function entryFor(
138
142
  };
139
143
  }
140
144
 
141
- // Only the final retry frees the key: earlier failures are retried by the runner and
142
- // must still dedup. Assumes ctx.redis is the handle the service claimed on; runProdApp
143
- // wires the same instance into both.
144
- async function releaseIdempotencyOnTerminalFailure(
145
- ctx: JobContext,
146
- p: RenderJobPayload,
147
- ): Promise<void> {
148
- // skip: nothing to release before the last attempt or without an idempotency key
149
- if (!ctx.finalAttempt || !p.idempotencyKey) return;
145
+ // Assumes ctx.redis is the handle the service claimed on; runProdApp wires the same
146
+ // instance into both.
147
+ async function releaseIdempotencyKey(ctx: JobContext, p: RenderJobPayload): Promise<void> {
148
+ // skip: no idempotency key was claimed for this notification
149
+ if (!p.idempotencyKey) return;
150
150
  if (!ctx.redis) {
151
151
  ctx.log.warn("delivery: idempotency key not released, job context has no redis", {
152
152
  notificationType: p.notificationType,
@@ -162,6 +162,33 @@ async function releaseIdempotencyOnTerminalFailure(
162
162
  }
163
163
  }
164
164
 
165
+ // Only the final retry frees the key: earlier failures are retried by the runner and
166
+ // must still dedup.
167
+ async function releaseIdempotencyOnTerminalFailure(
168
+ ctx: JobContext,
169
+ p: RenderJobPayload,
170
+ ): Promise<void> {
171
+ // skip: not the last attempt, the runner retries and the key must keep deduplicating
172
+ if (!ctx.finalAttempt) return;
173
+ await releaseIdempotencyKey(ctx, p);
174
+ }
175
+
176
+ // Covers every throw after the payload parsed, not just the attempt-logged ones: a
177
+ // missing registry or unknown channel on the last attempt would otherwise hold the key
178
+ // until its TTL.
179
+ async function releasingKeyOnTerminalFailure<T>(
180
+ ctx: JobContext,
181
+ p: RenderJobPayload,
182
+ run: () => Promise<T>,
183
+ ): Promise<T> {
184
+ try {
185
+ return await run();
186
+ } catch (err) {
187
+ await releaseIdempotencyOnTerminalFailure(ctx, p);
188
+ throw err;
189
+ }
190
+ }
191
+
165
192
  // The job runner stores err.message in the run row, and BullMQ keeps failedReason and
166
193
  // the stack trace in Redis in plain text, so the rethrown error carries only the code.
167
194
  // The redacted message goes to the log.
@@ -184,7 +211,6 @@ async function failAttempt(
184
211
  p.deliveryAttemptId,
185
212
  entryFor(p, "failed", code, p.address),
186
213
  );
187
- await releaseIdempotencyOnTerminalFailure(ctx, p);
188
214
  throw new Error(code);
189
215
  }
190
216
 
@@ -193,6 +219,10 @@ async function failAttempt(
193
219
  // the send step is never reached, so a stuck render can't half-send.
194
220
  export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
195
221
  const p = renderJobPayloadSchema.parse(payload);
222
+ return releasingKeyOnTerminalFailure(ctx, p, () => renderStep(p, ctx));
223
+ };
224
+
225
+ async function renderStep(p: RenderJobPayload, ctx: JobContext): Promise<void> {
196
226
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
197
227
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
198
228
  const channel = resolveChannel(registry, p.channelName);
@@ -203,7 +233,7 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
203
233
  tenantId,
204
234
  ctx.secrets,
205
235
  ctx.config,
206
- (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
236
+ (fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId),
207
237
  );
208
238
 
209
239
  let rendered: RenderedMessage;
@@ -225,13 +255,17 @@ export const deliveryRenderJob: JobHandlerFn = async (payload, ctx) => {
225
255
  } catch (err) {
226
256
  return failAttempt(ctx, db, registry, p, "channel_error", err);
227
257
  }
228
- };
258
+ }
229
259
 
230
260
  // Deliver via the channel using the rendered payload (if any). On failure:
231
261
  // record the terminal failed event and rethrow so BullMQ retries the send with
232
262
  // the same already-rendered HTML — no re-render needed.
233
263
  export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
234
264
  const p = sendJobPayloadSchema.parse(payload);
265
+ return releasingKeyOnTerminalFailure(ctx, p, () => sendStep(p, ctx));
266
+ };
267
+
268
+ async function sendStep(p: z.infer<typeof sendJobPayloadSchema>, ctx: JobContext): Promise<void> {
235
269
  const tenantId = p.tenantId as TenantId; // @cast-boundary engine-payload — stream tenant
236
270
  const { db, registry } = requireTenantScopedDeps(ctx, tenantId);
237
271
  const channel = resolveChannel(registry, p.channelName);
@@ -242,7 +276,7 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
242
276
  tenantId,
243
277
  ctx.secrets,
244
278
  ctx.config,
245
- (fileTenantId) => fileContextForTenant(ctx, fileTenantId),
279
+ (fileTenantId) => fileContextForTenant(ctx, ctx.systemUser.tenantId, fileTenantId),
246
280
  );
247
281
 
248
282
  try {
@@ -259,7 +293,10 @@ export const deliverySendJob: JobHandlerFn = async (payload, ctx) => {
259
293
  result.confirmed,
260
294
  ),
261
295
  );
296
+ // A soft failure ends the job successfully, so no retry follows and the caller's
297
+ // retry must not be skipped as a duplicate.
298
+ if (result.status === "failed") await releaseIdempotencyKey(ctx, p);
262
299
  } catch (err) {
263
300
  return failAttempt(ctx, db, registry, p, "send_failed", err);
264
301
  }
265
- };
302
+ }
@@ -13,7 +13,7 @@ import type {
13
13
  } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-framework/files";
15
15
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
16
- import type { MailPayload } from "../renderer-foundation/index.js";
16
+ import type { MailPayload, RenderedTemplateRef } from "../renderer-foundation/index.js";
17
17
  import type { EmailAttachmentRef } from "./attachment-ref.js";
18
18
  import { DELIVERY_CHANNEL_EXTENSION } from "./constants.js";
19
19
 
@@ -24,7 +24,11 @@ export type RenderedMail = {
24
24
  readonly html: string;
25
25
  readonly text: string;
26
26
  readonly previewText?: string;
27
+ readonly fromName?: string;
28
+ readonly replyTo?: string;
27
29
  readonly attachments?: readonly EmailAttachmentRef[];
30
+ /** The layout version the html was rendered through; the editor preview names it. */
31
+ readonly layout?: RenderedTemplateRef;
28
32
  };
29
33
 
30
34
  export type ChannelContext = {
@@ -71,6 +75,8 @@ export type RenderedMessage = {
71
75
  readonly html: string;
72
76
  readonly subject: string;
73
77
  readonly text?: string;
78
+ readonly fromName?: string;
79
+ readonly replyTo?: string;
74
80
  readonly attachments?: readonly EmailAttachmentRef[];
75
81
  };
76
82
 
@@ -147,7 +147,7 @@ export const publicVariantQuery = defineQueryHandler({
147
147
  const spec = resolveFieldVariant(ctx.registry, entityType, fieldName, query.payload.variant);
148
148
  if (!spec) return null;
149
149
  const result = await derivatives.variant(query.payload.fileRefId, spec, query.payload.variant);
150
- const bytes = await files.ref(result.storageKey).read();
150
+ const bytes = await files.refFromFileRef(result).read();
151
151
 
152
152
  // ponytail: fully buffered (no streaming), +33% over the wire from
153
153
  // base64 — HttpRouteHandlerDeps only exposes {app, systemQuery} (no
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "readFileRefContent reads handed-over files; fileContextForTenant takes the context tenant",
6
+ "detail": "readFileRefContent, readTemplateResource and the public variant query read bytes through FileContext.refFromFileRef, so a fileRef moved by a tenant handover is readable again. fileContextForTenant gains a `contextTenantId` argument: without a provider resolver it returns undefined for any other tenant instead of the context's files, which are bound to a different key space."
7
+ },
2
8
  {
3
9
  "version": "0.292.0",
4
10
  "type": "improvement",
@@ -22,16 +22,19 @@ type FileRefRow = {
22
22
  readonly size: number | bigint | string;
23
23
  };
24
24
 
25
- // Tenant-bound on purpose: the FileContext of the current request only reaches its own key space.
25
+ // Tenant-bound on purpose: without a resolver `ctx.files` only reaches the storage of the
26
+ // context's own tenant (`contextTenantId`), so a request for another tenant's files is
27
+ // undefined instead of silently reading through the wrong tenant's storage.
26
28
  export function fileContextForTenant(
27
29
  ctx: {
28
30
  readonly _fileProviderResolver?: FileProviderResolver | undefined;
29
31
  readonly files?: FileContext | undefined;
30
32
  },
33
+ contextTenantId: string,
31
34
  tenantId: string,
32
35
  ): FileContext | undefined {
33
36
  const resolver = ctx._fileProviderResolver;
34
- if (resolver === undefined) return ctx.files;
37
+ if (resolver === undefined) return tenantId === contextTenantId ? ctx.files : undefined;
35
38
  return fileContextFromResolver(resolver, tenantId);
36
39
  }
37
40
 
@@ -81,6 +84,6 @@ export async function readFileRefContent(args: {
81
84
  if (files === undefined) {
82
85
  throw new Error("readFileRefContent: no file storage is available for the file's tenant");
83
86
  }
84
- const content = await files.ref(row.storageKey).read();
87
+ const content = await files.refFromFileRef(row).read();
85
88
  return { content, contentType: row.mimeType, tenantId: row.tenantId, size };
86
89
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Tenant destroy hooks make progress even when the deadline already passed",
6
+ "detail": "The chunked migration takes a minBatches option and the file destroy hooks process one chunk per tick, so a slow list call no longer makes every run time out empty."
7
+ },
2
8
  {
3
9
  "version": "0.354.0",
4
10
  "type": "fix",
@@ -129,6 +129,7 @@ export const fileRefTenantDestroyHook: TenantDataDestroyHook = async (ctx) => {
129
129
  },
130
130
  maxFailures: 1,
131
131
  deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
132
+ minBatches: 1,
132
133
  onRowError: (_row, err) => {
133
134
  firstFailure = err instanceof Error ? err.message : String(err);
134
135
  },
@@ -222,6 +223,9 @@ export const fileRefStorageDestroyHook: StorageProviderDestroyTenantHook = async
222
223
  },
223
224
  maxFailures: 1,
224
225
  deadlineAt: ctx.deadlineAt ?? Number.POSITIVE_INFINITY,
226
+ // list() and the survivor lookup above can use up the whole budget; one chunk per tick
227
+ // still deletes, so the stage never reports "no progress".
228
+ minBatches: 1,
225
229
  onRowError: (_key, err) => {
226
230
  firstFailure = err instanceof Error ? err.message : String(err);
227
231
  },
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Watch supervisor skips and logs accounts whose address does not decrypt",
6
+ "detail": "Previously a single such account made the whole account snapshot throw."
7
+ },
2
8
  {
3
9
  "version": "0.340.0",
4
10
  "type": "improvement",