@cosmicdrift/kumiko-bundled-features 0.319.0 → 0.321.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 (91) hide show
  1. package/package.json +9 -9
  2. package/src/auth-email-password/__tests__/auth-tenants-rate-limit.integration.test.ts +3 -0
  3. package/src/auth-email-password/__tests__/post-auth-landing.integration.test.ts +321 -0
  4. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +4 -0
  5. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +4 -0
  6. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +21 -1
  7. package/src/auth-email-password/__tests__/signup-handover.integration.test.ts +10 -0
  8. package/src/auth-email-password/auth-paths.ts +10 -6
  9. package/src/auth-email-password/changes.json +12 -0
  10. package/src/auth-email-password/index.ts +1 -1
  11. package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +34 -1
  12. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +30 -0
  13. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +26 -0
  14. package/src/auth-email-password/web/auth-client.ts +16 -1
  15. package/src/auth-email-password/web/auth-form-logic.ts +10 -0
  16. package/src/auth-email-password/web/invite-accept-screen.tsx +10 -5
  17. package/src/auth-email-password/web/signup-complete-screen.tsx +11 -7
  18. package/src/auth-mfa/web/mfa-client.ts +18 -2
  19. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  20. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  21. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  22. package/src/billing-foundation/changes.json +14 -0
  23. package/src/billing-foundation/checkout-core.ts +8 -4
  24. package/src/billing-foundation/constants.ts +6 -0
  25. package/src/billing-foundation/feature.ts +37 -2
  26. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  27. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  28. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  29. package/src/billing-foundation/i18n.ts +9 -0
  30. package/src/billing-foundation/index.ts +1 -0
  31. package/src/billing-foundation/plan-catalog.ts +22 -5
  32. package/src/billing-foundation/types.ts +27 -0
  33. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  34. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  35. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  36. package/src/channel-email/__tests__/smtp-transport-pinning.test.ts +42 -0
  37. package/src/channel-email/changes.json +9 -1
  38. package/src/channel-email/email-channel.ts +31 -6
  39. package/src/channel-email/smtp-transport.ts +7 -0
  40. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  41. package/src/delivery/changes.json +7 -0
  42. package/src/delivery/feature.ts +1 -1
  43. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  44. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  45. package/src/delivery/index.ts +2 -1
  46. package/src/delivery/public-names.ts +1 -1
  47. package/src/delivery/unsubscribe.ts +167 -89
  48. package/src/file-derivatives/feature.ts +1 -1
  49. package/src/file-derivatives/handlers/public-variant.query.ts +5 -7
  50. package/src/foundation-shared/__tests__/mail-host-policy.test.ts +68 -0
  51. package/src/foundation-shared/index.ts +9 -0
  52. package/src/foundation-shared/mail-host-policy.ts +70 -0
  53. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +11 -0
  54. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +14 -1
  55. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +88 -3
  56. package/src/inbound-provider-imap/changes.json +14 -1
  57. package/src/inbound-provider-imap/feature.ts +6 -3
  58. package/src/inbound-provider-imap/imap-client.ts +60 -2
  59. package/src/inbound-provider-imap/index.ts +1 -1
  60. package/src/ledger/__tests__/ledger.integration.test.ts +80 -0
  61. package/src/ledger/changes.json +9 -1
  62. package/src/ledger/entity.ts +14 -1
  63. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +86 -1
  64. package/src/mail-transport-smtp/__tests__/feature.test.ts +123 -3
  65. package/src/mail-transport-smtp/changes.json +14 -1
  66. package/src/mail-transport-smtp/feature.ts +76 -1
  67. package/src/mail-transport-smtp/index.ts +6 -1
  68. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +59 -0
  69. package/src/sessions/__tests__/sessions.integration.test.ts +5 -0
  70. package/src/shared/password-hashing.test.ts +21 -13
  71. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  72. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +283 -0
  73. package/src/step-dispatcher/changes.json +21 -1
  74. package/src/step-dispatcher/feature.ts +63 -15
  75. package/src/step-dispatcher/index.ts +11 -2
  76. package/src/step-dispatcher/webhook-runner.ts +157 -31
  77. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  78. package/src/subscription-stripe/changes.json +6 -0
  79. package/src/subscription-stripe/feature.ts +5 -1
  80. package/src/subscription-stripe/plugin-methods.ts +53 -0
  81. package/src/subscription-stripe/verify-webhook.ts +52 -28
  82. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  83. package/src/tenant-handover/changes.json +7 -0
  84. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  85. package/src/tenant-handover/move-entity-graph.ts +30 -44
  86. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +11 -0
  87. package/src/user-data-rights/__tests__/download-by-token-ip-bucket.integration.test.ts +170 -0
  88. package/src/user-data-rights/__tests__/download.integration.test.ts +12 -6
  89. package/src/user-data-rights/__tests__/extract-audit-meta.test.ts +36 -0
  90. package/src/user-data-rights/changes.json +6 -0
  91. package/src/user-data-rights/feature.ts +35 -37
@@ -0,0 +1,283 @@
1
+ // Host-egress guard tests for performWebhookDispatch — spec.url is
2
+ // request-controlled (see webhook-runner.ts header), so it must resolve to
3
+ // a public address before any connect is attempted, mirroring the SMTP/
4
+ // IMAP host guard.
5
+
6
+ import { afterEach, describe, expect, mock, test } from "bun:test";
7
+ import type { lookup } from "node:dns/promises";
8
+ import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
9
+ import {
10
+ performWebhookDispatch,
11
+ setWebhookFetch,
12
+ setWebhookHostLookup,
13
+ WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
14
+ type WebhookDispatchDeps,
15
+ } from "../webhook-runner";
16
+
17
+ function fakeLookupFor(addressesByHost: Readonly<Record<string, string>>): typeof lookup {
18
+ return (async (hostname: string) => {
19
+ const address = addressesByHost[hostname];
20
+ if (!address) throw new Error(`ENOTFOUND ${hostname}`);
21
+ return [{ address, family: 4 }];
22
+ }) as unknown as typeof lookup;
23
+ }
24
+
25
+ const fetchMock = mock<typeof fetch>();
26
+
27
+ const TEST_TENANT_ID = "11111111-1111-4111-8111-111111111111";
28
+ const TEST_USER_ID = "22222222-2222-4222-8222-222222222222";
29
+
30
+ const noSecretsDeps: WebhookDispatchDeps = {
31
+ tenantId: TEST_TENANT_ID,
32
+ userId: TEST_USER_ID,
33
+ secrets: undefined,
34
+ };
35
+
36
+ function fakeSecrets(value: string | undefined) {
37
+ const get = mock(async () => (value === undefined ? undefined : createSecret(value)));
38
+ const secrets: SecretsContext = {
39
+ get,
40
+ has: async () => value !== undefined,
41
+ set: async () => {},
42
+ delete: async () => true,
43
+ };
44
+ return { secrets, get };
45
+ }
46
+
47
+ afterEach(() => {
48
+ fetchMock.mockReset();
49
+ setWebhookFetch(fetch);
50
+ setWebhookHostLookup(undefined);
51
+ delete process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
52
+ });
53
+
54
+ describe("performWebhookDispatch — host-egress guard", () => {
55
+ test("a private IP-literal host is rejected before any connect attempt", async () => {
56
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
57
+ const result = await performWebhookDispatch(
58
+ {
59
+ url: "http://10.0.0.5/hook",
60
+ method: "POST",
61
+ headers: {},
62
+ },
63
+ noSecretsDeps,
64
+ );
65
+ expect(result.ok).toBe(false);
66
+ expect(fetchMock).not.toHaveBeenCalled();
67
+ });
68
+
69
+ test("a hostname resolving to a private address is rejected, without leaking the resolved address", async () => {
70
+ setWebhookHostLookup(fakeLookupFor({ "internal.example": "127.0.0.1" }));
71
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
72
+ const result = await performWebhookDispatch(
73
+ {
74
+ url: "http://internal.example/hook",
75
+ method: "POST",
76
+ headers: {},
77
+ },
78
+ noSecretsDeps,
79
+ );
80
+ expect(result.ok).toBe(false);
81
+ if (!result.ok) {
82
+ expect(result.error).not.toContain("127.0.0.1");
83
+ }
84
+ expect(fetchMock).not.toHaveBeenCalled();
85
+ });
86
+
87
+ test("a DNS resolution failure surfaces as a delivery error, not a thrown exception", async () => {
88
+ setWebhookHostLookup(fakeLookupFor({}));
89
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
90
+ const result = await performWebhookDispatch(
91
+ {
92
+ url: "http://nowhere.example/hook",
93
+ method: "POST",
94
+ headers: {},
95
+ },
96
+ noSecretsDeps,
97
+ );
98
+ expect(result.ok).toBe(false);
99
+ expect(fetchMock).not.toHaveBeenCalled();
100
+ });
101
+
102
+ test("a blocked host and a DNS resolution failure produce the exact same tenant-visible error, without the host", async () => {
103
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
104
+
105
+ const blocked = await performWebhookDispatch(
106
+ {
107
+ url: "http://10.0.0.5/hook",
108
+ method: "POST",
109
+ headers: {},
110
+ },
111
+ noSecretsDeps,
112
+ );
113
+ setWebhookHostLookup(fakeLookupFor({}));
114
+ const unresolvable = await performWebhookDispatch(
115
+ {
116
+ url: "http://nowhere.example/hook",
117
+ method: "POST",
118
+ headers: {},
119
+ },
120
+ noSecretsDeps,
121
+ );
122
+
123
+ expect(blocked.ok).toBe(false);
124
+ expect(unresolvable.ok).toBe(false);
125
+ if (blocked.ok || unresolvable.ok) throw new Error("unreachable");
126
+ expect(blocked.error).toBe(unresolvable.error);
127
+ expect(blocked.error).not.toContain("10.0.0.5");
128
+ expect(unresolvable.error).not.toContain("nowhere.example");
129
+ });
130
+
131
+ test("an operator-allowlisted private host bypasses resolution and keeps its raw url", async () => {
132
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "webhook-receiver.internal";
133
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
134
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
135
+
136
+ const result = await performWebhookDispatch(
137
+ {
138
+ url: "http://webhook-receiver.internal/hook",
139
+ method: "POST",
140
+ headers: {},
141
+ },
142
+ noSecretsDeps,
143
+ );
144
+
145
+ expect(result.ok).toBe(true);
146
+ expect(fetchMock).toHaveBeenCalledTimes(1);
147
+ const [calledUrl] = fetchMock.mock.calls[0]!;
148
+ expect(String(calledUrl)).toBe("http://webhook-receiver.internal/hook");
149
+ });
150
+
151
+ test("a public hostname is pinned to its resolved address, keeping the original host as Host header and TLS SNI", async () => {
152
+ setWebhookHostLookup(fakeLookupFor({ "hooks.example.com": "203.0.113.9" }));
153
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
154
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
155
+
156
+ const result = await performWebhookDispatch(
157
+ {
158
+ url: "https://hooks.example.com/incident",
159
+ method: "POST",
160
+ headers: {},
161
+ },
162
+ noSecretsDeps,
163
+ );
164
+
165
+ expect(result.ok).toBe(true);
166
+ const [calledUrl, init] = fetchMock.mock.calls[0]!;
167
+ expect(String(calledUrl)).toBe("https://203.0.113.9/incident");
168
+ const headers = init?.headers as Headers;
169
+ expect(headers.get("host")).toBe("hooks.example.com");
170
+ expect((init as unknown as { tls?: { servername: string } })?.tls?.servername).toBe(
171
+ "hooks.example.com",
172
+ );
173
+ });
174
+ });
175
+
176
+ describe("performWebhookDispatch — auth.secret resolution", () => {
177
+ test("bearer auth sends the tenant's secret under the Authorization header", async () => {
178
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
179
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
180
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
181
+ const { secrets, get } = fakeSecrets("tok_live_abc123");
182
+
183
+ const result = await performWebhookDispatch(
184
+ {
185
+ url: "http://hooks.example/hook",
186
+ method: "POST",
187
+ headers: {},
188
+ auth: { kind: "bearer", secret: "incident-hook" },
189
+ },
190
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
191
+ );
192
+
193
+ expect(result.ok).toBe(true);
194
+ expect(get).toHaveBeenCalledWith(TEST_TENANT_ID, "step-dispatcher:webhook-auth.incident-hook", {
195
+ userId: TEST_USER_ID,
196
+ handlerName: "step-dispatcher:webhook.send",
197
+ });
198
+ const [, init] = fetchMock.mock.calls[0]!;
199
+ expect(new Headers(init?.headers).get("authorization")).toBe("Bearer tok_live_abc123");
200
+ });
201
+
202
+ test("header auth sends the tenant's secret under the configured header name", async () => {
203
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
204
+ fetchMock.mockResolvedValueOnce(new Response(null, { status: 200 }));
205
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
206
+ const { secrets } = fakeSecrets("shared-secret-xyz");
207
+
208
+ const result = await performWebhookDispatch(
209
+ {
210
+ url: "http://hooks.example/hook",
211
+ method: "POST",
212
+ headers: {},
213
+ auth: { kind: "header", name: "x-hub-signature", secret: "incident-hook" },
214
+ },
215
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
216
+ );
217
+
218
+ expect(result.ok).toBe(true);
219
+ const [, init] = fetchMock.mock.calls[0]!;
220
+ expect(new Headers(init?.headers).get("x-hub-signature")).toBe("shared-secret-xyz");
221
+ });
222
+
223
+ test("a missing secret fails without leaking the secret name or fetching", async () => {
224
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
225
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
226
+ const { secrets } = fakeSecrets(undefined);
227
+
228
+ const result = await performWebhookDispatch(
229
+ {
230
+ url: "http://hooks.example/hook",
231
+ method: "POST",
232
+ headers: {},
233
+ auth: { kind: "bearer", secret: "incident-hook" },
234
+ },
235
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
236
+ );
237
+
238
+ expect(result.ok).toBe(false);
239
+ if (result.ok) throw new Error("unreachable");
240
+ expect(result.error).toBe("webhook auth secret is not available");
241
+ expect(result.error).not.toContain("incident-hook");
242
+ expect(fetchMock).not.toHaveBeenCalled();
243
+ });
244
+
245
+ test("an undefined secrets context fails the same generic way as a missing secret", async () => {
246
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
247
+ process.env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR] = "hooks.example";
248
+
249
+ const result = await performWebhookDispatch(
250
+ {
251
+ url: "http://hooks.example/hook",
252
+ method: "POST",
253
+ headers: {},
254
+ auth: { kind: "bearer", secret: "incident-hook" },
255
+ },
256
+ noSecretsDeps,
257
+ );
258
+
259
+ expect(result.ok).toBe(false);
260
+ if (result.ok) throw new Error("unreachable");
261
+ expect(result.error).toBe("webhook auth secret is not available");
262
+ expect(fetchMock).not.toHaveBeenCalled();
263
+ });
264
+
265
+ test("an invalid url is rejected before any secret is read", async () => {
266
+ setWebhookFetch(fetchMock as unknown as typeof fetch);
267
+ const { secrets, get } = fakeSecrets("should-never-be-read");
268
+
269
+ const result = await performWebhookDispatch(
270
+ {
271
+ url: "not-a-url",
272
+ method: "POST",
273
+ headers: {},
274
+ auth: { kind: "bearer", secret: "incident-hook" },
275
+ },
276
+ { tenantId: TEST_TENANT_ID, userId: TEST_USER_ID, secrets },
277
+ );
278
+
279
+ expect(result.ok).toBe(false);
280
+ expect(get).not.toHaveBeenCalled();
281
+ expect(fetchMock).not.toHaveBeenCalled();
282
+ });
283
+ });
@@ -1 +1,21 @@
1
- []
1
+ [
2
+ {
3
+ "version": "0.321.0",
4
+ "type": "breaking",
5
+ "title": "Webhook auth secrets are now tenant-owned via the secrets feature, not a global env var",
6
+ "detail": "`r.step.webhook.send`'s `auth` config renamed `secretRef` → `secret`. The\nvalue is now a name inside the tenant-owned secrets namespace\n`step-dispatcher:webhook-auth.<secret>` (secrets feature), resolved via\n`SecretsContext.get()` at dispatch time with an audit read stamped with\nthe triggering event's userId (or the system actor for cron/resume\ndispatches). `setWebhookSecretResolver` / the `WEBHOOK_SECRET_*` env\nconvention are gone. `performWebhookDispatch(spec)` now takes a required\nsecond `deps: { tenantId, userId, secrets }` argument.",
7
+ "migration": "Mount `createSecretsFeature()` (with a `MasterKeyProvider`) alongside\n`createStepDispatcherFeature()` — boot-validation now fails without it.\nFor each tenant that uses `r.step.webhook.send` with `auth`, set the\ncredential via `secrets:write:set` under\n`step-dispatcher:webhook-auth.<secret>` (the same name passed as\n`auth.secret`). Rename `auth.secretRef` → `auth.secret` at every\n`r.step.webhook.send({ auth: {...} })` call site. Remove any\n`WEBHOOK_SECRET_*` env vars — they're no longer read.\n\nA `step.dispatch-requested` event already enqueued before this upgrade\n(still carrying the old `secretRef` shape) fails validation on drain and\nis recorded as `step.dispatch-failed` with `error: \"invalid dispatch\npayload\"` instead of silently resolving a stale ref — drain the queue (or\naccept the one-time failed event) before deploying.\n\nTests calling `performWebhookDispatch` directly or using\n`setWebhookSecretResolver` must pass a `SecretsContext` (or `undefined`)\nvia the new `deps` argument instead."
8
+ },
9
+ {
10
+ "version": "0.320.0",
11
+ "type": "fix",
12
+ "title": "webhook dispatch failures no longer distinguish a blocked host from a DNS failure",
13
+ "detail": "`performWebhookDispatch` returns the same generic error\n(\"webhook host is not reachable or not allowed\") for both a blocked\nhost and a DNS resolution failure, instead of two distinguishable\nstrings that also embedded the target hostname in the delivery-attempt\nevent payload. The hostname and the underlying error are now logged via\nthe feature's own logger instead of being included in the result."
14
+ },
15
+ {
16
+ "version": "0.320.0",
17
+ "type": "breaking",
18
+ "title": "A webhook.send target host must resolve to a public address",
19
+ "migration": "A workflow or handler pointing `r.step.webhook.send` at an internal\nreceiver or a local dev/test endpoint (localhost or a private IP) now\ngets a delivery error (step.dispatch-failed) unless that host is\nexplicitly allowed. Set the operator env var (comma-separated, read at\ndispatch time — no boot-time code call needed) before starting the\nprocess:\n KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS=webhook-receiver.internal\nThis is an operator env var, not a tenant/workflow-config value — a\nworkflow author cannot add their own host to this list. A DNS resolution\nfailure for a genuinely unreachable host now surfaces as a distinct\ndelivery error instead of only failing later inside `fetch()`."
20
+ }
21
+ ]
@@ -8,44 +8,92 @@
8
8
  // the audit trail lives in the event log only — no separate status table.
9
9
 
10
10
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
11
- import { type MailSpec, performMailDispatch } from "./mail-runner";
12
- import { performWebhookDispatch, type WebhookSpec } from "./webhook-runner";
11
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
12
+ import * as z from "zod";
13
+ import { mailSpecSchema, performMailDispatch } from "./mail-runner";
14
+ import {
15
+ performWebhookDispatch,
16
+ WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
17
+ webhookSpecSchema,
18
+ } from "./webhook-runner";
19
+
20
+ export const stepDispatcherEnvSchema = z.object({
21
+ [WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR]: z
22
+ .string()
23
+ .optional()
24
+ .describe(
25
+ "Comma-separated operator allowlist of private/internal hosts (e.g. a local webhook-receiver for dev/test) that bypass the public-address check for webhook.send targets. Never a tenant-config value.",
26
+ ),
27
+ });
13
28
 
14
29
  export const STEP_DISPATCH_AGGREGATE_TYPE = "step-dispatch";
15
30
  export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-requested";
16
31
  export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
17
32
  export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
18
33
 
19
- type DispatchRequestedPayload =
20
- | {
21
- readonly stepKind: "webhook.send";
22
- readonly spec: WebhookSpec;
23
- readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
24
- }
25
- | {
26
- readonly stepKind: "mail.send";
27
- readonly spec: MailSpec;
28
- };
34
+ // 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.
37
+ const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
38
+ z.object({
39
+ stepKind: z.literal("webhook.send"),
40
+ spec: webhookSpecSchema,
41
+ retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
42
+ }),
43
+ z.object({ stepKind: z.literal("mail.send"), spec: mailSpecSchema }),
44
+ ]);
45
+
46
+ // zod issue messages can echo the invalid value (e.g. a rejected url) back
47
+ // into the tenant-visible dispatch-failed event — keep this generic.
48
+ const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
49
+
50
+ const rawStepKindSchema = z.object({ stepKind: z.string() });
51
+
52
+ function rawStepKindOf(payload: unknown): string {
53
+ const parsed = rawStepKindSchema.safeParse(payload);
54
+ return parsed.success ? parsed.data.stepKind : "unknown";
55
+ }
29
56
 
30
57
  export function createStepDispatcherFeature(): FeatureDefinition {
31
58
  return defineFeature("step-dispatcher", (r) => {
32
59
  r.describe(
33
- "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`.",
60
+ "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.",
34
61
  );
35
62
  r.uiHints({
36
63
  displayLabel: "Step Dispatcher · Deferred Side-Effects",
37
64
  category: "infrastructure",
38
65
  recommended: false,
39
66
  });
67
+ r.envSchema(stepDispatcherEnvSchema);
68
+ r.requires("secrets");
40
69
 
41
70
  r.multiStreamProjection({
42
71
  name: "step-dispatcher",
43
72
  apply: {
44
73
  [STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
45
- const payload = event.payload as DispatchRequestedPayload;
74
+ const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
75
+ if (!parsed.success) {
76
+ await ctx.unsafeAppendEvent({
77
+ aggregateId: event.aggregateId,
78
+ 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
+ },
85
+ });
86
+ // skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
87
+ return;
88
+ }
89
+ const payload = parsed.data;
46
90
  const result =
47
91
  payload.stepKind === "webhook.send"
48
- ? await performWebhookDispatch(payload.spec)
92
+ ? await performWebhookDispatch(payload.spec, {
93
+ tenantId: event.tenantId,
94
+ userId: event.metadata.userId || SYSTEM_USER_ID,
95
+ secrets: ctx.secrets,
96
+ })
49
97
  : await performMailDispatch(payload.spec);
50
98
  if (result.ok) {
51
99
  await ctx.unsafeAppendEvent({
@@ -1,4 +1,8 @@
1
- export { createStepDispatcherFeature, STEP_DISPATCH_AGGREGATE_TYPE } from "./feature";
1
+ export {
2
+ createStepDispatcherFeature,
3
+ STEP_DISPATCH_AGGREGATE_TYPE,
4
+ stepDispatcherEnvSchema,
5
+ } from "./feature";
2
6
  export {
3
7
  type MailDispatchResult,
4
8
  type MailSpec,
@@ -8,9 +12,14 @@ export {
8
12
  } from "./mail-runner";
9
13
  export {
10
14
  performWebhookDispatch,
15
+ readAllowedPrivateWebhookHostsFromEnv,
11
16
  setWebhookFetch,
12
- setWebhookSecretResolver,
17
+ setWebhookHostLookup,
18
+ WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
19
+ WEBHOOK_AUTH_SECRET_KEY_PREFIX,
20
+ type WebhookDispatchDeps,
13
21
  type WebhookDispatchResult,
14
22
  type WebhookSpec,
23
+ webhookAuthSecretKey,
15
24
  webhookSpecSchema,
16
25
  } from "./webhook-runner";
@@ -1,8 +1,82 @@
1
1
  // Webhook execution logic — separated from feature.ts so tests can stub
2
2
  // the fetch without touching the MSP wiring.
3
+ //
4
+ // `spec.url` is request-controlled (a write-handler or workflow step reads
5
+ // it straight from tenant/user input — see samples/recipes/webhook-step for
6
+ // the reference usage), so it gets the same connect-time host-egress guard
7
+ // as tenant-supplied SMTP/IMAP hosts: resolve once, reject a private/
8
+ // reserved address, and pin the connect to the resolved address (Host
9
+ // header + TLS SNI keep the original hostname for cert validation).
10
+ //
11
+ // `allowedPrivateWebhookHosts` is the operator's own escape hatch for an
12
+ // internal receiver or a dev/test endpoint — an operator env var
13
+ // (KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS), never a tenant-config key, so a
14
+ // tenant can never grant themselves the bypass. Own key, not the mail
15
+ // features' — step-dispatcher has no dependency relation to mail-transport-
16
+ // smtp/inbound-provider-imap and shouldn't require mounting them.
17
+ //
18
+ // `auth.secret` resolves through the secrets feature under the tenant-owned
19
+ // namespace `step-dispatcher:webhook-auth.<secret>` — the target URL is
20
+ // tenant/request-controlled, so a webhook can never read a platform-wide or
21
+ // another tenant's secret.
3
22
 
23
+ import type { lookup } from "node:dns/promises";
24
+ import {
25
+ BlockedHostError,
26
+ buildPinnedRequest,
27
+ HostResolutionError,
28
+ resolvePublicHostname,
29
+ } from "@cosmicdrift/kumiko-framework/http";
30
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
31
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
32
+ import type { TenantId } from "@cosmicdrift/kumiko-types/identifiers";
4
33
  import * as z from "zod";
5
34
 
35
+ const log = createFallbackLogger("step-dispatcher");
36
+
37
+ export const WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR = "KUMIKO_WEBHOOK_ALLOWED_PRIVATE_HOSTS";
38
+
39
+ /** Parses the comma-separated operator allowlist env var. Never throws —
40
+ * an unset or empty value just means no bypass. */
41
+ export function readAllowedPrivateWebhookHostsFromEnv(
42
+ env: Readonly<Record<string, string | undefined>> = process.env,
43
+ ): readonly string[] {
44
+ const raw = env[WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR];
45
+ if (!raw) return [];
46
+ return raw
47
+ .split(",")
48
+ .map((host) => host.trim())
49
+ .filter((host) => host.length > 0);
50
+ }
51
+
52
+ // Test-only DNS seam — production never calls this, resolvePublicHostname
53
+ // defaults to the real resolver. Reset it in afterEach/afterAll — this is
54
+ // module-global state.
55
+ let webhookHostLookup: typeof lookup | undefined;
56
+
57
+ export function setWebhookHostLookup(fn: typeof lookup | undefined): void {
58
+ webhookHostLookup = fn;
59
+ }
60
+
61
+ // Tenant-owned namespace every webhook auth secret lives under in the
62
+ // secrets feature. Applied at resolution time, never at step-build time,
63
+ // so `auth.secret` stays a short, human-picked name (e.g. "smtp.password")
64
+ // while the stored key stays collision-free with every other feature's
65
+ // secrets.
66
+ export const WEBHOOK_AUTH_SECRET_KEY_PREFIX = "step-dispatcher:webhook-auth.";
67
+
68
+ export function webhookAuthSecretKey(name: string): string {
69
+ return `${WEBHOOK_AUTH_SECRET_KEY_PREFIX}${name}`;
70
+ }
71
+
72
+ const WEBHOOK_AUTH_SECRET_NAME_MAX_LENGTH = 100 - WEBHOOK_AUTH_SECRET_KEY_PREFIX.length;
73
+
74
+ const webhookAuthSecretNameSchema = z
75
+ .string()
76
+ .min(1)
77
+ .max(WEBHOOK_AUTH_SECRET_NAME_MAX_LENGTH)
78
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
79
+
6
80
  export const webhookSpecSchema = z.object({
7
81
  url: z.string(),
8
82
  method: z.enum(["POST", "PUT", "PATCH"]),
@@ -10,8 +84,12 @@ export const webhookSpecSchema = z.object({
10
84
  body: z.unknown().optional(),
11
85
  auth: z
12
86
  .union([
13
- z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
14
- z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
87
+ z.object({ kind: z.literal("bearer"), secret: webhookAuthSecretNameSchema }),
88
+ z.object({
89
+ kind: z.literal("header"),
90
+ name: z.string(),
91
+ secret: webhookAuthSecretNameSchema,
92
+ }),
15
93
  ])
16
94
  .optional(),
17
95
  });
@@ -22,27 +100,81 @@ export type WebhookDispatchResult =
22
100
  | { readonly ok: true; readonly status: number }
23
101
  | { readonly ok: false; readonly error: string };
24
102
 
25
- // Resolves a secretRef via the test-injectable secret-store. Default
26
- // implementation reads from process.env at the prefix WEBHOOK_SECRET_.
27
- // Tests pass a custom resolver via setWebhookSecretResolver.
28
- let secretResolver: (ref: string) => string | undefined = (ref) =>
29
- process.env[`WEBHOOK_SECRET_${ref}`];
30
-
31
- export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
32
- secretResolver = fn;
33
- }
34
-
35
103
  let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
36
104
 
37
105
  export function setWebhookFetch(fn: typeof fetch): void {
38
106
  fetchImpl = fn;
39
107
  }
40
108
 
41
- export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
42
- // SSRF guard at the primitive boundary: only http(s), and never follow
43
- // redirects — a 3xx could point at an internal/metadata target and the
44
- // spec carries secrets (auth) that would be forwarded there. A webhook
45
- // destination that redirects now surfaces as a delivery error instead.
109
+ export type WebhookDispatchDeps = {
110
+ readonly tenantId: TenantId;
111
+ readonly userId: string;
112
+ readonly secrets: SecretsContext | undefined;
113
+ };
114
+
115
+ // Never includes the secret name or value — spec.auth.secret is a
116
+ // tenant-chosen name, but the error still reaches the tenant via the
117
+ // dispatch-failed event, so it stays generic.
118
+ const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
119
+
120
+ async function buildWebhookHeaders(
121
+ spec: WebhookSpec,
122
+ deps: WebhookDispatchDeps,
123
+ ): Promise<{ ok: true; headers: Record<string, string> } | { ok: false; error: string }> {
124
+ const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
125
+ if (!spec.auth) return { ok: true, headers };
126
+ if (!deps.secrets) return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
127
+ const revealed = await deps.secrets.get(deps.tenantId, webhookAuthSecretKey(spec.auth.secret), {
128
+ userId: deps.userId,
129
+ handlerName: "step-dispatcher:webhook.send",
130
+ });
131
+ if (!revealed) {
132
+ return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
133
+ }
134
+ const secret = revealed.reveal();
135
+ if (spec.auth.kind === "bearer") {
136
+ headers["authorization"] = `Bearer ${secret}`;
137
+ } else {
138
+ headers[spec.auth.name] = secret;
139
+ }
140
+ return { ok: true, headers };
141
+ }
142
+
143
+ async function resolveWebhookFetchTarget(
144
+ rawUrl: string,
145
+ url: URL,
146
+ headers: Record<string, string>,
147
+ ): Promise<
148
+ { ok: true; fetchUrl: string | URL; requestInit: RequestInit } | { ok: false; error: string }
149
+ > {
150
+ const isAllowedPrivateHost = readAllowedPrivateWebhookHostsFromEnv().some(
151
+ (candidate) => candidate.toLowerCase() === url.hostname.toLowerCase(),
152
+ );
153
+ if (isAllowedPrivateHost) return { ok: true, fetchUrl: rawUrl, requestInit: { headers } };
154
+ try {
155
+ const resolved = await resolvePublicHostname(url.hostname, webhookHostLookup);
156
+ const pinned = buildPinnedRequest(url, resolved, { headers });
157
+ return { ok: true, fetchUrl: pinned.url, requestInit: pinned.init };
158
+ } catch (err) {
159
+ const reason = err instanceof Error ? err.message : String(err);
160
+ if (err instanceof BlockedHostError || err instanceof HostResolutionError) {
161
+ log.warn("webhook host unreachable", { host: url.hostname, reason });
162
+ return { ok: false, error: "webhook host is not reachable or not allowed" };
163
+ }
164
+ throw err;
165
+ }
166
+ }
167
+
168
+ export async function performWebhookDispatch(
169
+ spec: WebhookSpec,
170
+ deps: WebhookDispatchDeps,
171
+ ): Promise<WebhookDispatchResult> {
172
+ // Host-egress guard at the primitive boundary: only http(s), the target
173
+ // host must resolve to a public address (unless operator-allowlisted),
174
+ // and redirects are never followed — a 3xx could point at an internal/
175
+ // metadata target and the spec carries secrets (auth) that would be
176
+ // forwarded there. A webhook destination that redirects now surfaces as
177
+ // a delivery error instead.
46
178
  let url: URL;
47
179
  try {
48
180
  url = new URL(spec.url);
@@ -53,22 +185,16 @@ export async function performWebhookDispatch(spec: WebhookSpec): Promise<Webhook
53
185
  return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
54
186
  }
55
187
 
56
- const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
57
- if (spec.auth) {
58
- const secret = secretResolver(spec.auth.secretRef);
59
- if (!secret) {
60
- return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
61
- }
62
- if (spec.auth.kind === "bearer") {
63
- headers["authorization"] = `Bearer ${secret}`;
64
- } else {
65
- headers[spec.auth.name] = secret;
66
- }
67
- }
188
+ const headers = await buildWebhookHeaders(spec, deps);
189
+ if (!headers.ok) return headers;
190
+
191
+ const target = await resolveWebhookFetchTarget(spec.url, url, headers.headers);
192
+ if (!target.ok) return target;
193
+
68
194
  try {
69
- const res = await fetchImpl(spec.url, {
195
+ const res = await fetchImpl(target.fetchUrl, {
196
+ ...target.requestInit,
70
197
  method: spec.method,
71
- headers,
72
198
  redirect: "manual",
73
199
  body: spec.body !== undefined ? JSON.stringify(spec.body) : undefined,
74
200
  });