@cosmicdrift/kumiko-bundled-features 0.326.1 → 0.327.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.326.1",
3
+ "version": "0.327.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>",
@@ -134,12 +134,12 @@
134
134
  "./workflow-runner": "./src/workflow-runner/index.ts"
135
135
  },
136
136
  "dependencies": {
137
- "@cosmicdrift/kumiko-dispatcher-live": "0.326.1",
138
- "@cosmicdrift/kumiko-framework": "0.326.1",
139
- "@cosmicdrift/kumiko-headless": "0.326.1",
140
- "@cosmicdrift/kumiko-renderer": "0.326.1",
141
- "@cosmicdrift/kumiko-renderer-web": "0.326.1",
142
- "@cosmicdrift/kumiko-types": "0.326.1",
137
+ "@cosmicdrift/kumiko-dispatcher-live": "0.327.0",
138
+ "@cosmicdrift/kumiko-framework": "0.327.0",
139
+ "@cosmicdrift/kumiko-headless": "0.327.0",
140
+ "@cosmicdrift/kumiko-renderer": "0.327.0",
141
+ "@cosmicdrift/kumiko-renderer-web": "0.327.0",
142
+ "@cosmicdrift/kumiko-types": "0.327.0",
143
143
  "@mollie/api-client": "^4.5.0",
144
144
  "@node-rs/argon2": "^2.0.2",
145
145
  "@types/mailparser": "^3.4.6",
@@ -167,8 +167,8 @@
167
167
  ],
168
168
  "devDependencies": {
169
169
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.326.1",
171
- "@cosmicdrift/kumiko-locale-es": "0.326.1",
170
+ "@cosmicdrift/kumiko-locale-de": "0.327.0",
171
+ "@cosmicdrift/kumiko-locale-es": "0.327.0",
172
172
  "jsqr": "^1.4.0"
173
173
  }
174
174
  }
@@ -0,0 +1,268 @@
1
+ // step-dispatcher crypto-shredding (fw#2057): the dispatch-requested payload
2
+ // carries recipient/subject/body/url/headers as ciphertext under a
3
+ // per-dispatch record key; the dispatcher decrypts to send, then erases the
4
+ // key once the outcome is recorded — redelivery after that must be a no-op.
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
7
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import {
9
+ configurePiiSubjectKms,
10
+ decryptPiiValueForSubject,
11
+ InMemoryKmsAdapter,
12
+ isPiiCiphertext,
13
+ KeyErasedError,
14
+ PII_CIPHERTEXT_PREFIX,
15
+ PII_ERASED_SENTINEL,
16
+ } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
18
+ import {
19
+ defineFeature,
20
+ defineWriteHandler,
21
+ stepsPipeline,
22
+ } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
24
+ import {
25
+ createTestUser,
26
+ resetEventStore,
27
+ setupTestStack,
28
+ type TestStack,
29
+ } from "@cosmicdrift/kumiko-framework/stack";
30
+ import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
31
+ import * as z from "zod";
32
+ import { createSecretsFeature } from "../../secrets";
33
+ import {
34
+ createStepDispatcherFeature,
35
+ type MailDispatchResult,
36
+ type MailSpec,
37
+ setMailRunner,
38
+ setWebhookFetch,
39
+ WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
40
+ } from "../index";
41
+
42
+ const DISPATCH_REQUESTED = "kumiko:system:step.dispatch-requested";
43
+ const DISPATCHED = "kumiko:system:step.dispatched";
44
+ const DISPATCH_FAILED = "kumiko:system:step.dispatch-failed";
45
+
46
+ const probeFeature = defineFeature("step-pii-probe", (r) => {
47
+ r.requires.step("mail.send");
48
+ r.requires.step("webhook.send");
49
+
50
+ r.writeHandler(
51
+ defineWriteHandler({
52
+ name: "notify-mail",
53
+ schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
54
+ access: { roles: ["Admin"] },
55
+ perform: stepsPipeline<{ to: string; subject: string; body: string }, { ok: true }>(
56
+ ({ event, r }) => [
57
+ r.step.mail.send({
58
+ to: () => event.payload.to,
59
+ subject: () => event.payload.subject,
60
+ body: () => event.payload.body,
61
+ mode: "deferred",
62
+ }),
63
+ r.step.return(() => ({ isSuccess: true as const, data: { ok: true as const } })),
64
+ ],
65
+ ),
66
+ }),
67
+ );
68
+
69
+ r.writeHandler(
70
+ defineWriteHandler({
71
+ name: "notify-webhook",
72
+ schema: z.object({ url: z.string(), token: z.string() }),
73
+ access: { roles: ["Admin"] },
74
+ perform: stepsPipeline<{ url: string; token: string }, { ok: true }>(({ event, r }) => [
75
+ r.step.webhook.send({
76
+ url: () => event.payload.url,
77
+ headers: () => ({ "x-probe-token": event.payload.token }),
78
+ body: () => ({ secretNote: "webhook-body-secret" }),
79
+ mode: "deferred",
80
+ }),
81
+ r.step.return(() => ({ isSuccess: true as const, data: { ok: true as const } })),
82
+ ]),
83
+ }),
84
+ );
85
+ });
86
+
87
+ const admin = createTestUser({ roles: ["Admin"] });
88
+ const fetchMock = mock<typeof fetch>();
89
+ const mailMock = mock<(spec: MailSpec) => Promise<MailDispatchResult>>();
90
+ const originalAllowedPrivateHostsEnv = process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
91
+
92
+ let stack: TestStack;
93
+ let kms: InMemoryKmsAdapter;
94
+
95
+ type EventRow = { aggregateId: string; payload: Record<string, unknown> };
96
+
97
+ async function eventsOfType(type: string): Promise<EventRow[]> {
98
+ return (await selectMany(stack.db, eventsTable, { type })) as unknown as EventRow[];
99
+ }
100
+
101
+ async function drain(): Promise<void> {
102
+ await stack.eventDispatcher?.runOnce();
103
+ }
104
+
105
+ async function requestedRow(): Promise<EventRow> {
106
+ const rows = await eventsOfType(DISPATCH_REQUESTED);
107
+ expect(rows).toHaveLength(1);
108
+ return rows[0]!;
109
+ }
110
+
111
+ async function expectKeyErased(aggregateId: string): Promise<void> {
112
+ await expect(
113
+ kms.getKey({ kind: "record", entity: "step-dispatch", id: aggregateId }),
114
+ ).rejects.toBeInstanceOf(KeyErasedError);
115
+ }
116
+
117
+ beforeAll(async () => {
118
+ // hooks.example is a placeholder host — operator allowlist instead of DNS.
119
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
120
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
121
+ setMailRunner(async (spec: MailSpec) => mailMock(spec));
122
+ stack = await setupTestStack({
123
+ features: [createStepDispatcherFeature(), createSecretsFeature(), probeFeature],
124
+ systemHooks: [],
125
+ });
126
+ });
127
+
128
+ afterAll(async () => {
129
+ if (originalAllowedPrivateHostsEnv === undefined) {
130
+ delete process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
131
+ } else {
132
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = originalAllowedPrivateHostsEnv;
133
+ }
134
+ await stack.cleanup();
135
+ });
136
+
137
+ beforeEach(async () => {
138
+ fetchMock.mockReset();
139
+ mailMock.mockReset();
140
+ mailMock.mockResolvedValue({ ok: true, status: 202 });
141
+ kms = new InMemoryKmsAdapter();
142
+ configurePiiSubjectKms(kms);
143
+ await resetEventStore(stack, []);
144
+ await stack.redis.flushNamespace();
145
+ await stack.eventDispatcher?.ensureRegistered();
146
+ });
147
+
148
+ afterEach(() => {
149
+ resetPiiSubjectKmsForTests();
150
+ });
151
+
152
+ const MAIL_INPUT = { to: "ops@example.com", subject: "Server down", body: "Disk is full" };
153
+
154
+ describe("step-dispatcher payload crypto-shredding", () => {
155
+ test("mail.send: ciphertext at rest, plaintext to the runner, key erased after dispatched", async () => {
156
+ await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
157
+
158
+ const requested = await requestedRow();
159
+ const keyPrefix = `${PII_CIPHERTEXT_PREFIX}record:step-dispatch:${requested.aggregateId}:`;
160
+ for (const field of ["to", "subject", "body"]) {
161
+ const stored = String(requested.payload[field]);
162
+ expect(stored.startsWith(keyPrefix)).toBe(true);
163
+ }
164
+ expect(JSON.stringify(requested.payload)).not.toContain("ops@example.com");
165
+ expect(JSON.stringify(requested.payload)).not.toContain("Disk is full");
166
+
167
+ await drain();
168
+
169
+ expect(mailMock).toHaveBeenCalledTimes(1);
170
+ expect(mailMock).toHaveBeenCalledWith({
171
+ to: "ops@example.com",
172
+ subject: "Server down",
173
+ body: "Disk is full",
174
+ });
175
+ expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
176
+ await expectKeyErased(requested.aggregateId);
177
+ const afterErase = await decryptPiiValueForSubject(
178
+ kms,
179
+ String(requested.payload["to"]),
180
+ { requestId: "test" },
181
+ "to",
182
+ );
183
+ expect(afterErase).toBe(PII_ERASED_SENTINEL);
184
+ });
185
+
186
+ test("failed delivery erases the key too", async () => {
187
+ mailMock.mockResolvedValue({ ok: false, error: "550 <ops@example.com> rejected" });
188
+ await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
189
+ const requested = await requestedRow();
190
+
191
+ await drain();
192
+
193
+ const failed = await eventsOfType(DISPATCH_FAILED);
194
+ expect(failed).toHaveLength(1);
195
+ expect(failed[0]?.payload["error"]).toBe("mail delivery failed");
196
+ expect(JSON.stringify(failed[0]?.payload)).not.toContain("ops@example.com");
197
+ await expectKeyErased(requested.aggregateId);
198
+ });
199
+
200
+ test("redelivery after the erase neither re-sends nor throws nor records a second outcome", async () => {
201
+ await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
202
+ await drain();
203
+ expect(mailMock).toHaveBeenCalledTimes(1);
204
+
205
+ await asRawClient(stack.db).unsafe(
206
+ `UPDATE kumiko_event_consumers SET last_processed_event_id = 0 WHERE name LIKE '%step-dispatcher%'`,
207
+ );
208
+ await drain();
209
+
210
+ expect(mailMock).toHaveBeenCalledTimes(1);
211
+ expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
212
+ expect(await eventsOfType(DISPATCH_FAILED)).toHaveLength(0);
213
+ });
214
+
215
+ test("a key erased before any outcome ends as a generic dispatch-failed, without sending", async () => {
216
+ await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
217
+ const requested = await requestedRow();
218
+ await kms.eraseKey({ kind: "record", entity: "step-dispatch", id: requested.aggregateId });
219
+
220
+ await drain();
221
+
222
+ expect(mailMock).not.toHaveBeenCalled();
223
+ const failed = await eventsOfType(DISPATCH_FAILED);
224
+ expect(failed).toHaveLength(1);
225
+ expect(failed[0]?.payload["error"]).toBe(
226
+ "dispatch payload erased before an outcome was recorded",
227
+ );
228
+ });
229
+
230
+ test("ciphertext without a configured KMS ends as generic dispatch-failed", async () => {
231
+ await stack.http.writeOk("step-pii-probe:write:notify-mail", MAIL_INPUT, admin);
232
+ resetPiiSubjectKmsForTests();
233
+
234
+ await drain();
235
+
236
+ expect(mailMock).not.toHaveBeenCalled();
237
+ const failed = await eventsOfType(DISPATCH_FAILED);
238
+ expect(failed).toHaveLength(1);
239
+ expect(JSON.stringify(failed[0]?.payload)).not.toContain("ops@example.com");
240
+ });
241
+
242
+ test("webhook.send: url, headers and body are ciphertext at rest and plaintext to fetch", async () => {
243
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
244
+ await stack.http.writeOk(
245
+ "step-pii-probe:write:notify-webhook",
246
+ { url: "https://hooks.example/secret-path", token: "probe-token" },
247
+ admin,
248
+ );
249
+
250
+ const requested = await requestedRow();
251
+ for (const field of ["url", "headersJson", "bodyJson"]) {
252
+ expect(isPiiCiphertext(requested.payload[field])).toBe(true);
253
+ }
254
+ expect(JSON.stringify(requested.payload)).not.toContain("secret-path");
255
+ expect(JSON.stringify(requested.payload)).not.toContain("probe-token");
256
+ expect(JSON.stringify(requested.payload)).not.toContain("webhook-body-secret");
257
+
258
+ await drain();
259
+
260
+ expect(fetchMock).toHaveBeenCalledTimes(1);
261
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
262
+ expect(calledUrl).toBe("https://hooks.example/secret-path");
263
+ expect(new Headers(init?.headers).get("x-probe-token")).toBe("probe-token");
264
+ expect(JSON.parse(init?.body as string)).toEqual({ secretNote: "webhook-body-secret" });
265
+ expect(await eventsOfType(DISPATCHED)).toHaveLength(1);
266
+ await expectKeyErased(requested.aggregateId);
267
+ });
268
+ });
@@ -276,8 +276,22 @@ describe("performWebhookDispatch — auth.secret resolution", () => {
276
276
  { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
277
277
  );
278
278
 
279
- expect(result.ok).toBe(false);
279
+ expect(result).toEqual({ ok: false, error: "invalid url" });
280
280
  expect(get).not.toHaveBeenCalled();
281
281
  expect(fetchMock).not.toHaveBeenCalled();
282
282
  });
283
+
284
+ test("a failing request never echoes the request url into the delivery error", async () => {
285
+ setWebhookHostLookup(fakeLookupFor({ "secret-host.example": "203.0.113.9" }));
286
+ setWebhookFetch((async () => {
287
+ throw new TypeError("connect ECONNREFUSED https://secret-host.example/hook?token=abc");
288
+ }) as unknown as typeof fetch);
289
+
290
+ const result = await performWebhookDispatch(
291
+ { url: "https://secret-host.example/hook?token=abc", method: "POST", headers: {} },
292
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets: undefined },
293
+ );
294
+
295
+ expect(result).toEqual({ ok: false, error: "webhook request failed (TypeError)" });
296
+ });
283
297
  });
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.327.0",
4
+ "type": "breaking",
5
+ "title": "step.dispatch-requested payloads are flat and crypto-shredded; kumiko:system:* events have a declared PII stance",
6
+ "migration": "No code change is needed for r.step.mail.send / r.step.webhook.send callers. The step.dispatch-requested payload changed from a nested `spec` object to flat fields (mail.send: to as JSON string, subject, body, from; webhook.send: url, method, headersJson, bodyJson, auth, retry). With a subject KMS configured, those fields are encrypted under a per-dispatch record key (record:step-dispatch:<aggregateId>) that the step-dispatcher erases once the outcome is recorded (Art. 17 via crypto-shredding). There is no legacy branch: a dispatch-requested event still in flight at upgrade (old nested shape) ends as step.dispatch-failed with error \"invalid dispatch payload\", so drain the queue before deploying or accept the one-time failed event. Webhook delivery errors no longer echo the URL or the raw request error. mail.send delivery errors are recorded as a generic \"mail delivery failed\"; the adapter's raw error goes to the step-dispatcher log. Code reading dispatch-requested payloads directly must switch to the flat shape. A new kumiko:system:* event type must be added to SYSTEM_EVENT_PII_STANCES (crypto/system-event-pii.ts); encryptEventPayloadPii throws for an undeclared system type. SYSTEM_EVENT_PREFIX and AGGREGATE_TRANSFERRED_EVENT_TYPE now live in crypto/system-event-pii.ts, and the STEP_DISPATCH_* constants are exported from @cosmicdrift/kumiko-framework/engine instead of engine/steps/webhook-send."
7
+ },
2
8
  {
3
9
  "version": "0.321.0",
4
10
  "type": "breaking",
@@ -2,21 +2,39 @@
2
2
  // requests (webhook.send, mail.send, ...) after their TX commits.
3
3
  //
4
4
  // Listens on the `kumiko:system:step.dispatch-requested` system event
5
- // (registry-bypassed, see append-event-core.ts SYSTEM_EVENT_PREFIX).
5
+ // (registry-bypassed, see SYSTEM_EVENT_PREFIX).
6
6
  // Performs the side-effect and emits `kumiko:system:step.dispatched`
7
7
  // or `kumiko:system:step.dispatch-failed` back onto the same stream so
8
8
  // the audit trail lives in the event log only — no separate status table.
9
9
 
10
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
10
+ import { requestContext } from "@cosmicdrift/kumiko-framework/api";
11
+ import {
12
+ configuredPiiSubjectKms,
13
+ decryptPiiValueForSubject,
14
+ isPiiCiphertext,
15
+ PII_ERASED_SENTINEL,
16
+ } from "@cosmicdrift/kumiko-framework/crypto";
17
+ import {
18
+ defineFeature,
19
+ type FeatureDefinition,
20
+ STEP_DISPATCH_AGGREGATE_TYPE,
21
+ STEP_DISPATCH_FAILED_TYPE,
22
+ STEP_DISPATCH_REQUESTED_TYPE,
23
+ STEP_DISPATCHED_TYPE,
24
+ } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
11
26
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
12
27
  import * as z from "zod";
13
- import { mailSpecSchema, performMailDispatch } from "./mail-runner";
28
+ import { type MailSpec, mailSpecSchema, performMailDispatch } from "./mail-runner";
14
29
  import {
15
30
  performWebhookDispatch,
16
31
  WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
32
+ type WebhookSpec,
17
33
  webhookSpecSchema,
18
34
  } from "./webhook-runner";
19
35
 
36
+ const log = createFallbackLogger("step-dispatcher");
37
+
20
38
  export const stepDispatcherEnvSchema = z.object({
21
39
  [WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
22
40
  .string()
@@ -26,26 +44,43 @@ export const stepDispatcherEnvSchema = z.object({
26
44
  ),
27
45
  });
28
46
 
29
- export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
30
- export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
31
- export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
32
- export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
47
+ export { STEP_DISPATCH_AGGREGATE_TYPE };
33
48
 
49
+ // PII fields of the flat payload are ciphertext under the per-dispatch
50
+ // record key (system-event-pii.ts). `to`/`headersJson`/`bodyJson` are JSON
51
+ // strings because event PII encryption only handles top-level strings.
34
52
  // Runtime-validated instead of cast — `event.payload` is `unknown` at the
35
- // MSP-apply boundary (unsafeAppendEvent), so a payload in an older or
36
- // foreign shape must end as dispatch-failed, never reach performWebhookDispatch.
53
+ // MSP-apply boundary, so a payload in another shape must end as
54
+ // dispatch-failed, never reach the runners.
37
55
  const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
38
56
  z.object({
39
57
  stepKind: z.literal("webhook.send"),
40
- spec: webhookSpecSchema,
58
+ url: z.string(),
59
+ method: webhookSpecSchema.shape.method,
60
+ headersJson: z.string(),
61
+ bodyJson: z.string().optional(),
62
+ auth: webhookSpecSchema.shape.auth,
41
63
  retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
42
64
  }),
43
- z.object({ stepKind: z.literal("mail.send"), spec: mailSpecSchema }),
65
+ z.object({
66
+ stepKind: z.literal("mail.send"),
67
+ to: z.string(),
68
+ subject: z.string(),
69
+ body: z.string(),
70
+ from: z.string().optional(),
71
+ }),
44
72
  ]);
45
73
 
74
+ type DispatchRequestedPayload = z.infer<typeof dispatchRequestedPayloadSchema>;
75
+
46
76
  // zod issue messages can echo the invalid value (e.g. a rejected url) back
47
77
  // into the tenant-visible dispatch-failed event — keep this generic.
48
78
  const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
79
+ const PAYLOAD_UNREADABLE_ERROR = "dispatch payload is not readable";
80
+ // Adapter errors (e.g. SMTP 550) usually echo the recipient address; the
81
+ // dispatch-failed text outlives the payload erase, so only this is persisted.
82
+ const MAIL_DELIVERY_FAILED_ERROR = "mail delivery failed";
83
+ const PAYLOAD_ERASED_ERROR = "dispatch payload erased before an outcome was recorded";
49
84
 
50
85
  const rawStepKindSchema = z.object({ stepKind: z.string() });
51
86
 
@@ -54,6 +89,105 @@ function rawStepKindOf(payload: unknown): string {
54
89
  return parsed.success ? parsed.data.stepKind : "unknown";
55
90
  }
56
91
 
92
+ const jsonStringSchema = z.string().transform((raw, refinementCtx) => {
93
+ try {
94
+ const parsed: unknown = JSON.parse(raw);
95
+ return parsed;
96
+ } catch {
97
+ refinementCtx.addIssue({ code: "custom", message: "invalid json" });
98
+ return z.NEVER;
99
+ }
100
+ });
101
+
102
+ const headersJsonSchema = jsonStringSchema.pipe(z.record(z.string(), z.string()));
103
+ const mailToJsonSchema = jsonStringSchema.pipe(mailSpecSchema.shape.to);
104
+
105
+ type PayloadFieldName = "to" | "subject" | "body" | "from" | "url" | "headersJson" | "bodyJson";
106
+
107
+ function piiFieldsOf(payload: DispatchRequestedPayload): readonly PayloadFieldName[] {
108
+ return payload.stepKind === "mail.send"
109
+ ? ["to", "subject", "body", "from"]
110
+ : ["url", "headersJson", "bodyJson"];
111
+ }
112
+
113
+ function payloadFieldValue(
114
+ payload: DispatchRequestedPayload,
115
+ field: PayloadFieldName,
116
+ ): string | undefined {
117
+ const values: Readonly<Partial<Record<PayloadFieldName, string>>> =
118
+ payload.stepKind === "mail.send"
119
+ ? { to: payload.to, subject: payload.subject, body: payload.body, from: payload.from }
120
+ : { url: payload.url, headersJson: payload.headersJson, bodyJson: payload.bodyJson };
121
+ return values[field];
122
+ }
123
+
124
+ type ReadPayloadResult =
125
+ | { readonly kind: "ready"; readonly fields: Readonly<Partial<Record<PayloadFieldName, string>>> }
126
+ | { readonly kind: "erased" }
127
+ | { readonly kind: "unreadable" };
128
+
129
+ async function readPayloadFields(payload: DispatchRequestedPayload): Promise<ReadPayloadResult> {
130
+ const kms = configuredPiiSubjectKms();
131
+ const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
132
+ const fields: Partial<Record<PayloadFieldName, string>> = {};
133
+ for (const field of piiFieldsOf(payload)) {
134
+ const value = payloadFieldValue(payload, field);
135
+ if (value === undefined) continue;
136
+ if (!isPiiCiphertext(value)) {
137
+ fields[field] = value;
138
+ continue;
139
+ }
140
+ if (!kms) return { kind: "unreadable" };
141
+ const plain = await decryptPiiValueForSubject(kms, value, { requestId }, field);
142
+ if (plain === PII_ERASED_SENTINEL) return { kind: "erased" };
143
+ fields[field] = plain;
144
+ }
145
+ return { kind: "ready", fields };
146
+ }
147
+
148
+ type DispatchSpec =
149
+ | { readonly stepKind: "mail.send"; readonly spec: MailSpec }
150
+ | { readonly stepKind: "webhook.send"; readonly spec: WebhookSpec };
151
+
152
+ // Parse failures return null — the caller records a generic error, never the
153
+ // (decrypted) values.
154
+ function buildDispatchSpec(
155
+ payload: DispatchRequestedPayload,
156
+ fields: Readonly<Partial<Record<PayloadFieldName, string>>>,
157
+ ): DispatchSpec | null {
158
+ if (payload.stepKind === "mail.send") {
159
+ const to = mailToJsonSchema.safeParse(fields.to);
160
+ if (!to.success || fields.subject === undefined || fields.body === undefined) return null;
161
+ return {
162
+ stepKind: "mail.send",
163
+ spec: {
164
+ to: to.data,
165
+ subject: fields.subject,
166
+ body: fields.body,
167
+ ...(fields.from !== undefined && { from: fields.from }),
168
+ },
169
+ };
170
+ }
171
+ const headers = headersJsonSchema.safeParse(fields.headersJson);
172
+ if (!headers.success || fields.url === undefined) return null;
173
+ let body: unknown;
174
+ if (fields.bodyJson !== undefined) {
175
+ const parsedBody = jsonStringSchema.safeParse(fields.bodyJson);
176
+ if (!parsedBody.success) return null;
177
+ body = parsedBody.data;
178
+ }
179
+ return {
180
+ stepKind: "webhook.send",
181
+ spec: {
182
+ url: fields.url,
183
+ method: payload.method,
184
+ headers: headers.data,
185
+ ...(body !== undefined && { body }),
186
+ ...(payload.auth && { auth: payload.auth }),
187
+ },
188
+ };
189
+ }
190
+
57
191
  export function createStepDispatcherFeature(): FeatureDefinition {
58
192
  return defineFeature("step-dispatcher", (r) => {
59
193
  r.describe(
@@ -71,44 +205,85 @@ export function createStepDispatcherFeature(): FeatureDefinition {
71
205
  name: "step-dispatcher",
72
206
  apply: {
73
207
  [STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
74
- const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
75
- if (!parsed.success) {
208
+ const kms = configuredPiiSubjectKms();
209
+ const requestId = requestContext.get()?.requestId ?? "step-dispatcher";
210
+
211
+ // Outcome events are plaintext and generic; the request payload's
212
+ // per-dispatch key is erased right after, so the PII dies with the
213
+ // dispatch instead of living in the event log.
214
+ const recordOutcome = async (
215
+ type: typeof STEP_DISPATCHED_TYPE | typeof STEP_DISPATCH_FAILED_TYPE,
216
+ payload: Record<string, unknown>,
217
+ ): Promise<void> => {
76
218
  await ctx.unsafeAppendEvent({
77
219
  aggregateId: event.aggregateId,
78
220
  aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
79
- type: STEP_DISPATCH_FAILED_TYPE,
80
- payload: {
81
- stepKind: rawStepKindOf(event.payload),
82
- error: INVALID_DISPATCH_PAYLOAD_ERROR,
83
- attempt: 1,
84
- },
221
+ type,
222
+ payload,
85
223
  });
224
+ await kms?.eraseKey(
225
+ { kind: "record", entity: STEP_DISPATCH_AGGREGATE_TYPE, id: event.aggregateId },
226
+ { requestId, eraseReason: "step-dispatch-outcome-recorded" },
227
+ );
228
+ };
229
+ const recordFailure = (stepKind: string, error: string) =>
230
+ recordOutcome(STEP_DISPATCH_FAILED_TYPE, { stepKind, error, attempt: 1 });
231
+
232
+ const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
233
+ if (!parsed.success) {
234
+ await recordFailure(rawStepKindOf(event.payload), INVALID_DISPATCH_PAYLOAD_ERROR);
86
235
  // skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
87
236
  return;
88
237
  }
89
238
  const payload = parsed.data;
239
+
240
+ const read = await readPayloadFields(payload);
241
+ if (read.kind === "unreadable") {
242
+ await recordFailure(payload.stepKind, PAYLOAD_UNREADABLE_ERROR);
243
+ // skip: unreadable payload already recorded via step.dispatch-failed above
244
+ return;
245
+ }
246
+ if (read.kind === "erased") {
247
+ const stream = await ctx.loadAggregate(event.aggregateId);
248
+ const hasOutcome = stream.some(
249
+ (e) => e.type === STEP_DISPATCHED_TYPE || e.type === STEP_DISPATCH_FAILED_TYPE,
250
+ );
251
+ // skip: redelivery after the key was erased — the outcome is already recorded
252
+ if (hasOutcome) return;
253
+ await recordFailure(payload.stepKind, PAYLOAD_ERASED_ERROR);
254
+ // skip: erased payload recorded via step.dispatch-failed above
255
+ return;
256
+ }
257
+
258
+ const dispatchSpec = buildDispatchSpec(payload, read.fields);
259
+ if (!dispatchSpec) {
260
+ await recordFailure(payload.stepKind, INVALID_DISPATCH_PAYLOAD_ERROR);
261
+ // skip: unparseable payload already recorded via step.dispatch-failed above
262
+ return;
263
+ }
90
264
  const result =
91
- payload.stepKind === "webhook.send"
92
- ? await performWebhookDispatch(payload.spec, {
265
+ dispatchSpec.stepKind === "webhook.send"
266
+ ? await performWebhookDispatch(dispatchSpec.spec, {
93
267
  tenantId: event.tenantId,
94
268
  userId: event.metadata.userId || SYSTEM_USER_ID,
95
269
  secrets: ctx.secrets,
96
270
  })
97
- : await performMailDispatch(payload.spec);
271
+ : await performMailDispatch(dispatchSpec.spec);
98
272
  if (result.ok) {
99
- await ctx.unsafeAppendEvent({
100
- aggregateId: event.aggregateId,
101
- aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
102
- type: STEP_DISPATCHED_TYPE,
103
- payload: { stepKind: payload.stepKind, status: result.status },
273
+ await recordOutcome(STEP_DISPATCHED_TYPE, {
274
+ stepKind: payload.stepKind,
275
+ status: result.status,
104
276
  });
105
277
  } else {
106
- await ctx.unsafeAppendEvent({
107
- aggregateId: event.aggregateId,
108
- aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
109
- type: STEP_DISPATCH_FAILED_TYPE,
110
- payload: { stepKind: payload.stepKind, error: result.error, attempt: 1 },
111
- });
278
+ if (payload.stepKind === "mail.send") {
279
+ log.warn("mail dispatch failed", {
280
+ aggregateId: event.aggregateId,
281
+ reason: result.error,
282
+ });
283
+ await recordFailure(payload.stepKind, MAIL_DELIVERY_FAILED_ERROR);
284
+ } else {
285
+ await recordFailure(payload.stepKind, result.error);
286
+ }
112
287
  }
113
288
  },
114
289
  },
@@ -179,7 +179,7 @@ export async function performWebhookDispatch(
179
179
  try {
180
180
  url = new URL(spec.url);
181
181
  } catch {
182
- return { ok: false, error: `invalid url "${spec.url}"` };
182
+ return { ok: false, error: "invalid url" };
183
183
  }
184
184
  if (url.protocol !== "http:" && url.protocol !== "https:") {
185
185
  return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
@@ -203,6 +203,10 @@ export async function performWebhookDispatch(
203
203
  }
204
204
  return { ok: true, status: res.status };
205
205
  } catch (err) {
206
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
206
+ // err.message can echo the request URL; this text outlives the payload erase.
207
+ return {
208
+ ok: false,
209
+ error: `webhook request failed (${err instanceof Error ? err.name : "unknown error"})`,
210
+ };
207
211
  }
208
212
  }
@@ -79,7 +79,7 @@ const retryWorkflow: WorkflowDefinition = defineWorkflow({
79
79
  do: [
80
80
  r.step.compute("gate", (ctx) => {
81
81
  const attempt = ctx.workflow?.retryAttempt ?? 1;
82
- if (attempt === 1) throw new Error("first-attempt-fails");
82
+ if (attempt === 1) throw new Error("send to jane.doe@example.com failed");
83
83
  return attempt;
84
84
  }),
85
85
  ],
@@ -289,6 +289,9 @@ describe("workflow-runner resume loop", () => {
289
289
  WORKFLOW_RETRY_SCHEDULED_TYPE,
290
290
  ]);
291
291
  expect(rowsAfterSuspend[1]!["payload"]).toMatchObject({ stepIndex: 0, attempt: 1 });
292
+ // The event outlives an erase, so it carries the error class only.
293
+ expect(rowsAfterSuspend[1]!["payload"]["error"]).toBe("workflow step failed (Error)");
294
+ expect(JSON.stringify(rowsAfterSuspend[1]!["payload"])).not.toContain("jane.doe@example.com");
292
295
 
293
296
  await tamperWakeAtToPast(runId, 0);
294
297
  await runResumeDueRunsJob();
@@ -52,7 +52,7 @@ const failingWorkflow: WorkflowDefinition = defineWorkflow({
52
52
  idempotencyKey: ({ payload }) => (payload as { runKey: string }).runKey,
53
53
  steps: stepsPipeline(({ r }) => [
54
54
  r.step.compute("boom", () => {
55
- throw new Error("boom-explicit-failure");
55
+ throw new Error("boom-explicit-failure for jane.doe@example.com");
56
56
  }),
57
57
  r.step.return({ isSuccess: true, data: undefined }),
58
58
  ]),
@@ -186,7 +186,7 @@ describe("workflow-runner event-trigger", () => {
186
186
  });
187
187
  });
188
188
 
189
- test("error path: a throwing step writes run-failed with the error text, no run-completed", async () => {
189
+ test("error path: a throwing step writes run-failed with a generic error text, no run-completed", async () => {
190
190
  const runKey = crypto.randomUUID();
191
191
  const runId = workflowRunAggregateId(failingWorkflow.name, runKey);
192
192
  // The registrar namespaces every MSP as `<feature>:projection:<name>`.
@@ -223,7 +223,10 @@ describe("workflow-runner event-trigger", () => {
223
223
  WORKFLOW_RUN_FAILED_TYPE,
224
224
  ]);
225
225
  expect(rows[1]!["payload"]).toMatchObject({ workflowName: failingWorkflow.name, stepIndex: 0 });
226
- expect(String(rows[1]!["payload"]["error"])).toContain("boom-explicit-failure");
226
+ // The event outlives an erase, so it carries the error class only.
227
+ expect(rows[1]!["payload"]["error"]).toBe("workflow step failed (Error)");
228
+ expect(JSON.stringify(rows[1]!["payload"])).not.toContain("boom-explicit-failure");
229
+ expect(JSON.stringify(rows[1]!["payload"])).not.toContain("jane.doe@example.com");
227
230
 
228
231
  // Second pass has nothing left to redeliver — the same trigger event
229
232
  // never spawns a second run-started for this runId. `processed` counts
@@ -1 +1,8 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.327.0",
4
+ "type": "breaking",
5
+ "title": "Workflow run-failed and retry.scheduled events store a generic error text instead of the raw error",
6
+ "migration": "The `error` field of workflow.run-failed and workflow.retry.scheduled events is now generic, \"workflow step failed (<error class>)\" (or \"(unknown error)\" for non-Error throwables), because the raw text can contain recipients or payload fragments and would survive an erase. The raw error goes to the log (namespace workflow-runner, and the framework logger for retry). Code that parses the `error` text must switch to `reason` (machine-readable, set for definition changes) or to the logs. Already stored events keep their old text. The texts for an unregistered workflow and a definition-fingerprint mismatch are unchanged. describeWorkflowStepError is exported from @cosmicdrift/kumiko-framework/engine."
7
+ }
8
+ ]
@@ -16,14 +16,18 @@ import type {
16
16
  WriteEvent,
17
17
  } from "@cosmicdrift/kumiko-framework/engine";
18
18
  import {
19
+ describeWorkflowStepError,
19
20
  WORKFLOW_AGGREGATE_TYPE,
20
21
  WORKFLOW_RUN_FAILED_TYPE,
21
22
  } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
22
24
  import { workflowRunAggregateId } from "./aggregate-id";
23
25
  import { registerEventWakeup } from "./event-subscriber";
24
26
  import { startAndRunWorkflow, type WorkflowRunFailedPayload } from "./runner";
25
27
  import { registerWorkflow } from "./workflow-registry";
26
28
 
29
+ const log = createFallbackLogger("workflow-runner");
30
+
27
31
  export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefinition): void {
28
32
  // Populate the workflow-registry unconditionally, before the event-trigger
29
33
  // guard below — resume-run (framework#2513 Phase 2) looks workflows up by
@@ -89,10 +93,16 @@ export function registerEventTrigger(r: FeatureRegistrar, workflow: WorkflowDefi
89
93
  handlerCtx: ctx as never,
90
94
  });
91
95
  } catch (error) {
92
- const failedPayload: WorkflowRunFailedPayload = {
96
+ log.warn("workflow run failed", {
97
+ runId,
93
98
  workflowName: workflow.name,
94
99
  stepIndex: 0,
95
100
  error: String(error),
101
+ });
102
+ const failedPayload: WorkflowRunFailedPayload = {
103
+ workflowName: workflow.name,
104
+ stepIndex: 0,
105
+ error: describeWorkflowStepError(error),
96
106
  };
97
107
  await ctx.unsafeAppendEvent({
98
108
  aggregateId: runId,
@@ -31,6 +31,7 @@ import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
31
31
  import {
32
32
  buildPipelineSteps,
33
33
  computeDefinitionFingerprint,
34
+ describeWorkflowStepError,
34
35
  getStep,
35
36
  type HandlerContext,
36
37
  runStepList,
@@ -48,6 +49,7 @@ import {
48
49
  type WriteHandlerDef,
49
50
  } from "@cosmicdrift/kumiko-framework/engine";
50
51
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
52
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
51
53
  import * as z from "zod";
52
54
  import {
53
55
  isResumableSuspension,
@@ -59,6 +61,8 @@ import {
59
61
  import { workflowRunPendingTable } from "../tables";
60
62
  import { getWorkflow } from "../workflow-registry";
61
63
 
64
+ const log = createFallbackLogger("workflow-runner");
65
+
62
66
  const resumeRunSchema = z.object({
63
67
  runId: z.string().min(1),
64
68
  stepIndex: z.number().int().nonnegative(),
@@ -338,10 +342,11 @@ export const resumeRunHandler: WriteHandlerDef = {
338
342
  });
339
343
  return { isSuccess: true, data: { outcome: "completed" as const } };
340
344
  } catch (error) {
345
+ log.warn("workflow run failed", { runId, workflowName, stepIndex, error: String(error) });
341
346
  const failedPayload: WorkflowRunFailedPayload = {
342
347
  workflowName,
343
348
  stepIndex,
344
- error: String(error),
349
+ error: describeWorkflowStepError(error),
345
350
  };
346
351
  await appendRunFailed(ctx, runId, failedPayload);
347
352
  return { isSuccess: true, data: { outcome: "failed" as const } };
@@ -57,10 +57,12 @@ export type WorkflowRunCompletedPayload = {
57
57
  export type WorkflowRunFailedPayload = {
58
58
  readonly workflowName: string;
59
59
  readonly stepIndex: number;
60
+ // Generic text (class name only), never the raw error message: the event
61
+ // survives an erase. The raw error goes to the log.
60
62
  readonly error: string;
61
63
  // Machine-readable failure category — set by resume-run for a Q7
62
64
  // fingerprint mismatch ("workflow_definition_changed"); absent for a
63
- // plain pipeline-step failure (the `error` string is human-readable only).
65
+ // plain pipeline-step failure (the `error` string is generic).
64
66
  readonly reason?: string;
65
67
  };
66
68