@cosmicdrift/kumiko-samples 0.352.0 → 0.354.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +18 -2
  3. package/packages/bundled-features/src/agent-tools/agent-doc-lint.ts +66 -0
  4. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +26 -7
  5. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  6. package/packages/bundled-features/src/agent-tools/feature.ts +13 -2
  7. package/packages/bundled-features/src/agent-tools/index.ts +6 -1
  8. package/packages/bundled-features/src/agent-tools/manifest-deny-qns.ts +25 -0
  9. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +4 -1
  10. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +1 -1
  11. package/packages/bundled-features/src/audit/changes.json +6 -0
  12. package/packages/bundled-features/src/audit/handlers/list.query.ts +20 -10
  13. package/packages/bundled-features/src/auth-email-password/changes.json +24 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +1 -0
  15. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +3 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +3 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +21 -0
  18. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -1
  19. package/packages/bundled-features/src/auth-email-password/handlers/login.write.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/handlers/reset-password.write.ts +1 -0
  21. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +7 -3
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +53 -96
  23. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  24. package/packages/bundled-features/src/auth-email-password/handlers/verify-email.write.ts +1 -0
  25. package/packages/bundled-features/src/auth-email-password/index.ts +12 -0
  26. package/packages/bundled-features/src/auth-email-password/seeding.ts +38 -7
  27. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +83 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-token-store.ts +9 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +7 -7
  30. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +15 -0
  31. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +2 -2
  32. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  33. package/packages/bundled-features/src/auth-mfa/changes.json +7 -0
  34. package/packages/bundled-features/src/auth-mfa/feature.ts +5 -0
  35. package/packages/bundled-features/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +1 -0
  36. package/packages/bundled-features/src/auth-mfa/handlers/verify.write.ts +1 -0
  37. package/packages/bundled-features/src/auth-mfa/screens.ts +2 -2
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-recovery-codes-reveal.tsx +6 -8
  39. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +18 -21
  40. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +2 -2
  41. package/packages/bundled-features/src/billing-foundation/billing-info-query.ts +7 -8
  42. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  43. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +14 -2
  44. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +7 -1
  45. package/packages/bundled-features/src/cap-overview/access.ts +2 -9
  46. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +5 -2
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +6 -9
  48. package/packages/bundled-features/src/channel-in-app/changes.json +8 -1
  49. package/packages/bundled-features/src/channel-in-app/in-app-channel.ts +1 -1
  50. package/packages/bundled-features/src/channel-in-app/tables.ts +18 -12
  51. package/packages/bundled-features/src/compliance-profiles-ops/handlers/tenants-missing-profile.query.ts +5 -4
  52. package/packages/bundled-features/src/config/handlers/values.query.ts +7 -2
  53. package/packages/bundled-features/src/crypto-shredding/changes.json +12 -0
  54. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +154 -69
  55. package/packages/bundled-features/src/delivery/changes.json +6 -0
  56. package/packages/bundled-features/src/delivery/db/queries/address-opt-outs.ts +18 -12
  57. package/packages/bundled-features/src/delivery/delivery-service.ts +103 -16
  58. package/packages/bundled-features/src/delivery/public-names.ts +1 -1
  59. package/packages/bundled-features/src/delivery/unsubscribe.ts +81 -13
  60. package/packages/bundled-features/src/feature-toggles/feature.ts +2 -3
  61. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +34 -5
  62. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +31 -8
  63. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +10 -1
  64. package/packages/bundled-features/src/files/changes.json +24 -0
  65. package/packages/bundled-features/src/files-tenant-data/changes.json +5 -0
  66. package/packages/bundled-features/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +4 -0
  67. package/packages/bundled-features/src/files-tenant-data/hooks.ts +75 -25
  68. package/packages/bundled-features/src/folders/changes.json +6 -0
  69. package/packages/bundled-features/src/folders/feature.ts +2 -12
  70. package/packages/bundled-features/src/folders/handlers/delete-folder.write.ts +41 -9
  71. package/packages/bundled-features/src/form-draft/changes.json +7 -1
  72. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +4 -2
  73. package/packages/bundled-features/src/form-draft/handlers/cleanup.job.ts +18 -6
  74. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  75. package/packages/bundled-features/src/foundation-shared/mail-host-policy.ts +16 -4
  76. package/packages/bundled-features/src/inbound-provider-imap/changes.json +6 -0
  77. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +2 -0
  78. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +3 -4
  79. package/packages/bundled-features/src/jobs/changes.json +30 -0
  80. package/packages/bundled-features/src/jobs/constants.ts +1 -0
  81. package/packages/bundled-features/src/jobs/db/queries/retention.ts +12 -0
  82. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +26 -2
  83. package/packages/bundled-features/src/jobs/feature.ts +7 -1
  84. package/packages/bundled-features/src/jobs/handlers/list.query.ts +45 -12
  85. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +1 -1
  86. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +94 -0
  87. package/packages/bundled-features/src/jobs/i18n.ts +1 -0
  88. package/packages/bundled-features/src/jobs/index.ts +2 -0
  89. package/packages/bundled-features/src/jobs/job-run-logger.ts +177 -10
  90. package/packages/bundled-features/src/jobs/tenant-job-failure-table.ts +27 -8
  91. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +55 -0
  92. package/packages/bundled-features/src/ledger/changes.json +23 -0
  93. package/packages/bundled-features/src/ledger/entity.ts +17 -5
  94. package/packages/bundled-features/src/ledger/feature.ts +3 -0
  95. package/packages/bundled-features/src/ledger/handlers/confirm-schedule-period.write.ts +15 -3
  96. package/packages/bundled-features/src/ledger/handlers/create-transaction.write.ts +1 -0
  97. package/packages/bundled-features/src/ledger/handlers/reverse-transaction.write.ts +1 -0
  98. package/packages/bundled-features/src/ledger/schedule-subject-pair.ts +27 -0
  99. package/packages/bundled-features/src/ledger/schemas.ts +8 -2
  100. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +4 -15
  101. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  102. package/packages/bundled-features/src/managed-pages/feature.ts +24 -11
  103. package/packages/bundled-features/src/notes-history/changes.json +13 -0
  104. package/packages/bundled-features/src/notes-history/entity.ts +4 -0
  105. package/packages/bundled-features/src/notes-history/feature.ts +2 -12
  106. package/packages/bundled-features/src/personal-access-tokens/handlers/create.write.ts +1 -0
  107. package/packages/bundled-features/src/personal-access-tokens/screens.ts +24 -3
  108. package/packages/bundled-features/src/seo/changes.json +5 -0
  109. package/packages/bundled-features/src/seo/feature.ts +23 -7
  110. package/packages/bundled-features/src/sessions/feature.ts +21 -8
  111. package/packages/bundled-features/src/sessions/handlers/detail.query.ts +34 -33
  112. package/packages/bundled-features/src/sessions/handlers/list.query.ts +68 -65
  113. package/packages/bundled-features/src/sessions/screens.ts +10 -8
  114. package/packages/bundled-features/src/shared/assert-no-where-rule-in-ownership-write.ts +25 -0
  115. package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +15 -8
  116. package/packages/bundled-features/src/shared/index.ts +4 -2
  117. package/packages/bundled-features/src/shared/parent-visibility.ts +31 -1
  118. package/packages/bundled-features/src/shared/rate-limited-text-response.ts +11 -0
  119. package/packages/bundled-features/src/shared/row-bound-grant.ts +18 -2
  120. package/packages/bundled-features/src/shared/session-locale-field.ts +7 -0
  121. package/packages/bundled-features/src/tags/feature.ts +2 -12
  122. package/packages/bundled-features/src/template-resolver/api.ts +3 -3
  123. package/packages/bundled-features/src/template-resolver/changes.json +5 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +2 -2
  125. package/packages/bundled-features/src/tenant/changes.json +19 -0
  126. package/packages/bundled-features/src/tenant/decrypt-tenant-names.ts +17 -0
  127. package/packages/bundled-features/src/tenant/feature.ts +8 -1
  128. package/packages/bundled-features/src/tenant/handlers/me.query.ts +4 -1
  129. package/packages/bundled-features/src/tenant/handlers/memberships.query.ts +7 -3
  130. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +11 -5
  131. package/packages/bundled-features/src/tenant/handlers/toggle-enabled.write.ts +2 -0
  132. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +1 -0
  133. package/packages/bundled-features/src/tenant/index.ts +1 -0
  134. package/packages/bundled-features/src/tenant/invitation-table.ts +6 -0
  135. package/packages/bundled-features/src/tenant/invited-membership.ts +45 -12
  136. package/packages/bundled-features/src/tenant/schema/tenant.ts +4 -6
  137. package/packages/bundled-features/src/tenant/screens.ts +1 -1
  138. package/packages/bundled-features/src/tenant-handover/changes.json +12 -0
  139. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +12 -19
  140. package/packages/bundled-features/src/tenant-handover/transfer-graph.ts +14 -96
  141. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +16 -0
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +22 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +8 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/events.ts +6 -0
  145. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +14 -1
  146. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +105 -29
  147. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +64 -10
  148. package/packages/bundled-features/src/tenant-lifecycle/testing.ts +58 -0
  149. package/packages/bundled-features/src/tier-engine/changes.json +5 -0
  150. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +1 -0
  151. package/packages/bundled-features/src/user/changes.json +15 -0
  152. package/packages/bundled-features/src/user/handlers/detail.query.ts +1 -0
  153. package/packages/bundled-features/src/user/handlers/list.query.ts +8 -2
  154. package/packages/bundled-features/src/user/handlers/update.write.ts +1 -0
  155. package/packages/bundled-features/src/user/seeding.ts +34 -0
  156. package/packages/bundled-features/src/user-data-rights/boot-checks.ts +7 -0
  157. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  158. package/packages/bundled-features/src/user-data-rights/deletion-token.ts +1 -1
  159. package/packages/bundled-features/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -2
  160. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +1 -0
  161. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  162. package/packages/bundled-features/src/user-data-rights/schema/download-attempt.ts +17 -9
  163. package/packages/bundled-features/src/user-profile/handlers/change-email.write.ts +8 -2
  164. package/packages/bundled-features/src/workflow-runner/changes.json +5 -0
  165. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +80 -14
  166. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +11 -1
  167. package/packages/bundled-features/src/workflow-runner/runner.ts +8 -2
  168. package/samples/apps/showcase/src/features/items/feature.ts +2 -1
  169. package/samples/apps/showcase/src/features/items/schema/item.ts +0 -1
  170. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +47 -0
  171. package/samples/apps/styleguide/src/features/widgets/feature.ts +131 -15
  172. package/samples/apps/styleguide/src/features/widgets/i18n.ts +40 -0
  173. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +5 -4
  174. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +4 -0
  175. package/samples/apps/ui-walkthrough/package.json +3 -0
  176. package/samples/apps/use-all-bundled/README.md +1 -2
  177. package/samples/apps/use-all-bundled/feature-manifest.json +1 -1
  178. package/samples/apps/user-data-rights-demo/README.md +1 -3
  179. package/samples/recipes/legal-pages/README.md +3 -4
  180. package/samples/recipes/managed-pages/README.md +2 -2
  181. package/samples/recipes/public-share-token/README.md +4 -5
  182. package/samples/recipes/public-share-token/src/feature.ts +1 -1
  183. package/samples/recipes/security-baseline/README.md +6 -0
  184. package/samples/recipes/seed-migration/README.md +0 -2
  185. package/samples/recipes/tenant-settings/README.md +1 -1
  186. package/packages/bundled-features/src/shared/has-where-rule.ts +0 -11
@@ -1,5 +1,9 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { configuredPiiSubjectKms, type SubjectId } from "@cosmicdrift/kumiko-framework/crypto";
2
+ import {
3
+ configuredPiiSubjectKms,
4
+ type SubjectId,
5
+ subjectIdToKey,
6
+ } from "@cosmicdrift/kumiko-framework/crypto";
3
7
  import {
4
8
  createEventStoreExecutor,
5
9
  createTenantDb,
@@ -18,6 +22,7 @@ import {
18
22
  isTenantResourceExtensionHooks,
19
23
  type Registry,
20
24
  type TenantDataHookCtx,
25
+ type TenantDestroyHookResult,
21
26
  type TenantId,
22
27
  type TenantResourceExtensionName,
23
28
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -26,6 +31,10 @@ import {
26
31
  createEscapeHatchReporter,
27
32
  UNATTRIBUTED_ACTOR,
28
33
  } from "@cosmicdrift/kumiko-framework/pipeline";
34
+ import {
35
+ purgeSearchDocumentsForSubject,
36
+ type SearchAdapter,
37
+ } from "@cosmicdrift/kumiko-framework/search";
29
38
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
30
39
  import {
31
40
  tenantEntity,
@@ -45,17 +54,26 @@ export type DestructionStageCtx = {
45
54
  // "destroyTenant" hooks) — undefined when no file-provider is wired,
46
55
  // which those hooks must treat as "nothing to clean up", not an error.
47
56
  readonly fileProviderResolver?: FileProviderResolver;
57
+ // Derived search docs of the tenant record (its name) live in the writer's
58
+ // index, not the destroyed tenant's, so the subject-keys stage purges them.
59
+ readonly searchAdapter?: SearchAdapter;
48
60
  // fw#2914 — sourced from the owning job's ctx (_escapeHatchAuditSink,
49
61
  // systemUser.id); runTenantDataHooks uses them to attribute+audit any
50
62
  // EXT_TENANT_DATA usage's declared escapeHatch.
51
63
  readonly escapeHatchAuditSink?: EscapeHatchAuditSink;
52
64
  readonly actor?: string;
65
+ // Epoch ms the stage must hand back by returning { done: false }.
66
+ readonly deadlineAt: number;
53
67
  };
54
68
 
69
+ export type StageRunOutcome = { readonly done: boolean; readonly processed: number };
70
+
71
+ const STAGE_DONE: StageRunOutcome = { done: true, processed: 0 };
72
+
55
73
  export type DestructionStage = {
56
74
  readonly name: TenantDestructionStageName;
57
75
  readonly maxAttempts: number;
58
- readonly run: (ctx: DestructionStageCtx) => Promise<void>;
76
+ readonly run: (ctx: DestructionStageCtx) => Promise<StageRunOutcome>;
59
77
  };
60
78
 
61
79
  const tenantCrud = createEventStoreExecutor(tenantTable, tenantEntity, { entityName: "tenant" });
@@ -67,24 +85,44 @@ const tenantMembershipCrud = createEventStoreExecutor(
67
85
  },
68
86
  );
69
87
 
88
+ // Every hook still runs each tick (they are idempotent), so a later hook is not
89
+ // starved by an earlier one that keeps reporting done:false.
90
+ function newOutcomeAccumulator(): {
91
+ add: (result: TenantDestroyHookResult | void) => void;
92
+ outcome: () => StageRunOutcome;
93
+ } {
94
+ let allDone = true;
95
+ let processed = 0;
96
+ return {
97
+ add: (result) => {
98
+ if (result?.done === false) allDone = false;
99
+ processed += result?.processed ?? 0;
100
+ },
101
+ outcome: () => ({ done: allDone, processed }),
102
+ };
103
+ }
104
+
70
105
  async function runExtensionDestroyHooks(
71
106
  registry: Registry,
72
107
  extensionName: TenantResourceExtensionName,
73
108
  ctx: DestructionStageCtx,
74
- ): Promise<void> {
109
+ ): Promise<StageRunOutcome> {
75
110
  const usages = registry.getExtensionUsages(extensionName);
111
+ const total = newOutcomeAccumulator();
76
112
  for (const usage of usages) {
77
113
  if (!isTenantResourceExtensionHooks(usage.options)) {
78
114
  throw new Error(
79
115
  `${extensionName} registration for "${usage.entityName}" has no destroy function`,
80
116
  );
81
117
  }
82
- await usage.options.destroyTenant(ctx.tenantId, ctx);
118
+ total.add(await usage.options.destroyTenant(ctx.tenantId, ctx));
83
119
  }
120
+ return total.outcome();
84
121
  }
85
122
 
86
- async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
123
+ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
87
124
  const usages = ctx.registry.getExtensionUsages(EXT_TENANT_DATA);
125
+ const total = newOutcomeAccumulator();
88
126
  for (const usage of usages) {
89
127
  if (!isTenantDataExtensionHooks(usage.options)) {
90
128
  throw new Error(
@@ -106,25 +144,29 @@ async function runTenantDataHooks(ctx: DestructionStageCtx): Promise<void> {
106
144
  }),
107
145
  registry: ctx.registry,
108
146
  tenantId: ctx.tenantId,
147
+ deadlineAt: ctx.deadlineAt,
109
148
  fileProviderResolver: ctx.fileProviderResolver,
110
149
  log: ctx.log,
111
150
  };
112
- await destroy(hookCtx);
151
+ total.add(await destroy(hookCtx));
113
152
  }
153
+ return total.outcome();
114
154
  }
115
155
 
116
- async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
156
+ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
117
157
  const kms = configuredPiiSubjectKms();
118
158
  if (!kms) {
119
159
  ctx.log?.("[tenant-lifecycle] subject-keys stage skipped: no KMS adapter configured");
120
160
  // skip: KMS optional — apps without crypto-shredding still run other destroy stages
121
- return;
161
+ return STAGE_DONE;
122
162
  }
123
163
  const memberships = await selectMany<{ userId: string }>(ctx.db, tenantMembershipsTable, {
124
164
  tenantId: ctx.tenantId,
125
165
  });
166
+ const tenantRecordSubject: SubjectId = { kind: "record", entity: "tenant", id: ctx.tenantId };
126
167
  const subjects: SubjectId[] = [
127
168
  { kind: "tenant", tenantId: ctx.tenantId },
169
+ tenantRecordSubject,
128
170
  ...memberships.map((m) => ({ kind: "user" as const, userId: m.userId })),
129
171
  ];
130
172
  for (const subject of subjects) {
@@ -133,15 +175,26 @@ async function eraseSubjectKeys(ctx: DestructionStageCtx): Promise<void> {
133
175
  eraseReason: "tenant-destroy stage subject-keys",
134
176
  });
135
177
  }
178
+ if (ctx.searchAdapter) {
179
+ await purgeSearchDocumentsForSubject(
180
+ ctx.db,
181
+ ctx.registry.features,
182
+ ctx.searchAdapter,
183
+ subjectIdToKey(tenantRecordSubject),
184
+ tenantRecordSubject,
185
+ );
186
+ }
187
+ return STAGE_DONE;
136
188
  }
137
189
 
138
- async function purgeTenantCache(ctx: DestructionStageCtx): Promise<void> {
190
+ async function purgeTenantCache(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
139
191
  // ponytail: Redis SCAN+DEL is wired when ctx carries a redis client; until
140
192
  // then this stage is a documented no-op (no cache layer in test stack).
141
193
  ctx.log?.("[tenant-lifecycle] cache stage: no redis client in ctx — skipped");
194
+ return STAGE_DONE;
142
195
  }
143
196
 
144
- async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
197
+ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<StageRunOutcome> {
145
198
  const now = getTemporal().Now.instant();
146
199
  const user = createSystemUser(ctx.tenantId);
147
200
  const db = createTenantDb(ctx.db, ctx.tenantId, "system");
@@ -180,6 +233,7 @@ async function tombstoneTenantRow(ctx: DestructionStageCtx): Promise<void> {
180
233
  { skipOptimisticLock: true },
181
234
  );
182
235
  invalidateTenantLifecycleGate(ctx.tenantId);
236
+ return STAGE_DONE;
183
237
  }
184
238
 
185
239
  export const DESTRUCTION_STAGES: readonly DestructionStage[] = [
@@ -0,0 +1,58 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { append, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
5
+ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
6
+ import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
7
+ import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
8
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
9
+ import { tenantTable } from "../tenant/index.js";
10
+ import { TENANT_AGGREGATE_TYPE, TENANT_DESTRUCTION_STARTED_EVENT_QN } from "./constants.js";
11
+ import { runTenantDestructionSweep } from "./run-tenant-destroy.js";
12
+
13
+ // Sidesteps the `request-destruction` write handler (needs user/auth/sessions
14
+ // features wired) by seeding the same "destroying" state it would produce.
15
+ export async function seedDestroyingTenant(db: DbConnection, tenantId: TenantId): Promise<void> {
16
+ const now = getTemporal().Now.instant();
17
+ await updateRows(
18
+ db,
19
+ tenantTable,
20
+ { status: "destroying", destroyStartedAt: now },
21
+ { id: tenantId },
22
+ );
23
+ await append(db, {
24
+ aggregateId: tenantId,
25
+ aggregateType: TENANT_AGGREGATE_TYPE,
26
+ tenantId,
27
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
28
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
29
+ payload: { startedAt: now.toString() },
30
+ metadata: { userId: "system", requestId: "test:destruction-started" },
31
+ });
32
+ }
33
+
34
+ export async function driveDestructionToCompletion(
35
+ stack: TestStack,
36
+ db: DbConnection,
37
+ tenantId: TenantId,
38
+ searchAdapter?: SearchAdapter,
39
+ ): Promise<string> {
40
+ const farFuture = getTemporal()
41
+ .Now.instant()
42
+ .add({ hours: 24 * 3650 });
43
+ let status = "";
44
+ for (let i = 0; i < 20; i++) {
45
+ await runTenantDestructionSweep({
46
+ db: stack.db,
47
+ registry: stack.registry,
48
+ now: farFuture,
49
+ searchAdapter,
50
+ });
51
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
52
+ status = String(rows[0]?.["status"]);
53
+ if (status === "destroyed" || status === "destroyFailed") break;
54
+ }
55
+ return status;
56
+ }
57
+
58
+ export { runTenantDestructionSweep };
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "tier-engine:write:set-tenant-tier resolves agent risk high"
6
+ },
2
7
  {
3
8
  "version": "0.352.0",
4
9
  "type": "improvement",
@@ -67,6 +67,7 @@ export function createSetTenantTierWrite(opts: SetTenantTierOptions = {}) {
67
67
  .refine((t) => !opts.validTiers || opts.validTiers.has(t), { message: "unknown tier" }),
68
68
  }),
69
69
  access: { roles: ["SystemAdmin"] },
70
+ agent: { risk: "high" },
70
71
  escapeHatch: {
71
72
  grants: ["unsafeRaw"],
72
73
  reason: SET_TENANT_TIER_REASON,
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "fix",
5
+ "title": "user:write:user:update resolves agent risk high"
6
+ },
7
+ {
8
+ "version": "0.354.0",
9
+ "type": "fix",
10
+ "title": "user:query:user:list skips the tenants label when the tenant feature is not mounted"
11
+ },
12
+ {
13
+ "version": "0.354.0",
14
+ "type": "fix",
15
+ "title": "user:query:user:detail skips the tenants label when the tenant feature is not mounted"
16
+ },
2
17
  {
3
18
  "version": "0.340.0",
4
19
  "type": "improvement",
@@ -25,6 +25,7 @@ export const detailQuery = {
25
25
  ) => {
26
26
  const result = await baseDetail.handler?.(query, ctx);
27
27
  if (result === null || typeof result !== "object") return result;
28
+ if (!ctx.registry.features.has("tenant")) return result;
28
29
  const [enriched] = await attachTenantLabels(
29
30
  [result as Record<string, unknown>],
30
31
  dbForList(ctx),
@@ -8,7 +8,7 @@ import {
8
8
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
9
9
  import type { QueryHandlerDef } from "@cosmicdrift/kumiko-types/handlers";
10
10
  // kumiko-lint-ignore cross-feature-import SystemAdmin user-list joins memberships for tenants column
11
- import { tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
11
+ import { decryptTenantNames, tenantMembershipsTable, tenantTable } from "../../tenant/index.js";
12
12
  import { userEntity } from "../schema/user.js";
13
13
 
14
14
  const baseList = defineEntityListHandler("user", userEntity, {
@@ -82,7 +82,10 @@ export async function attachTenantLabels(
82
82
  ];
83
83
  const tenants =
84
84
  tenantIds.length > 0
85
- ? await selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } })
85
+ ? await decryptTenantNames(
86
+ await selectMany<TenantRow>(db, tenantTable, { id: { in: tenantIds } }),
87
+ "user:list",
88
+ )
86
89
  : [];
87
90
  const byUser = labelsByUserId(memberships, tenantLabelById(tenants));
88
91
  return rows.map((row) => {
@@ -117,6 +120,9 @@ export const listQuery: QueryHandlerDef = {
117
120
  return result;
118
121
  }
119
122
  const envelope = result as { rows: readonly Record<string, unknown>[] };
123
+ // Without the tenant feature there are no membership tables to join; with
124
+ // it, a failing lookup must surface instead of rendering a silent empty column.
125
+ if (!ctx.registry.features.has("tenant")) return result;
120
126
  const rows = await attachTenantLabels(envelope.rows, dbForList(ctx));
121
127
  return { ...envelope, rows };
122
128
  },
@@ -59,6 +59,7 @@ export const updateWrite = defineWriteHandler({
59
59
  },
60
60
  description:
61
61
  "Changes a user's display name, locale, timezone, email, verification flag, last active tenant or global roles against the version the caller read; callers may edit themselves, while editing someone else or granting roles needs a privileged actor.",
62
+ agent: { risk: "high" },
62
63
  escapeHatch: {
63
64
  reason: SYSTEM_ADMIN_DEMOTION_COUNT_REASON,
64
65
  },
@@ -125,6 +125,40 @@ export async function reconcileSeededUserEmailVerified(
125
125
  }
126
126
  }
127
127
 
128
+ // Re-writes `email` through the executor so the PII write pipeline recomputes
129
+ // `email_bidx`. Repairs rows whose blind index is NULL or stale (written before
130
+ // the key existed, key rotated, subject-key erased), which would otherwise stay
131
+ // invisible to every `fetchOne(userTable, { email })` lookup.
132
+ export async function rewriteSeededUserEmail(
133
+ db: DbConnection,
134
+ existingRow: Record<string, unknown>,
135
+ email: string,
136
+ by: SessionUser,
137
+ ): Promise<void> {
138
+ const id = existingRow["id"];
139
+ if (typeof id !== "string") {
140
+ throw new Error("rewriteSeededUserEmail: existing user row has no string id");
141
+ }
142
+ const tdb = createTenantDb(db, by.tenantId, "system");
143
+ const result = await userExecutor.update(
144
+ { id, version: existingRow["version"] as number, changes: { email } },
145
+ by,
146
+ tdb,
147
+ );
148
+ // version_conflict: a concurrent write already changed the row — fine for a seed helper.
149
+ // unique_violation: a duplicate row already holds the blind index; the repair
150
+ // has to wait for a manual duplicate cleanup.
151
+ if (
152
+ !result.isSuccess &&
153
+ result.error.code !== "version_conflict" &&
154
+ result.error.code !== "unique_violation"
155
+ ) {
156
+ throw new Error(
157
+ `rewriteSeededUserEmail failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
158
+ );
159
+ }
160
+ }
161
+
128
162
  // Extrahiert die `id`-Spalte aus dem executor.create-Result. Der
129
163
  // Executor liefert ein Record<string, unknown> (die Projection-Row), in
130
164
  // das die DB die Aggregat-id reinschreibt — wir prüfen runtime statt
@@ -24,6 +24,13 @@ export function validateGdprHookCompleteness(features: readonly FeatureDefinitio
24
24
  if (usage.extensionName !== EXT_USER_DATA) continue;
25
25
  const hasExport = typeof usage.options?.["export"] === "function";
26
26
  const hasDelete = typeof usage.options?.["delete"] === "function";
27
+ // The compile-time "at least one hook" type is bypassed by string-typed
28
+ // extension names and casts; without this an empty bag registers nothing.
29
+ if (!hasExport && !hasDelete) {
30
+ throw new Error(
31
+ `[kumiko:boot] Feature "${feature.name}" registers EXT_USER_DATA for entity "${usage.entityName}" with neither an export nor a delete hook — the entity silently stays out of the Art.15/17/20 pipeline. Register at least one hook.`,
32
+ );
33
+ }
27
34
  if (hasExport && !hasDelete) {
28
35
  throw new Error(
29
36
  `[kumiko:boot] Feature "${feature.name}" exports entity "${usage.entityName}" via EXT_USER_DATA but registers no delete hook — data is included in Art.20 exports but never erased on forget (Art.17 violation). Add a delete hook. If erasure is intentionally handled elsewhere (e.g. crypto-shredding key-erase, parent cascade), register a no-op delete: async () => {} with a comment explaining why.`,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.354.0",
4
+ "type": "breaking",
5
+ "title": "Row-bound grants (deletion token, tenant handover) refuse a colon in anchor or subject",
6
+ "migration": "signRowBoundGrant now throws when the anchor or subject contains \":\", and redeem returns FAILED for such a token. Use colon-free ids (uuids) for anchor and subject; a purpose may still contain a colon."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "fix",
@@ -38,7 +38,7 @@ export function redeemDeletionToken(args: {
38
38
  readonly secret: string | undefined;
39
39
  readonly loadPendingRequestId: (userId: string) => Promise<string | null>;
40
40
  // Spends the anchor AND performs the actual lifecycle transition in one
41
- // atomic step (#3024) — the caller (confirm-deletion-by-token) folds the
41
+ // atomic step — the caller (confirm-deletion-by-token) folds the
42
42
  // Active→DeletionRequested write itself in here via `updateUserLifecycle`'s
43
43
  // `expect: { status: Active, pendingDeletionRequestId }`, so a write issued
44
44
  // after `ok: true` can't lose its work to a crash while the grant is
@@ -37,7 +37,7 @@ async function readPendingDeletionRequestId(
37
37
 
38
38
  // Anonymous apex flow step 2: verify-link target. Verifies the HMAC token,
39
39
  // extracts the userId, and flips the grace period through the shared logic —
40
- // in ONE atomic step with the anchor spend (#3024): commitDeletion carries
40
+ // in ONE atomic step with the anchor spend: commitDeletion carries
41
41
  // the actual grace-period transition, so a crash after the spend can no
42
42
  // longer lose the write step.
43
43
  //
@@ -47,7 +47,7 @@ async function readPendingDeletionRequestId(
47
47
  // a cancel-deletion (status → Active, pendingDeletionRequestId → null), a
48
48
  // replayed token fails against the nulled/renewed requestId — no re-arm.
49
49
  //
50
- // Concurrency (#3024): two simultaneous confirms of the same token both read
50
+ // Concurrency: two simultaneous confirms of the same token both read
51
51
  // the same requestId and both verify the HMAC — commitDeletion spends the
52
52
  // anchor AND writes the transition atomically (expect: status===Active &&
53
53
  // pendingDeletionRequestId===requestId), so exactly one wins. The loser gets
@@ -51,6 +51,7 @@ export function createRequestDeletionHandler(opts: RequestDeletionOptions = {})
51
51
  },
52
52
  },
53
53
  escapeHatch: {
54
+ grants: ["systemIdentity", "unsafeRaw"],
54
55
  reason:
55
56
  "resolveGracePeriod reads the tenant compliance profile via ctx.queryAs(SYSTEM, " +
56
57
  "...) to compute the grace period end — the calling user's own identity has no read " +
@@ -1,3 +1,4 @@
1
+ export { TENANT_MODEL_CONFIG_KEY } from "./constants.js";
1
2
  export { createUserDataRightsFeature, type UserDataRightsOptions } from "./feature.js";
2
3
  export type { SendDeletionVerificationEmailFn } from "./handlers/request-deletion-by-email.write.js";
3
4
  export {
@@ -35,16 +35,24 @@ export const downloadAttemptEntity = createEntity({
35
35
  jobId: createTextField({ personal: false, reason: "technical_reference" }),
36
36
  // User-ID wenn auth-Pfad (job). NULL bei anonymous (token-Pfad).
37
37
  attemptedByUserId: createTextField({ personal: false, reason: "pseudonymous_fk" }),
38
- // Same reasoning as download-token.ts's lastUsedFromIp/lastUsedUserAgent
39
- // (sibling entity, same feature): DPO brute-force-audit data belongs to
40
- // the tenant operator, not the (often anonymous) requester. Also avoids
41
- // a real hazard on this buildEntityTable-managed entity: attemptedByUserId
42
- // is null-by-design for the anonymous token path, so a subject annotation
43
- // (`personal: { of: "attemptedByUserId" }`) would throw
44
- // SubjectResolutionError on every such row once a KMS is configured
38
+ // No subject annotation on this buildEntityTable-managed entity:
39
+ // attemptedByUserId is null-by-design for the anonymous token path, so
40
+ // `personal: { of: "attemptedByUserId" }` would throw SubjectResolutionError
41
+ // on every such row once a KMS is configured
45
42
  // (crypto/subject-resolver.ts resolveUserOwnedSubject).
46
- ip: createTextField({ maxLength: 64, personal: false, reason: "is_business_data" }),
47
- userAgent: createTextField({ maxLength: 256, personal: false, reason: "is_business_data" }),
43
+ // The job path knows the requester and an IP is personal data, so this is a
44
+ // deliberate waiver, not business data: a user forget does not shred these
45
+ // values, the 90d hardDelete below is what removes them.
46
+ ip: createTextField({
47
+ maxLength: 64,
48
+ personal: false,
49
+ reason: "security_audit_retention_bounded",
50
+ }),
51
+ userAgent: createTextField({
52
+ maxLength: 256,
53
+ personal: false,
54
+ reason: "security_audit_retention_bounded",
55
+ }),
48
56
  attemptedAt: createTimestampField({ required: true }),
49
57
  },
50
58
  // 90d hardDelete: unbounded growth = disk-bomb genau gegen das System
@@ -1,5 +1,4 @@
1
1
  import {
2
- access,
3
2
  createSystemUser,
4
3
  defineWriteHandler,
5
4
  SYSTEM_TENANT_ID,
@@ -36,8 +35,15 @@ export const changeEmailWrite = defineWriteHandler({
36
35
  currentPassword: z.string().min(1),
37
36
  newEmail: z.email(),
38
37
  }),
39
- access: { roles: access.authenticated },
38
+ access: {
39
+ openToAll: {
40
+ reason:
41
+ "self-service for any signed-in user, including tenant members whose only role is " +
42
+ "TenantAdmin; the handler only touches event.user.id and re-checks the caller's password",
43
+ },
44
+ },
40
45
  escapeHatch: {
46
+ grants: ["systemIdentity"],
41
47
  reason:
42
48
  "Reads the caller's own passwordHash via ctx.queryAs(SYSTEM, user:findForAuth), checks " +
43
49
  "email uniqueness the same way, and writes the new email via ctx.writeAs(SYSTEM, " +
@@ -1,4 +1,9 @@
1
1
  [
2
+ {
3
+ "version": "0.353.0",
4
+ "type": "fix",
5
+ "title": "workflow.run-failed carries the real stepIndex when a run suspends in an unsupported step"
6
+ },
2
7
  {
3
8
  "version": "0.340.0",
4
9
  "type": "improvement",
@@ -12,22 +12,87 @@
12
12
  import type {
13
13
  FeatureRegistrar,
14
14
  MultiStreamProjectionDefinition,
15
+ StepInstance,
15
16
  WorkflowDefinition,
16
17
  WriteEvent,
17
18
  } from "@cosmicdrift/kumiko-framework/engine";
18
19
  import {
20
+ buildPipelineSteps,
21
+ createSystemUser,
19
22
  describeWorkflowStepError,
23
+ getStep,
24
+ SYSTEM_TENANT_ID,
25
+ type TenantId,
20
26
  WORKFLOW_AGGREGATE_TYPE,
21
27
  WORKFLOW_RUN_FAILED_TYPE,
28
+ WorkflowStepError,
22
29
  } from "@cosmicdrift/kumiko-framework/engine";
23
30
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
24
31
  import { workflowRunAggregateId } from "./aggregate-id.js";
25
32
  import { registerEventWakeup } from "./event-subscriber.js";
26
- import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner.js";
33
+ import {
34
+ startAndRunWorkflow,
35
+ type WorkflowRunFailedPayload,
36
+ WorkflowSuspensionUnsupportedError,
37
+ } from "./runner.js";
27
38
  import { registerWorkflow } from "./workflow-registry.js";
28
39
 
29
40
  const log = createFallbackLogger("workflow-runner");
30
41
 
42
+ // The MSP apply context only offers unsafeAppendEvent/loadAggregate; these steps reach for
43
+ // ctx.db/write/writeAs and would crash on the first run instead of at registration.
44
+ const STEP_KINDS_NEEDING_HANDLER_CONTEXT: ReadonlySet<string> = new Set([
45
+ "aggregate.create",
46
+ "aggregate.update",
47
+ "callFeature",
48
+ "read.findOne",
49
+ "read.findMany",
50
+ "unsafeProjectionUpsert",
51
+ "unsafeProjectionDelete",
52
+ ]);
53
+
54
+ function* walkStepInstances(steps: readonly StepInstance[]): Generator<StepInstance, void, void> {
55
+ for (const step of steps) {
56
+ yield step;
57
+ const { args } = step;
58
+ if (typeof args !== "object" || args === null) continue;
59
+ for (const path of getStep(step.kind)?.subPaths ?? []) {
60
+ const nested: unknown = path in args ? Reflect.get(args, path) : undefined;
61
+ if (Array.isArray(nested)) yield* walkStepInstances(nested);
62
+ }
63
+ }
64
+ }
65
+
66
+ export function assertEventTriggerStepsSupported(workflow: WorkflowDefinition): void {
67
+ let steps: readonly StepInstance[];
68
+ try {
69
+ // Same contract as the boot validator for handler pipelines: the closure must build its
70
+ // step list from the step builder alone, so an empty event is enough to enumerate kinds.
71
+ steps = buildPipelineSteps(workflow.pipelineDef, {
72
+ type: "workflow-registration-probe",
73
+ payload: {},
74
+ user: createSystemUser(SYSTEM_TENANT_ID as TenantId),
75
+ });
76
+ } catch (error) {
77
+ throw new Error(
78
+ `Workflow "${workflow.name}" pipeline closure threw at registration: ${String(error)}. ` +
79
+ "Build the step list without reading event payload fields; read them inside step resolvers.",
80
+ { cause: error },
81
+ );
82
+ }
83
+ for (const step of walkStepInstances(steps)) {
84
+ if (getStep(step.kind) === undefined) {
85
+ throw new Error(`Workflow "${workflow.name}" uses unknown step kind "${step.kind}"`);
86
+ }
87
+ if (STEP_KINDS_NEEDING_HANDLER_CONTEXT.has(step.kind)) {
88
+ throw new Error(
89
+ `Workflow "${workflow.name}" is event-triggered but uses step "${step.kind}", which needs a ` +
90
+ "handler context (db/write) the event-trigger projection does not provide.",
91
+ );
92
+ }
93
+ }
94
+ }
95
+
31
96
  export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
32
97
  // Populate the workflow-registry unconditionally, before the event-trigger
33
98
  // guard below — resume-run (framework#2513 Phase 2) looks workflows up by
@@ -43,6 +108,8 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
43
108
  // need a scheduler, not an MSP, so there is nothing to register here.
44
109
  if (workflow.trigger.kind !== "event") return;
45
110
 
111
+ assertEventTriggerStepsSupported(workflow);
112
+
46
113
  const eventType = workflow.trigger.eventType;
47
114
 
48
115
  r.multiStreamProjection({
@@ -56,12 +123,6 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
56
123
  // closure does not carry that narrowing, and re-narrowing here keeps
57
124
  // `trigger.filter` below typed without a cast.
58
125
  if (workflow.trigger.kind !== "event") return;
59
- if (workflow.trigger.filter) {
60
- const matches = workflow.trigger.filter(event as never);
61
- // skip: the workflow's own trigger filter rejected this event — not
62
- // this run's concern, so no run is started and nothing is recorded.
63
- if (!matches) return;
64
- }
65
126
 
66
127
  // @cast-boundary msp-to-write-event — the MSP delivers a StoredEvent
67
128
  // (event-store shape); the workflow runner expects a WriteEvent
@@ -69,6 +130,9 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
69
130
  // overlap exactly — the missing `.user` field is acceptable because
70
131
  // workflow triggers run system-level, not user-scoped.
71
132
  const triggerEvent = event as unknown as WriteEvent;
133
+ // skip: the workflow's own trigger filter rejected this event — not this run's
134
+ // concern, so no run is started and nothing is recorded.
135
+ if (workflow.trigger.filter && !workflow.trigger.filter(triggerEvent)) return;
72
136
  let idempotencyKey: string | undefined;
73
137
  if (typeof workflow.idempotencyKey === "function") {
74
138
  idempotencyKey = workflow.idempotencyKey(triggerEvent);
@@ -91,22 +155,24 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
91
155
  version: event.version,
92
156
  },
93
157
  ...(idempotencyKey && { idempotencyKey }),
94
- // @cast-boundary msp-to-handler-ctx — MultiStreamApplyContext only
95
- // exposes unsafeAppendEvent/loadAggregate, a subset of
96
- // HandlerContext. The step vocabulary this runner drives (wait,
97
- // compute, return) only ever calls unsafeAppendEvent on it.
98
- handlerCtx: ctx as never,
158
+ handlerCtx: ctx,
99
159
  });
100
160
  } catch (error) {
161
+ // Failures outside the step loop (run-started append) have no step yet, so 0.
162
+ const stepIndex =
163
+ error instanceof WorkflowStepError ||
164
+ error instanceof WorkflowSuspensionUnsupportedError
165
+ ? error.stepIndex
166
+ : 0;
101
167
  log.warn("workflow run failed", {
102
168
  runId,
103
169
  workflowName: workflow.name,
104
- stepIndex: 0,
170
+ stepIndex,
105
171
  error: String(error),
106
172
  });
107
173
  const failedPayload: WorkflowRunFailedPayload = {
108
174
  workflowName: workflow.name,
109
- stepIndex: 0,
175
+ stepIndex,
110
176
  error: describeWorkflowStepError(error),
111
177
  };
112
178
  await ctx.unsafeAppendEvent({