@cosmicdrift/kumiko-bundled-features 0.338.0 → 0.339.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 (153) hide show
  1. package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +2 -2
  2. package/dist/auth-email-password/handlers/invite-accept.write.js +2 -4
  3. package/dist/auth-email-password/handlers/invite-info.query.js +3 -1
  4. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +2 -4
  5. package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -1
  6. package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
  7. package/dist/auth-email-password/web/auth-gate.d.ts +12 -2
  8. package/dist/auth-email-password/web/auth-gate.js +25 -1
  9. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -0
  10. package/dist/auth-email-password/web/auth-redirect.js +58 -0
  11. package/dist/auth-email-password/web/client-plugin.d.ts +8 -1
  12. package/dist/auth-email-password/web/client-plugin.js +2 -0
  13. package/dist/auth-email-password/web/index.d.ts +2 -1
  14. package/dist/auth-email-password/web/index.js +1 -0
  15. package/dist/auth-email-password/web/session.d.ts +3 -1
  16. package/dist/auth-email-password/web/session.js +8 -2
  17. package/dist/auth-mfa/feature.js +1 -1
  18. package/dist/auth-mfa/handlers/enable-confirm-preauth.write.js +1 -1
  19. package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -1
  20. package/dist/auth-mfa/handlers/verify.write.js +1 -1
  21. package/dist/auth-mfa/mfa-code-verifier.js +1 -1
  22. package/dist/auth-mfa/mfa-status-checker.js +1 -1
  23. package/dist/billing-foundation/constants.d.ts +7 -0
  24. package/dist/billing-foundation/constants.js +8 -0
  25. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +3 -3
  26. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +3 -3
  27. package/dist/billing-foundation/consumer-protection/consent-gate.js +10 -2
  28. package/dist/billing-foundation/consumer-protection/consent-text.js +2 -2
  29. package/dist/billing-foundation/consumer-protection/register-termination.d.ts +1 -1
  30. package/dist/billing-foundation/consumer-protection/register-termination.js +31 -3
  31. package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +3 -1
  32. package/dist/billing-foundation/consumer-protection/termination-mail.js +1 -1
  33. package/dist/billing-foundation/consumer-protection/termination-notify.d.ts +18 -0
  34. package/dist/billing-foundation/consumer-protection/termination-notify.js +21 -0
  35. package/dist/billing-foundation/consumer-protection/termination-pages.js +31 -2
  36. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +24 -2
  37. package/dist/billing-foundation/consumer-protection/termination-record.js +102 -34
  38. package/dist/billing-foundation/consumer-protection/termination-request.js +10 -42
  39. package/dist/billing-foundation/consumer-protection/termination-texts.js +20 -20
  40. package/dist/billing-foundation/events.d.ts +19 -0
  41. package/dist/billing-foundation/events.js +12 -0
  42. package/dist/billing-foundation/handlers/create-checkout-session.write.js +2 -2
  43. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +4 -2
  44. package/dist/billing-foundation/i18n.js +3 -0
  45. package/dist/billing-foundation/tenant-destroy-hook.js +4 -4
  46. package/dist/billing-foundation/types.d.ts +6 -1
  47. package/dist/billing-foundation/validate-options.js +4 -0
  48. package/dist/channel-discord/discord-channel.d.ts +6 -0
  49. package/dist/channel-discord/discord-channel.js +19 -0
  50. package/dist/channel-discord/feature.d.ts +3 -0
  51. package/dist/channel-discord/feature.js +30 -0
  52. package/dist/channel-discord/index.d.ts +2 -0
  53. package/dist/channel-discord/index.js +2 -0
  54. package/dist/channel-slack/feature.d.ts +3 -0
  55. package/dist/channel-slack/feature.js +30 -0
  56. package/dist/channel-slack/index.d.ts +2 -0
  57. package/dist/channel-slack/index.js +2 -0
  58. package/dist/channel-slack/slack-channel.d.ts +5 -0
  59. package/dist/channel-slack/slack-channel.js +16 -0
  60. package/dist/channel-teams/feature.d.ts +3 -0
  61. package/dist/channel-teams/feature.js +34 -0
  62. package/dist/channel-teams/index.d.ts +2 -0
  63. package/dist/channel-teams/index.js +2 -0
  64. package/dist/channel-teams/teams-channel.d.ts +5 -0
  65. package/dist/channel-teams/teams-channel.js +38 -0
  66. package/dist/channel-telegram/feature.d.ts +3 -0
  67. package/dist/channel-telegram/feature.js +28 -0
  68. package/dist/channel-telegram/index.d.ts +2 -0
  69. package/dist/channel-telegram/index.js +2 -0
  70. package/dist/channel-telegram/telegram-channel.d.ts +8 -0
  71. package/dist/channel-telegram/telegram-channel.js +54 -0
  72. package/dist/crypto-shredding/handlers/forget-subject.write.js +6 -6
  73. package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -1
  74. package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -1
  75. package/dist/custom-fields/handlers/set-custom-field.write.js +1 -1
  76. package/dist/custom-fields/wire-user-data-rights.js +1 -1
  77. package/dist/data-retention/feature.js +1 -1
  78. package/dist/delivery/channel-context.d.ts +2 -1
  79. package/dist/delivery/channel-context.js +2 -2
  80. package/dist/delivery/chat-webhook-channel.d.ts +19 -0
  81. package/dist/delivery/chat-webhook-channel.js +48 -0
  82. package/dist/delivery/chat-webhook-sender.d.ts +21 -0
  83. package/dist/delivery/chat-webhook-sender.js +102 -0
  84. package/dist/delivery/delivery-service.js +3 -0
  85. package/dist/delivery/index.d.ts +2 -0
  86. package/dist/delivery/index.js +2 -0
  87. package/dist/delivery/jobs.js +1 -1
  88. package/dist/delivery/testing.d.ts +2 -0
  89. package/dist/delivery/types.d.ts +3 -1
  90. package/dist/delivery/types.js +1 -2
  91. package/dist/file-derivatives/handlers/public-variant-by-file-ref.query.js +1 -1
  92. package/dist/files-tenant-data/index.js +1 -1
  93. package/dist/form-draft/feature.js +1 -1
  94. package/dist/form-draft/handlers/discard.write.js +1 -1
  95. package/dist/form-draft/handlers/save.write.js +1 -1
  96. package/dist/inbound-mail-foundation/connect-routes.d.ts +7 -0
  97. package/dist/inbound-mail-foundation/connect-routes.js +13 -8
  98. package/dist/inbound-mail-foundation/feature.js +1 -1
  99. package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -1
  100. package/dist/inbound-mail-foundation/index.d.ts +1 -0
  101. package/dist/inbound-mail-foundation/index.js +1 -0
  102. package/dist/inbound-mail-foundation/oauth-access-token.d.ts +28 -0
  103. package/dist/inbound-mail-foundation/oauth-access-token.js +131 -0
  104. package/dist/inbound-mail-foundation/tenant-destroy-hook.js +1 -1
  105. package/dist/inbound-mail-foundation/types.d.ts +18 -1
  106. package/dist/inbound-mail-foundation/watch-supervisor.d.ts +5 -0
  107. package/dist/inbound-mail-foundation/watch-supervisor.js +42 -14
  108. package/dist/managed-pages/handlers/set.write.js +1 -1
  109. package/dist/notes-history/handlers/add-note.write.js +1 -1
  110. package/dist/renderer-simple/feature.d.ts +8 -2
  111. package/dist/renderer-simple/feature.js +8 -5
  112. package/dist/renderer-simple/index.d.ts +2 -2
  113. package/dist/renderer-simple/index.js +1 -1
  114. package/dist/renderer-simple/simple-renderer.d.ts +15 -0
  115. package/dist/renderer-simple/simple-renderer.js +74 -27
  116. package/dist/secrets/feature.js +1 -1
  117. package/dist/sessions/feature.js +1 -1
  118. package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -1
  119. package/dist/tenant-handover/handlers/claim.write.js +1 -2
  120. package/dist/tenant-lifecycle/feature.js +1 -1
  121. package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +3 -1
  122. package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -1
  123. package/dist/tier-engine/feature.js +1 -1
  124. package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -1
  125. package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -1
  126. package/dist/user-data-rights/feature.js +2 -2
  127. package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -1
  128. package/dist/user-data-rights/handlers/confirm-deletion-by-token.write.js +1 -1
  129. package/dist/user-data-rights/handlers/download-by-job.query.js +1 -1
  130. package/dist/user-data-rights/handlers/download-by-token.query.js +1 -1
  131. package/dist/user-data-rights/handlers/export-status.query.js +1 -1
  132. package/dist/user-data-rights/handlers/lift-restriction.write.js +2 -4
  133. package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -1
  134. package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -1
  135. package/dist/user-data-rights/handlers/request-deletion.write.js +1 -1
  136. package/dist/user-data-rights/handlers/request-export.write.js +1 -1
  137. package/dist/user-data-rights/handlers/restrict-account.write.js +2 -2
  138. package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -1
  139. package/dist/user-data-rights/lib/update-user-lifecycle.js +1 -1
  140. package/dist/workflow-runner/event-subscriber.js +6 -2
  141. package/dist/workflow-runner/event-trigger.js +5 -0
  142. package/dist/workflow-runner/handlers/resume-run.write.js +147 -59
  143. package/dist/workflow-runner/pending-projection.js +1 -0
  144. package/dist/workflow-runner/runner.d.ts +7 -1
  145. package/dist/workflow-runner/runner.js +1 -1
  146. package/dist/workflow-runner/tables.js +8 -1
  147. package/package.json +41 -9
  148. package/src/auth-email-password/changes.json +7 -0
  149. package/src/billing-foundation/changes.json +34 -0
  150. package/src/delivery/changes.json +7 -0
  151. package/src/inbound-mail-foundation/changes.json +7 -0
  152. package/src/renderer-simple/changes.json +9 -1
  153. package/src/workflow-runner/changes.json +7 -0
@@ -58,7 +58,7 @@ export const requestExportWrite = defineWriteHandler({
58
58
  const userId = event.user.id;
59
59
  const T = getTemporal();
60
60
  const now = T.Now.instant();
61
- const exportJobRunner = ctx.db.unsafeRaw(REQUEST_EXPORT_REASON);
61
+ const exportJobRunner = ctx.db.unsafeRaw();
62
62
  // Pre-Check: exportJobRunner bypasses the TenantDb-Filter — the
63
63
  // TenantDb-Wrapper would hide cross-tenant jobs.
64
64
  const existing = await findActiveJob(exportJobRunner, userId);
@@ -59,7 +59,7 @@ export const restrictAccountWrite = defineWriteHandler({
59
59
  details: { reason: "admin_required_for_other_user" },
60
60
  }));
61
61
  }
62
- const outside = await denyIfTargetOutsideAdminTenant(ctx.db.unsafeRaw("checks the target user's membership in the admin's tenant via the DbRunner helper"), event.user, targetUserId);
62
+ const outside = await denyIfTargetOutsideAdminTenant(ctx.db.unsafeRaw(), event.user, targetUserId);
63
63
  if (outside)
64
64
  return outside;
65
65
  }
@@ -82,7 +82,7 @@ export const restrictAccountWrite = defineWriteHandler({
82
82
  details: { currentStatus },
83
83
  }));
84
84
  }
85
- await updateUserLifecycle(ctx.db.unsafeRaw("appends the user lifecycle event on the SYSTEM_TENANT_ID user stream"), targetUserId, { status: USER_STATUS.Restricted });
85
+ await updateUserLifecycle(ctx.db.unsafeRaw(), targetUserId, { status: USER_STATUS.Restricted });
86
86
  // Cross-Feature: alle live sessions revoken — sonst koennte der User
87
87
  // mit existierendem JWT bis zur Token-Expiry weiter schreiben.
88
88
  // ctx.writeAs(systemUser, ...) damit der privileged-Handler die
@@ -36,7 +36,7 @@ export function createRunForgetCleanupHandler(opts = {}) {
36
36
  }));
37
37
  }
38
38
  const T = getTemporal();
39
- const runner = ctx.db.unsafeRaw(RUN_FORGET_CLEANUP_REASON);
39
+ const runner = ctx.db.unsafeRaw();
40
40
  // Operator-triggered forget must also erase binaries, not just rows —
41
41
  // it flips users to Deleted, after which the cron never re-processes
42
42
  // them, so a row-only delete here would permanently leak the binaries.
@@ -18,7 +18,7 @@ import { USER_STATUS, userEntity, userTable } from "../../user/index.js";
18
18
  // NICHT `event.user.tenantId` (das ist der aktive Tenant zur Lifecycle-Zeit und
19
19
  // kann abweichen).
20
20
  const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
21
- // `conn` is ctx.db.unsafeRaw(reason) (regular handlers) or the open tx
21
+ // `conn` is ctx.db.unsafeRaw() (regular handlers) or the open tx
22
22
  // (forget-cleanup sub-tx) — keeps the event append atomic with the write.
23
23
  //
24
24
  // Returns `{ applied: false }` instead of throwing only when `options.expect`
@@ -20,7 +20,7 @@
20
20
  // This subscriber never resumes a run itself (D3, workflow-resume-loop.md):
21
21
  // the MultiStreamApplyContext it runs in has no callFeature/runStepList
22
22
  // access, only unsafeAppendEvent/loadAggregate. It writes triggerEventType
23
- // + triggerPayload + wakeAt=now() onto the matching pending row(s); the
23
+ // + triggerEventRef + wakeAt=now() onto the matching pending row(s); the
24
24
  // existing resume-due-runs job (Phase 2) picks the row up on its next
25
25
  // cron tick — the one resume path stays exactly the one Phase 2 built.
26
26
  import { selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -66,7 +66,11 @@ const wakeupApply = async (event, tx) => {
66
66
  continue;
67
67
  await updateMany(tx, workflowRunPendingTable, {
68
68
  triggerEventType: event.type,
69
- triggerPayload: event.payload,
69
+ triggerEventRef: {
70
+ eventId: event.id,
71
+ aggregateId: event.aggregateId,
72
+ version: event.version,
73
+ },
70
74
  wakeAt: getTemporal().Now.instant().toString(),
71
75
  }, { tenantId: row.tenantId, runId: row.runId, stepIndex: row.stepIndex });
72
76
  }
@@ -69,6 +69,11 @@ export function registerEventTrigger(r, workflow) {
69
69
  runId,
70
70
  workflow,
71
71
  triggerEvent,
72
+ triggerEventRef: {
73
+ eventId: event.id,
74
+ aggregateId: event.aggregateId,
75
+ version: event.version,
76
+ },
72
77
  ...(idempotencyKey && { idempotencyKey }),
73
78
  // @cast-boundary msp-to-handler-ctx — MultiStreamApplyContext only
74
79
  // exposes unsafeAppendEvent/loadAggregate, a subset of
@@ -16,16 +16,19 @@
16
16
  // otherwise (wait already wrote its effect; resume past it).
17
17
  //
18
18
  // The run's ORIGINAL trigger event (the one that started the whole run) is
19
- // always recovered from the run's own WORKFLOW_RUN_STARTED_TYPE event,
20
- // never from the pending row — that event always carries it (see
21
- // WorkflowRunStartedPayload in ./runner). The pending row's own
22
- // triggerEventType/triggerPayload columns are a DIFFERENT thing: for a
23
- // waitForEvent suspension they hold the AWAITED event the Phase 3b
19
+ // always re-read from the event store via the triggerEventRef on the run's
20
+ // own WORKFLOW_RUN_STARTED_TYPE event, never from the pending row (see
21
+ // WorkflowRunStartedPayload in ./runner). Neither the run stream nor the
22
+ // pending row holds a copy of a foreign payload, so an erased source event
23
+ // shows up here in its shredded form, and an unloadable one (archived
24
+ // stream) fails the run. The pending row's own
25
+ // triggerEventType/triggerEventRef columns are a DIFFERENT thing: for a
26
+ // waitForEvent suspension they point at the AWAITED event the Phase 3b
24
27
  // event-subscriber matched (NULL until then, and NULL forever on a
25
- // timeout-without-a-match). That payload becomes the resumed pipeline's
26
- // result for the skipped waitForEvent step itself (see resultKey on
27
- // steps/wait-for-event.ts) — pre-seeded into stepsAcc below so a
28
- // subsequent step's resolver can read `ctx.steps[awaits.someKey]`.
28
+ // timeout-without-a-match). That event's payload becomes the resumed
29
+ // pipeline's result for the skipped waitForEvent step itself (see
30
+ // resultKey on steps/wait-for-event.ts) — pre-seeded into stepsAcc below so
31
+ // a subsequent step's resolver can read `ctx.steps[awaits.someKey]`.
29
32
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
30
33
  import { buildPipelineSteps, computeDefinitionFingerprint, describeWorkflowStepError, getStep, runStepList, SYSTEM_ROLE, WORKFLOW_AGGREGATE_TYPE, WORKFLOW_RESUMED_TYPE, WORKFLOW_RETRY_SCHEDULED_TYPE, WORKFLOW_RUN_COMPLETED_TYPE, WORKFLOW_RUN_FAILED_TYPE, WORKFLOW_RUN_STARTED_TYPE, WORKFLOW_WAITING_FOR_EVENT_TYPE, } from "@cosmicdrift/kumiko-framework/engine";
31
34
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
@@ -113,40 +116,134 @@ function isRunAlreadySettled(runEvents, stepIndex, expectedRetryAttempt) {
113
116
  return resumed.stepIndex === stepIndex && resumed.retryAttempt === expectedRetryAttempt;
114
117
  });
115
118
  }
116
- function recoverTriggerEvent(runEvents, runId, user) {
119
+ function isRecord(value) {
120
+ return typeof value === "object" && value !== null;
121
+ }
122
+ function isTriggerEventRef(value) {
123
+ return (isRecord(value) &&
124
+ typeof value["eventId"] === "string" &&
125
+ typeof value["aggregateId"] === "string" &&
126
+ typeof value["version"] === "number");
127
+ }
128
+ function readTriggerSource(startedPayload) {
129
+ if (!isRecord(startedPayload) || typeof startedPayload["triggerEventType"] !== "string") {
130
+ return null;
131
+ }
132
+ const eventType = startedPayload["triggerEventType"];
133
+ const ref = startedPayload["triggerEventRef"];
134
+ if (isTriggerEventRef(ref)) {
135
+ return { kind: "ref", eventType, ref };
136
+ }
137
+ if ("triggerPayload" in startedPayload) {
138
+ return { kind: "legacy-copy", eventType, payload: startedPayload["triggerPayload"] };
139
+ }
140
+ return null;
141
+ }
142
+ async function loadReferencedEvent(ctx, ref) {
143
+ const events = await ctx.loadAggregate(ref.aggregateId);
144
+ return events.find((e) => e.id === ref.eventId);
145
+ }
146
+ async function recoverTriggerEvent(ctx, runEvents, runId, user) {
117
147
  const started = runEvents.find((e) => e.type === WORKFLOW_RUN_STARTED_TYPE);
118
148
  if (!started) {
119
149
  throw new InternalError({
120
150
  message: `workflow-runner:write:resume-run: run ${runId} has no ${WORKFLOW_RUN_STARTED_TYPE} event — cannot recover its trigger event.`,
121
151
  });
122
152
  }
123
- const startedPayload = started.payload; // @cast-boundary event-store-payload
124
- const triggerEvent = {
125
- type: startedPayload.triggerEventType,
126
- payload: startedPayload.triggerPayload,
127
- user,
153
+ const source = readTriggerSource(started.payload);
154
+ if (!source) {
155
+ throw new InternalError({
156
+ message: `workflow-runner:write:resume-run: run ${runId} has a malformed ${WORKFLOW_RUN_STARTED_TYPE} event — neither triggerEventRef nor a legacy triggerPayload.`,
157
+ });
158
+ }
159
+ if (source.kind === "legacy-copy") {
160
+ return {
161
+ event: { type: source.eventType, payload: source.payload, user },
162
+ unavailable: false,
163
+ };
164
+ }
165
+ const triggerEvent = await loadReferencedEvent(ctx, source.ref);
166
+ if (!triggerEvent) {
167
+ return { event: null, unavailable: true };
168
+ }
169
+ return {
170
+ event: { type: source.eventType, payload: triggerEvent.payload, user },
171
+ unavailable: false,
128
172
  };
129
- return triggerEvent;
173
+ }
174
+ // NULL ref + NULL payload = timeout without a match: the step result stays
175
+ // undefined, same as before. A matched row whose event can no longer be loaded
176
+ // is NOT folded into that case: a later step could not tell "nothing arrived"
177
+ // from "arrived but gone" and would run on missing data, so the run fails
178
+ // instead. Rows matched before the reference existed still carry the copy.
179
+ async function loadAwaitedPayload(ctx, pending) {
180
+ if (!isTriggerEventRef(pending.triggerEventRef)) {
181
+ return { unavailable: false, payload: pending.triggerPayload ?? undefined };
182
+ }
183
+ const awaited = await loadReferencedEvent(ctx, pending.triggerEventRef);
184
+ return awaited ? { unavailable: false, payload: awaited.payload } : { unavailable: true };
130
185
  }
131
186
  // The suspended waitForEvent step itself is skipped on resume
132
187
  // (resumeFrom = stepIndex + 1) — its run() never re-executes, so its
133
188
  // resultKey (args.event, see steps/wait-for-event.ts) never gets
134
- // populated by the normal runStepList loop. Seed it here from the
135
- // pending row's own triggerPayload so a subsequent step's resolver
136
- // sees the matched event via `ctx.steps[awaits.someKey]`, same as any
137
- // other step result. NULL on a timeout-without-a-match — a later
138
- // resolver reading it just sees `undefined`, no special-casing needed.
139
- function seedResumedStepResults(pending, steps, stepIndex) {
189
+ // populated by the normal runStepList loop. Seed it here from the awaited
190
+ // event's payload so a subsequent step's resolver sees the matched event via
191
+ // `ctx.steps[awaits.someKey]`, same as any other step result.
192
+ function seedResumedStepResults(suspensionEventType, awaitedPayload, steps, stepIndex) {
140
193
  const stepsAcc = {};
141
- if (pending.suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE) {
194
+ if (suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE) {
142
195
  const suspendedStep = steps[stepIndex];
143
196
  const key = suspendedStep && getStep(suspendedStep.kind)?.resultKey?.(suspendedStep.args);
144
197
  if (key !== undefined) {
145
- stepsAcc[key] = pending.triggerPayload;
198
+ stepsAcc[key] = awaitedPayload;
146
199
  }
147
200
  }
148
201
  return stepsAcc;
149
202
  }
203
+ async function runResumedPipeline(ctx, input) {
204
+ const { workflow, workflowName, runId, stepIndex, resumeFrom, triggerEvent, awaitedPayload, pending, } = input;
205
+ try {
206
+ const steps = buildPipelineSteps(workflow.pipelineDef, triggerEvent);
207
+ const workflowCtx = {
208
+ runId,
209
+ workflowName,
210
+ stepIndex,
211
+ definitionFingerprint: pending.definitionFingerprint ?? undefined,
212
+ ...(pending.retryAttempt !== null && { retryAttempt: pending.retryAttempt + 1 }),
213
+ };
214
+ const stepsAcc = seedResumedStepResults(pending.suspensionEventType, awaitedPayload, steps, stepIndex);
215
+ const outcome = await runStepList(steps, triggerEvent, ctx, stepsAcc, {}, workflowCtx, resumeFrom);
216
+ if (outcome.kind === "suspended") {
217
+ if (!isResumableSuspension(steps, outcome.stepIndex)) {
218
+ throw new WorkflowSuspensionUnsupportedError(workflowName, outcome.stepIndex);
219
+ }
220
+ // Another suspension further down the pipeline — pending-projection.ts
221
+ // already materialised the new row; nothing more to do this pass.
222
+ return { isSuccess: true, data: { outcome: "suspended" } };
223
+ }
224
+ const completedPayload = {
225
+ workflowName,
226
+ stepIndex: steps.length,
227
+ };
228
+ await ctx.unsafeAppendEvent({
229
+ aggregateId: runId,
230
+ aggregateType: WORKFLOW_AGGREGATE_TYPE,
231
+ type: WORKFLOW_RUN_COMPLETED_TYPE,
232
+ payload: completedPayload,
233
+ });
234
+ return { isSuccess: true, data: { outcome: "completed" } };
235
+ }
236
+ catch (error) {
237
+ log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
238
+ const failedPayload = {
239
+ workflowName,
240
+ stepIndex,
241
+ error: describeWorkflowStepError(error),
242
+ };
243
+ await appendRunFailed(ctx, runId, failedPayload);
244
+ return { isSuccess: true, data: { outcome: "failed" } };
245
+ }
246
+ }
150
247
  export const resumeRunHandler = {
151
248
  name: "resume-run",
152
249
  agent: { expose: false },
@@ -198,48 +295,39 @@ export const resumeRunHandler = {
198
295
  // pipeline; nothing left for this call to do.
199
296
  return { isSuccess: true, data: { outcome: "already-resumed" } };
200
297
  }
201
- const triggerEvent = recoverTriggerEvent(runEvents, runId, event.user);
202
- const resumeFrom = pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
203
- try {
204
- const steps = buildPipelineSteps(workflow.pipelineDef, triggerEvent);
205
- const workflowCtx = {
206
- runId,
298
+ const recovered = await recoverTriggerEvent(ctx, runEvents, runId, event.user);
299
+ if (recovered.unavailable) {
300
+ await appendRunFailed(ctx, runId, {
207
301
  workflowName,
208
302
  stepIndex,
209
- definitionFingerprint: pending.definitionFingerprint ?? undefined,
210
- ...(pending.retryAttempt !== null && { retryAttempt: pending.retryAttempt + 1 }),
211
- };
212
- const stepsAcc = seedResumedStepResults(pending, steps, stepIndex);
213
- const outcome = await runStepList(steps, triggerEvent, ctx, stepsAcc, {}, workflowCtx, resumeFrom);
214
- if (outcome.kind === "suspended") {
215
- if (!isResumableSuspension(steps, outcome.stepIndex)) {
216
- throw new WorkflowSuspensionUnsupportedError(workflowName, outcome.stepIndex);
217
- }
218
- // Another suspension further down the pipeline — pending-projection.ts
219
- // already materialised the new row; nothing more to do this pass.
220
- return { isSuccess: true, data: { outcome: "suspended" } };
221
- }
222
- const completedPayload = {
223
- workflowName,
224
- stepIndex: steps.length,
225
- };
226
- await ctx.unsafeAppendEvent({
227
- aggregateId: runId,
228
- aggregateType: WORKFLOW_AGGREGATE_TYPE,
229
- type: WORKFLOW_RUN_COMPLETED_TYPE,
230
- payload: completedPayload,
303
+ error: `Trigger event of run ${runId} is no longer available — cannot resume.`,
304
+ reason: "trigger_event_unavailable",
231
305
  });
232
- return { isSuccess: true, data: { outcome: "completed" } };
306
+ return { isSuccess: true, data: { outcome: "failed" } };
233
307
  }
234
- catch (error) {
235
- log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
236
- const failedPayload = {
308
+ const triggerEvent = recovered.event;
309
+ const awaited = pending.suspensionEventType === WORKFLOW_WAITING_FOR_EVENT_TYPE
310
+ ? await loadAwaitedPayload(ctx, pending)
311
+ : { unavailable: false, payload: undefined };
312
+ if (awaited.unavailable) {
313
+ await appendRunFailed(ctx, runId, {
237
314
  workflowName,
238
315
  stepIndex,
239
- error: describeWorkflowStepError(error),
240
- };
241
- await appendRunFailed(ctx, runId, failedPayload);
316
+ error: `Awaited event of run ${runId} is no longer available — cannot resume.`,
317
+ reason: "awaited_event_unavailable",
318
+ });
242
319
  return { isSuccess: true, data: { outcome: "failed" } };
243
320
  }
321
+ const resumeFrom = pending.suspensionEventType === WORKFLOW_RETRY_SCHEDULED_TYPE ? stepIndex : stepIndex + 1;
322
+ return runResumedPipeline(ctx, {
323
+ workflow,
324
+ workflowName,
325
+ runId,
326
+ stepIndex,
327
+ resumeFrom,
328
+ triggerEvent,
329
+ awaitedPayload: awaited.payload,
330
+ pending,
331
+ });
244
332
  },
245
333
  };
@@ -27,6 +27,7 @@ async function upsertPending(tx, row) {
27
27
  // Phase 3's event-subscriber writes these when the awaited event
28
28
  // arrives — Phase 1 never touches them, insert or update.
29
29
  triggerEventType: null,
30
+ triggerEventRef: null,
30
31
  triggerPayload: null,
31
32
  }, { conflictKeys: ["tenantId", "runId", "stepIndex"] });
32
33
  }
@@ -1,9 +1,14 @@
1
1
  import type { HandlerContext, StepInstance, WorkflowDefinition, WriteEvent } from "@cosmicdrift/kumiko-framework/engine";
2
2
  export declare function isResumableSuspension(steps: readonly StepInstance[], stepIndex: number): boolean;
3
+ export type TriggerEventRef = {
4
+ readonly eventId: string;
5
+ readonly aggregateId: string;
6
+ readonly version: number;
7
+ };
3
8
  export type WorkflowRunStartedPayload = {
4
9
  readonly workflowName: string;
5
10
  readonly triggerEventType: string;
6
- readonly triggerPayload: unknown;
11
+ readonly triggerEventRef: TriggerEventRef;
7
12
  readonly definitionFingerprint: string;
8
13
  readonly idempotencyKey?: string;
9
14
  };
@@ -35,6 +40,7 @@ export declare function startAndRunWorkflow(args: {
35
40
  readonly runId: string;
36
41
  readonly workflow: WorkflowDefinition;
37
42
  readonly triggerEvent: WriteEvent;
43
+ readonly triggerEventRef: TriggerEventRef;
38
44
  readonly idempotencyKey?: string;
39
45
  readonly handlerCtx: HandlerContext;
40
46
  }): Promise<{
@@ -42,7 +42,7 @@ export async function startAndRunWorkflow(args) {
42
42
  const startedPayload = {
43
43
  workflowName: args.workflow.name,
44
44
  triggerEventType: args.triggerEvent.type,
45
- triggerPayload: args.triggerEvent.payload,
45
+ triggerEventRef: args.triggerEventRef,
46
46
  definitionFingerprint: fingerprint,
47
47
  ...(args.idempotencyKey && { idempotencyKey: args.idempotencyKey }),
48
48
  };
@@ -38,8 +38,14 @@ export const workflowRunPendingTable = pgTable("workflow_run_pending", {
38
38
  waitEventType: text("wait_event_type"),
39
39
  matchExpr: jsonb("match_expr"),
40
40
  // Written by the Phase 3 event-subscriber when the awaited event
41
- // arrives — always NULL in Phase 1/2.
41
+ // arrives — always NULL in Phase 1/2. The row points at the awaited event
42
+ // ({ eventId, aggregateId, version }); resume-run re-reads it from the
43
+ // event store, so erasing the event also covers the workflow.
42
44
  triggerEventType: text("trigger_event_type"),
45
+ triggerEventRef: jsonb("trigger_event_ref"),
46
+ // Legacy copy of the awaited payload. No longer written; only rows
47
+ // matched before the reference existed still carry it, and resume-run
48
+ // falls back to it for those.
43
49
  triggerPayload: jsonb("trigger_payload"),
44
50
  }, (t) => [
45
51
  primaryKey({
@@ -65,6 +71,7 @@ export const workflowRunPendingTableMeta = defineUnmanagedTable({
65
71
  { name: "wait_event_type", pgType: "text", notNull: false },
66
72
  { name: "match_expr", pgType: "jsonb", notNull: false },
67
73
  { name: "trigger_event_type", pgType: "text", notNull: false },
74
+ { name: "trigger_event_ref", pgType: "jsonb", notNull: false },
68
75
  { name: "trigger_payload", pgType: "jsonb", notNull: false },
69
76
  ],
70
77
  indexes: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.338.0",
3
+ "version": "0.339.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -365,6 +365,22 @@
365
365
  "types": "./dist/channel-push/index.d.ts",
366
366
  "default": "./dist/channel-push/index.js"
367
367
  },
368
+ "./channel-slack": {
369
+ "types": "./dist/channel-slack/index.d.ts",
370
+ "default": "./dist/channel-slack/index.js"
371
+ },
372
+ "./channel-discord": {
373
+ "types": "./dist/channel-discord/index.d.ts",
374
+ "default": "./dist/channel-discord/index.js"
375
+ },
376
+ "./channel-teams": {
377
+ "types": "./dist/channel-teams/index.d.ts",
378
+ "default": "./dist/channel-teams/index.js"
379
+ },
380
+ "./channel-telegram": {
381
+ "types": "./dist/channel-telegram/index.d.ts",
382
+ "default": "./dist/channel-telegram/index.js"
383
+ },
368
384
  "./renderer-simple": {
369
385
  "types": "./dist/renderer-simple/index.d.ts",
370
386
  "default": "./dist/renderer-simple/index.js"
@@ -483,12 +499,12 @@
483
499
  }
484
500
  },
485
501
  "dependencies": {
486
- "@cosmicdrift/kumiko-dispatcher-live": "0.338.0",
487
- "@cosmicdrift/kumiko-framework": "0.338.0",
488
- "@cosmicdrift/kumiko-headless": "0.338.0",
489
- "@cosmicdrift/kumiko-renderer": "0.338.0",
490
- "@cosmicdrift/kumiko-renderer-web": "0.338.0",
491
- "@cosmicdrift/kumiko-types": "0.338.0",
502
+ "@cosmicdrift/kumiko-dispatcher-live": "0.339.0",
503
+ "@cosmicdrift/kumiko-framework": "0.339.0",
504
+ "@cosmicdrift/kumiko-headless": "0.339.0",
505
+ "@cosmicdrift/kumiko-renderer": "0.339.0",
506
+ "@cosmicdrift/kumiko-renderer-web": "0.339.0",
507
+ "@cosmicdrift/kumiko-types": "0.339.0",
492
508
  "@mollie/api-client": "^4.5.0",
493
509
  "@node-rs/argon2": "^2.0.2",
494
510
  "@types/mailparser": "^3.4.6",
@@ -859,6 +875,22 @@
859
875
  "types": "./dist/channel-push/index.d.ts",
860
876
  "default": "./dist/channel-push/index.js"
861
877
  },
878
+ "./channel-slack": {
879
+ "types": "./dist/channel-slack/index.d.ts",
880
+ "default": "./dist/channel-slack/index.js"
881
+ },
882
+ "./channel-discord": {
883
+ "types": "./dist/channel-discord/index.d.ts",
884
+ "default": "./dist/channel-discord/index.js"
885
+ },
886
+ "./channel-teams": {
887
+ "types": "./dist/channel-teams/index.d.ts",
888
+ "default": "./dist/channel-teams/index.js"
889
+ },
890
+ "./channel-telegram": {
891
+ "types": "./dist/channel-telegram/index.d.ts",
892
+ "default": "./dist/channel-telegram/index.js"
893
+ },
862
894
  "./renderer-simple": {
863
895
  "types": "./dist/renderer-simple/index.d.ts",
864
896
  "default": "./dist/renderer-simple/index.js"
@@ -985,8 +1017,8 @@
985
1017
  ],
986
1018
  "devDependencies": {
987
1019
  "@testing-library/user-event": "^14.6.1",
988
- "@cosmicdrift/kumiko-locale-de": "0.338.0",
989
- "@cosmicdrift/kumiko-locale-es": "0.338.0",
1020
+ "@cosmicdrift/kumiko-locale-de": "0.339.0",
1021
+ "@cosmicdrift/kumiko-locale-es": "0.339.0",
990
1022
  "jsqr": "^1.4.0"
991
1023
  }
992
1024
  }
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "emailPasswordClient({ loginUrl, postLogoutUrl }) sends unauthenticated visitors to an external login page with a same-origin next path and navigates there on logout",
6
+ "detail": "emailPasswordClient accepts loginUrl and postLogoutUrl for external login pages",
7
+ "migration": "No code change needed."
8
+ },
2
9
  {
3
10
  "version": "0.335.0",
4
11
  "type": "fix",
@@ -1,4 +1,38 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "Missing or unchecked checkout consent returns 422 consent_required",
6
+ "detail": "The consent payload is optional in the `start-plan-checkout` and `create-checkout-session` input schemas and its two flags are plain booleans. `prepareConsent` throws `UnprocessableError(\"consent_required\")` with `billing-foundation.errors.consentRequired` when consumer protection is on and the consent is missing or either flag is not true.",
7
+ "migration": "Clients that treated the previous `400` validation error for a missing consent as the signal must check for `422` with `reason: \"consent_required\"`."
8
+ },
9
+ {
10
+ "version": "0.339.0",
11
+ "type": "fix",
12
+ "title": "Contract confirmation, termination receipt and cancellation pages use \"du\"",
13
+ "detail": "The German contract confirmation mail, the termination and withdrawal receipt mail, the public cancellation pages and the subscription and payment submit messages address the customer with \"du\". The recorded consent statements are first-person and unchanged, so `consentTextVersion` stays the same."
14
+ },
15
+ {
16
+ "version": "0.339.0",
17
+ "type": "fix",
18
+ "title": "The § 312k confirm POST forwards the visitor's host to /api/write",
19
+ "detail": "The `/legal/kuendigen` and `/legal/cancel` pages re-enter `/api/write` through the app. The re-entry now carries `Host`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Tenant` and the `kumiko_tenant` cookie as the visitor sent them, so a host-based `tenantResolver` resolves the same tenant as for a direct call; before, it saw no host and every confirm failed with `400 tenant_required`. The session cookie, `Authorization` and `X-Forwarded-For` are not forwarded.",
20
+ "migration": "keine"
21
+ },
22
+ {
23
+ "version": "0.339.0",
24
+ "type": "improvement",
25
+ "title": "consumerProtection.terminationScope \"platform\" for hosts without a tenant",
26
+ "detail": "`consumerProtection.terminationScope` is `\"tenant-host\"` (default, unchanged) or `\"platform\"`. With `\"platform\"`, `request-contract-termination` is flagged `tenantlessAnonymous` and also runs on a host whose resolver returns no tenant: it finds the contract by the declarant's email as before, records on the matched tenant's subscription stream, and answers every outcome identically. An unknown option value fails at `createBillingFoundationFeature`.",
27
+ "migration": "Apps that serve the pages on a platform apex (publicstatus) set `consumerProtection.terminationScope: \"platform\"`. Other apps change nothing."
28
+ },
29
+ {
30
+ "version": "0.339.0",
31
+ "type": "improvement",
32
+ "title": "Public termination declarations cancel at the provider in a job",
33
+ "detail": "For the public channel, `request-contract-termination` no longer calls the provider in the request. A matched request appends the PII-free `contract-termination-declared` event (`requestId`, `declarationType`, `terminationKind`, `receivedAtIso`, `locale`) via the system-only `declare-contract-termination`; the job `cancel-on-public-termination-declared` then runs `record-contract-termination`, which asks the provider for `cancel_at_period_end` and appends `contract-termination-requested` with the outcome. Matched and unmatched requests do the same work in the request, so the response time does not reveal whether the email is a customer. The job is idempotent per `requestId`. A provider without `cancelSubscription`, a provider error or a missing subscription now sends a separate operator notice with request id and tenant id but no name or email. A public withdrawal still makes no provider call. The account path (`terminate-contract`) stays synchronous.",
34
+ "migration": "Code that expected `contract-termination-requested` right after the public request must wait for the job (in tests: `drainJobs`)."
35
+ },
2
36
  {
3
37
  "version": "0.338.0",
4
38
  "type": "improvement",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "Slack, Discord, Microsoft Teams and Telegram delivery channels",
6
+ "detail": "`channel-slack`, `channel-discord`, `channel-teams` and `channel-telegram` register route-only delivery channels. Webhook URLs and the Telegram bot token live in the tenant's secrets; the send job reads them with an audit entry `<feature>:send`. `DeliveryChannel.resolve` is optional and `ChannelContext.secrets` is available on the send job path.",
7
+ "migration": "keine"
8
+ },
2
9
  {
3
10
  "version": "0.338.0",
4
11
  "type": "improvement",
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "Inbound mail OAuth accounts refresh their access token",
6
+ "detail": "`createInboundMailSupervisor` obtains a valid access token via the new `createOAuthAccessTokenManager` before `plugin.fetch`/`plugin.watch` for accounts whose provider declares `oauth` and whose authMethod is `oauth`/`xoauth2`, and passes it as `opts.accessToken` (`fetch`) or `{ accessToken }` (`watch`, 4th argument). `refreshAccessToken` receives an optional 4th argument `{ signal }` (aborted after 30 s); providers must pass it to their token-endpoint fetch, since the foundation awaits the provider and never drops a late result. The foundation has no single construction point for routes and supervisor, so apps that want connect to prime the cache and write under the refresh lock create one manager with `createOAuthAccessTokenManager({ db, secrets })` and pass it to both `createInboundMailSupervisor({ oauthTokens })` and `createInboundMailConnectRoutes({ oauthTokens })`; without it the supervisor builds its own manager and the callback writes the secret without the lock. `InboundAuthError` from `refreshAccessToken` moves the account to `auth_error`; the generic sync-error log and the `token_exchange_failed` response body no longer contain provider error messages.",
7
+ "migration": "Apps with OAuth inbound accounts create one `createOAuthAccessTokenManager({ db, secrets })` and pass it as `oauthTokens` to both `createInboundMailSupervisor` and `createInboundMailConnectRoutes`. Provider plugins with a `refreshAccessToken` pass the `signal` from its fourth argument to their token-endpoint fetch."
8
+ },
2
9
  {
3
10
  "version": "0.335.0",
4
11
  "type": "fix",
@@ -1 +1,9 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "improvement",
5
+ "title": "createRendererSimpleFeature({ mailBranding }) brands every notification mail, including the auth mails, with product name or logo, primary color and footer text and links",
6
+ "detail": "renderer-simple accepts mailBranding for all framework notification mails",
7
+ "migration": "No code change needed."
8
+ }
9
+ ]
@@ -1,4 +1,11 @@
1
1
  [
2
+ {
3
+ "version": "0.339.0",
4
+ "type": "breaking",
5
+ "title": "workflow-run stream stores a reference to the trigger event instead of its payload",
6
+ "detail": "`workflow.run-started` now holds `triggerEventRef: { eventId, aggregateId, version }` and no `triggerPayload`; the wait, waitForEvent and retry step events no longer embed it either. `workflow_run_pending` gets a `trigger_event_ref` column and the event-subscriber stops writing `trigger_payload`. `resume-run` loads the trigger and the awaited event through `ctx.loadAggregate` and fails the run with `trigger_event_unavailable` or `awaited_event_unavailable` when one is gone.",
7
+ "migration": "Own code that reads `triggerPayload` from `workflow.run-started` must read `triggerEventRef` and load the event with `ctx.loadAggregate(ref.aggregateId)`. Apps generate the migration for the new `workflow_run_pending.trigger_event_ref` column with `kumiko schema generate`. The step events `workflow.step.waiting`, `workflow.step.waiting-for-event` and `workflow.retry.scheduled` no longer carry `triggerPayload`; a custom resume loop that read it must load the trigger event through the `triggerEventRef` on its own run-started event (see `samples/recipes/workflow-engine/src/postgres-resume-loop.ts`). Stored run-started events and pending rows keep their old copy; this release rewrites no events, and runs started before it resume from that copy."
8
+ },
2
9
  {
3
10
  "version": "0.337.0",
4
11
  "type": "fix",