@multiplier-labs/stepflow 0.3.1 → 0.3.4

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 (112) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
  4. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  5. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  6. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
  7. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  8. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  9. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
  10. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  11. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
  12. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  13. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  14. package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
  15. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  16. package/.github/CODEOWNERS +15 -0
  17. package/.github/dependabot.yml +23 -0
  18. package/.github/workflows/ci.yml +38 -0
  19. package/.github/workflows/publish.yml +17 -5
  20. package/.github/workflows/security.yml +29 -0
  21. package/CHANGELOG.md +34 -0
  22. package/README.md +24 -0
  23. package/SECURITY.md +40 -0
  24. package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
  25. package/dist/chunk-5XJBAPXF.js.map +1 -0
  26. package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
  27. package/dist/chunk-FMPJBK2N.js.map +1 -0
  28. package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
  29. package/dist/chunk-JPXPB7U5.js.map +1 -0
  30. package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
  31. package/dist/chunk-U3PCVK6O.js.map +1 -0
  32. package/dist/events/index.d.ts +60 -7
  33. package/dist/events/index.js +1 -1
  34. package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
  35. package/dist/index.d.ts +15 -23
  36. package/dist/index.js +784 -547
  37. package/dist/index.js.map +1 -1
  38. package/dist/scheduler/index.d.ts +4 -4
  39. package/dist/scheduler/index.js +2 -2
  40. package/dist/storage/index.d.ts +322 -56
  41. package/dist/storage/index.js +4 -2
  42. package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
  43. package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
  44. package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
  45. package/docs/stepflow-api-reference.md +5 -4
  46. package/package.json +12 -8
  47. package/src/core/engine/concurrency.ts +229 -0
  48. package/src/core/engine/lifecycle.ts +176 -0
  49. package/src/core/engine.test.ts +536 -522
  50. package/src/core/engine.ts +116 -235
  51. package/src/core/index.ts +3 -3
  52. package/src/core/orchestrator/persistence.ts +165 -0
  53. package/src/core/orchestrator/plan-resolution.ts +140 -0
  54. package/src/core/orchestrator/state-transitions.ts +109 -0
  55. package/src/core/orchestrator/step-runner.ts +427 -0
  56. package/src/core/orchestrator.ts +57 -395
  57. package/src/core/types.ts +7 -29
  58. package/src/events/index.ts +4 -4
  59. package/src/events/memory.test.ts +39 -41
  60. package/src/events/memory.ts +7 -10
  61. package/src/events/socketio.test.ts +112 -162
  62. package/src/events/socketio.ts +20 -27
  63. package/src/events/types.ts +16 -19
  64. package/src/events/webhook.test.ts +489 -235
  65. package/src/events/webhook.ts +355 -139
  66. package/src/index.ts +19 -19
  67. package/src/planning/evaluate-condition.test.ts +153 -0
  68. package/src/planning/evaluate-condition.ts +105 -0
  69. package/src/planning/index.ts +6 -3
  70. package/src/planning/planner.ts +45 -152
  71. package/src/planning/planning.test.ts +303 -366
  72. package/src/planning/registry.test.ts +204 -251
  73. package/src/planning/registry.ts +20 -106
  74. package/src/planning/types.ts +23 -27
  75. package/src/scheduler/cron.test.ts +149 -150
  76. package/src/scheduler/cron.ts +58 -69
  77. package/src/scheduler/index.ts +4 -14
  78. package/src/scheduler/postgres-persistence.ts +89 -105
  79. package/src/scheduler/sqlite-persistence.test.ts +96 -96
  80. package/src/scheduler/sqlite-persistence.ts +40 -22
  81. package/src/scheduler/types.ts +4 -9
  82. package/src/storage/index.ts +5 -5
  83. package/src/storage/memory.test.ts +133 -183
  84. package/src/storage/memory.ts +21 -42
  85. package/src/storage/postgres-core.ts +550 -0
  86. package/src/storage/postgres-migration.test.ts +69 -0
  87. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  88. package/src/storage/postgres.ts +222 -937
  89. package/src/storage/sqlite-core.ts +151 -0
  90. package/src/storage/sqlite.test.ts +330 -281
  91. package/src/storage/sqlite.ts +225 -170
  92. package/src/storage/types.ts +33 -58
  93. package/src/utils/errors.test.ts +78 -80
  94. package/src/utils/errors.ts +27 -57
  95. package/src/utils/id.ts +3 -3
  96. package/src/utils/index.ts +6 -4
  97. package/src/utils/logger.test.ts +52 -58
  98. package/src/utils/logger.ts +4 -4
  99. package/src/utils/pinned-fetch.test.ts +84 -0
  100. package/src/utils/pinned-fetch.ts +118 -0
  101. package/src/utils/postgres-deps.ts +20 -4
  102. package/src/utils/retry.test.ts +47 -67
  103. package/src/utils/retry.ts +9 -9
  104. package/src/utils/safe-json.test.ts +166 -0
  105. package/src/utils/safe-json.ts +107 -0
  106. package/src/utils/webhook-signature.test.ts +111 -0
  107. package/src/utils/webhook-signature.ts +74 -0
  108. package/dist/chunk-FQCT3I2A.js.map +0 -1
  109. package/dist/chunk-GXY66V7Q.js.map +0 -1
  110. package/dist/chunk-U5QGN3RT.js.map +0 -1
  111. package/dist/chunk-XCF4PLN7.js.map +0 -1
  112. package/src/scheduler/postgres-persistence.test.ts +0 -906
@@ -1,27 +1,25 @@
1
- import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
2
- import * as dns from "node:dns";
3
- import { WebhookEventTransport, WebhookEndpoint } from "./webhook";
4
- import type { WorkflowEvent } from "./types";
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import * as dns from 'node:dns';
3
+ import { WebhookEventTransport, WebhookEndpoint } from './webhook';
4
+ import type { WorkflowEvent } from './types';
5
5
 
6
- vi.mock("node:dns", () => ({
6
+ vi.mock('node:dns', () => ({
7
7
  promises: {
8
- lookup: vi.fn().mockResolvedValue({ address: "93.184.216.34", family: 4 }),
8
+ lookup: vi.fn().mockResolvedValue({ address: '93.184.216.34', family: 4 }),
9
9
  },
10
10
  }));
11
11
 
12
- function createTestEvent(
13
- overrides: Partial<WorkflowEvent> = {},
14
- ): WorkflowEvent {
12
+ function createTestEvent(overrides: Partial<WorkflowEvent> = {}): WorkflowEvent {
15
13
  return {
16
- eventType: "run.started",
17
- runId: "run-123",
18
- kind: "test.workflow",
19
- timestamp: new Date("2024-01-01T12:00:00Z"),
14
+ eventType: 'run.started',
15
+ runId: 'run-123',
16
+ kind: 'test.workflow',
17
+ timestamp: new Date('2024-01-01T12:00:00Z'),
20
18
  ...overrides,
21
19
  };
22
20
  }
23
21
 
24
- describe("WebhookEventTransport", () => {
22
+ describe('WebhookEventTransport', () => {
25
23
  let transport: WebhookEventTransport;
26
24
  let mockFetch: ReturnType<typeof vi.fn>;
27
25
 
@@ -38,72 +36,72 @@ describe("WebhookEventTransport", () => {
38
36
  transport.close();
39
37
  });
40
38
 
41
- describe("addEndpoint / removeEndpoint", () => {
42
- it("should add an endpoint", () => {
39
+ describe('addEndpoint / removeEndpoint', () => {
40
+ it('should add an endpoint', () => {
43
41
  const endpoint: WebhookEndpoint = {
44
- id: "test-1",
45
- url: "https://example.com/webhook",
42
+ id: 'test-1',
43
+ url: 'https://example.com/webhook',
46
44
  };
47
45
 
48
46
  transport.addEndpoint(endpoint);
49
47
 
50
48
  expect(transport.getEndpoints()).toHaveLength(1);
51
- expect(transport.getEndpoints()[0].id).toBe("test-1");
49
+ expect(transport.getEndpoints()[0].id).toBe('test-1');
52
50
  });
53
51
 
54
- it("should add endpoint with enabled=true by default", () => {
52
+ it('should add endpoint with enabled=true by default', () => {
55
53
  transport.addEndpoint({
56
- id: "test-1",
57
- url: "https://example.com/webhook",
54
+ id: 'test-1',
55
+ url: 'https://example.com/webhook',
58
56
  });
59
57
 
60
58
  expect(transport.getEndpoints()[0].enabled).toBe(true);
61
59
  });
62
60
 
63
- it("should remove an endpoint", () => {
61
+ it('should remove an endpoint', () => {
64
62
  transport.addEndpoint({
65
- id: "test-1",
66
- url: "https://example.com/webhook",
63
+ id: 'test-1',
64
+ url: 'https://example.com/webhook',
67
65
  });
68
66
 
69
- const removed = transport.removeEndpoint("test-1");
67
+ const removed = transport.removeEndpoint('test-1');
70
68
 
71
69
  expect(removed).toBe(true);
72
70
  expect(transport.getEndpoints()).toHaveLength(0);
73
71
  });
74
72
 
75
- it("should return false when removing non-existent endpoint", () => {
76
- const removed = transport.removeEndpoint("nonexistent");
73
+ it('should return false when removing non-existent endpoint', () => {
74
+ const removed = transport.removeEndpoint('nonexistent');
77
75
  expect(removed).toBe(false);
78
76
  });
79
77
  });
80
78
 
81
- describe("setEndpointEnabled", () => {
82
- it("should enable/disable an endpoint", () => {
79
+ describe('setEndpointEnabled', () => {
80
+ it('should enable/disable an endpoint', () => {
83
81
  transport.addEndpoint({
84
- id: "test-1",
85
- url: "https://example.com/webhook",
82
+ id: 'test-1',
83
+ url: 'https://example.com/webhook',
86
84
  enabled: true,
87
85
  });
88
86
 
89
- transport.setEndpointEnabled("test-1", false);
87
+ transport.setEndpointEnabled('test-1', false);
90
88
  expect(transport.getEndpoints()[0].enabled).toBe(false);
91
89
 
92
- transport.setEndpointEnabled("test-1", true);
90
+ transport.setEndpointEnabled('test-1', true);
93
91
  expect(transport.getEndpoints()[0].enabled).toBe(true);
94
92
  });
95
93
 
96
- it("should do nothing for non-existent endpoint", () => {
97
- transport.setEndpointEnabled("nonexistent", false);
94
+ it('should do nothing for non-existent endpoint', () => {
95
+ transport.setEndpointEnabled('nonexistent', false);
98
96
  // Should not throw
99
97
  });
100
98
  });
101
99
 
102
- describe("emit", () => {
103
- it("should send event to enabled endpoints", async () => {
100
+ describe('emit', () => {
101
+ it('should send event to enabled endpoints', async () => {
104
102
  transport.addEndpoint({
105
- id: "test-1",
106
- url: "https://example.com/webhook",
103
+ id: 'test-1',
104
+ url: 'https://example.com/webhook',
107
105
  });
108
106
 
109
107
  const event = createTestEvent();
@@ -114,20 +112,20 @@ describe("WebhookEventTransport", () => {
114
112
 
115
113
  expect(mockFetch).toHaveBeenCalledTimes(1);
116
114
  expect(mockFetch).toHaveBeenCalledWith(
117
- "https://example.com/webhook",
115
+ 'https://example.com/webhook',
118
116
  expect.objectContaining({
119
- method: "POST",
117
+ method: 'POST',
120
118
  headers: expect.objectContaining({
121
- "Content-Type": "application/json",
119
+ 'Content-Type': 'application/json',
122
120
  }),
123
- }),
121
+ })
124
122
  );
125
123
  });
126
124
 
127
- it("should not send to disabled endpoints", async () => {
125
+ it('should not send to disabled endpoints', async () => {
128
126
  transport.addEndpoint({
129
- id: "test-1",
130
- url: "https://example.com/webhook",
127
+ id: 'test-1',
128
+ url: 'https://example.com/webhook',
131
129
  enabled: false,
132
130
  });
133
131
 
@@ -139,11 +137,11 @@ describe("WebhookEventTransport", () => {
139
137
  expect(mockFetch).not.toHaveBeenCalled();
140
138
  });
141
139
 
142
- it("should include custom headers", async () => {
140
+ it('should include custom headers', async () => {
143
141
  transport.addEndpoint({
144
- id: "test-1",
145
- url: "https://example.com/webhook",
146
- headers: { "X-Custom-Header": "custom-value" },
142
+ id: 'test-1',
143
+ url: 'https://example.com/webhook',
144
+ headers: { 'X-Custom-Header': 'custom-value' },
147
145
  });
148
146
 
149
147
  transport.emit(createTestEvent());
@@ -151,18 +149,18 @@ describe("WebhookEventTransport", () => {
151
149
  await new Promise((r) => setTimeout(r, 50));
152
150
 
153
151
  const callArgs = mockFetch.mock.calls[0];
154
- expect(callArgs[1].headers["X-Custom-Header"]).toBe("custom-value");
152
+ expect(callArgs[1].headers['X-Custom-Header']).toBe('custom-value');
155
153
  });
156
154
 
157
- it("should serialize payload correctly", async () => {
155
+ it('should serialize payload correctly', async () => {
158
156
  transport.addEndpoint({
159
- id: "test-1",
160
- url: "https://example.com/webhook",
157
+ id: 'test-1',
158
+ url: 'https://example.com/webhook',
161
159
  });
162
160
 
163
161
  const event = createTestEvent({
164
- runId: "run-456",
165
- kind: "my.workflow",
162
+ runId: 'run-456',
163
+ kind: 'my.workflow',
166
164
  });
167
165
  transport.emit(event);
168
166
 
@@ -171,81 +169,81 @@ describe("WebhookEventTransport", () => {
171
169
  const callArgs = mockFetch.mock.calls[0];
172
170
  const body = JSON.parse(callArgs[1].body);
173
171
 
174
- expect(body.event.runId).toBe("run-456");
175
- expect(body.event.kind).toBe("my.workflow");
176
- expect(body.event.timestamp).toBe("2024-01-01T12:00:00.000Z");
177
- expect(body.webhookId).toBe("test-1");
172
+ expect(body.event.runId).toBe('run-456');
173
+ expect(body.event.kind).toBe('my.workflow');
174
+ expect(body.event.timestamp).toBe('2024-01-01T12:00:00.000Z');
175
+ expect(body.webhookId).toBe('test-1');
178
176
  expect(body.deliveredAt).toBeDefined();
179
177
  });
180
178
  });
181
179
 
182
- describe("event filtering", () => {
183
- it("should filter by event type", async () => {
180
+ describe('event filtering', () => {
181
+ it('should filter by event type', async () => {
184
182
  transport.addEndpoint({
185
- id: "test-1",
186
- url: "https://example.com/webhook",
187
- eventTypes: ["run.completed", "run.failed"],
183
+ id: 'test-1',
184
+ url: 'https://example.com/webhook',
185
+ eventTypes: ['run.completed', 'run.failed'],
188
186
  });
189
187
 
190
188
  // Should be filtered out
191
- transport.emit(createTestEvent({ eventType: "run.started" }));
189
+ transport.emit(createTestEvent({ eventType: 'run.started' }));
192
190
 
193
191
  // Should be sent
194
- transport.emit(createTestEvent({ eventType: "run.completed" }));
192
+ transport.emit(createTestEvent({ eventType: 'run.completed' }));
195
193
 
196
194
  await new Promise((r) => setTimeout(r, 50));
197
195
 
198
196
  expect(mockFetch).toHaveBeenCalledTimes(1);
199
197
  });
200
198
 
201
- it("should filter by workflow kind", async () => {
199
+ it('should filter by workflow kind', async () => {
202
200
  transport.addEndpoint({
203
- id: "test-1",
204
- url: "https://example.com/webhook",
205
- workflowKinds: ["order.process", "order.ship"],
201
+ id: 'test-1',
202
+ url: 'https://example.com/webhook',
203
+ workflowKinds: ['order.process', 'order.ship'],
206
204
  });
207
205
 
208
206
  // Should be filtered out
209
- transport.emit(createTestEvent({ kind: "user.notify" }));
207
+ transport.emit(createTestEvent({ kind: 'user.notify' }));
210
208
 
211
209
  // Should be sent
212
- transport.emit(createTestEvent({ kind: "order.process" }));
210
+ transport.emit(createTestEvent({ kind: 'order.process' }));
213
211
 
214
212
  await new Promise((r) => setTimeout(r, 50));
215
213
 
216
214
  expect(mockFetch).toHaveBeenCalledTimes(1);
217
215
  });
218
216
 
219
- it("should apply both filters (AND logic)", async () => {
217
+ it('should apply both filters (AND logic)', async () => {
220
218
  transport.addEndpoint({
221
- id: "test-1",
222
- url: "https://example.com/webhook",
223
- eventTypes: ["run.completed"],
224
- workflowKinds: ["order.process"],
219
+ id: 'test-1',
220
+ url: 'https://example.com/webhook',
221
+ eventTypes: ['run.completed'],
222
+ workflowKinds: ['order.process'],
225
223
  });
226
224
 
227
225
  // Wrong event type
228
226
  transport.emit(
229
227
  createTestEvent({
230
- eventType: "run.started",
231
- kind: "order.process",
232
- }),
228
+ eventType: 'run.started',
229
+ kind: 'order.process',
230
+ })
233
231
  );
234
232
 
235
233
  // Wrong workflow kind
236
234
  transport.emit(
237
235
  createTestEvent({
238
- eventType: "run.completed",
239
- kind: "user.notify",
240
- }),
236
+ eventType: 'run.completed',
237
+ kind: 'user.notify',
238
+ })
241
239
  );
242
240
 
243
241
  // Both match
244
242
  transport.emit(
245
243
  createTestEvent({
246
- eventType: "run.completed",
247
- kind: "order.process",
248
- }),
244
+ eventType: 'run.completed',
245
+ kind: 'order.process',
246
+ })
249
247
  );
250
248
 
251
249
  await new Promise((r) => setTimeout(r, 50));
@@ -253,14 +251,14 @@ describe("WebhookEventTransport", () => {
253
251
  expect(mockFetch).toHaveBeenCalledTimes(1);
254
252
  });
255
253
 
256
- it("should send to all endpoints when no filters", async () => {
254
+ it('should send to all endpoints when no filters', async () => {
257
255
  transport.addEndpoint({
258
- id: "test-1",
259
- url: "https://example.com/webhook1",
256
+ id: 'test-1',
257
+ url: 'https://example.com/webhook1',
260
258
  });
261
259
  transport.addEndpoint({
262
- id: "test-2",
263
- url: "https://example.com/webhook2",
260
+ id: 'test-2',
261
+ url: 'https://example.com/webhook2',
264
262
  });
265
263
 
266
264
  transport.emit(createTestEvent());
@@ -271,12 +269,12 @@ describe("WebhookEventTransport", () => {
271
269
  });
272
270
  });
273
271
 
274
- describe("HMAC signing", () => {
275
- it("should sign payload when secret is provided", async () => {
272
+ describe('HMAC signing', () => {
273
+ it('should sign payload when secret is provided', async () => {
276
274
  transport.addEndpoint({
277
- id: "test-1",
278
- url: "https://example.com/webhook",
279
- secret: "my-secret-key",
275
+ id: 'test-1',
276
+ url: 'https://example.com/webhook',
277
+ secret: 'my-secret-key',
280
278
  });
281
279
 
282
280
  transport.emit(createTestEvent());
@@ -284,16 +282,16 @@ describe("WebhookEventTransport", () => {
284
282
  await new Promise((r) => setTimeout(r, 50));
285
283
 
286
284
  const callArgs = mockFetch.mock.calls[0];
287
- const signature = callArgs[1].headers["X-Webhook-Signature"];
285
+ const signature = callArgs[1].headers['X-Webhook-Signature'];
288
286
 
289
287
  expect(signature).toBeDefined();
290
288
  expect(signature).toMatch(/^sha256=[a-f0-9]{64}$/);
291
289
  });
292
290
 
293
- it("should not include signature header when no secret", async () => {
291
+ it('should not include signature header when no secret', async () => {
294
292
  transport.addEndpoint({
295
- id: "test-1",
296
- url: "https://example.com/webhook",
293
+ id: 'test-1',
294
+ url: 'https://example.com/webhook',
297
295
  });
298
296
 
299
297
  transport.emit(createTestEvent());
@@ -301,15 +299,15 @@ describe("WebhookEventTransport", () => {
301
299
  await new Promise((r) => setTimeout(r, 50));
302
300
 
303
301
  const callArgs = mockFetch.mock.calls[0];
304
- expect(callArgs[1].headers["X-Webhook-Signature"]).toBeUndefined();
302
+ expect(callArgs[1].headers['X-Webhook-Signature']).toBeUndefined();
305
303
  });
306
304
  });
307
305
 
308
- describe("retries", () => {
309
- it("should retry on failure", async () => {
306
+ describe('retries', () => {
307
+ it('should retry on failure', async () => {
310
308
  mockFetch
311
- .mockRejectedValueOnce(new Error("Network error"))
312
- .mockRejectedValueOnce(new Error("Network error"))
309
+ .mockRejectedValueOnce(new Error('Network error'))
310
+ .mockRejectedValueOnce(new Error('Network error'))
313
311
  .mockResolvedValueOnce({ ok: true });
314
312
 
315
313
  transport = new WebhookEventTransport({
@@ -320,8 +318,8 @@ describe("WebhookEventTransport", () => {
320
318
  });
321
319
 
322
320
  transport.addEndpoint({
323
- id: "test-1",
324
- url: "https://example.com/webhook",
321
+ id: 'test-1',
322
+ url: 'https://example.com/webhook',
325
323
  });
326
324
 
327
325
  transport.emit(createTestEvent());
@@ -332,13 +330,9 @@ describe("WebhookEventTransport", () => {
332
330
  expect(mockFetch).toHaveBeenCalledTimes(3);
333
331
  });
334
332
 
335
- it("should retry on non-ok response", async () => {
333
+ it('should retry on non-ok response', async () => {
336
334
  mockFetch
337
- .mockResolvedValueOnce({
338
- ok: false,
339
- status: 500,
340
- statusText: "Internal Server Error",
341
- })
335
+ .mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error' })
342
336
  .mockResolvedValueOnce({ ok: true });
343
337
 
344
338
  transport = new WebhookEventTransport({
@@ -349,8 +343,8 @@ describe("WebhookEventTransport", () => {
349
343
  });
350
344
 
351
345
  transport.addEndpoint({
352
- id: "test-1",
353
- url: "https://example.com/webhook",
346
+ id: 'test-1',
347
+ url: 'https://example.com/webhook',
354
348
  });
355
349
 
356
350
  transport.emit(createTestEvent());
@@ -361,72 +355,72 @@ describe("WebhookEventTransport", () => {
361
355
  });
362
356
  });
363
357
 
364
- describe("subscribe / subscribeAll", () => {
365
- it("should call local subscribers on emit", () => {
358
+ describe('subscribe / subscribeAll', () => {
359
+ it('should call local subscribers on emit', () => {
366
360
  const callback = vi.fn();
367
- transport.subscribe("run-123", callback);
361
+ transport.subscribe('run-123', callback);
368
362
 
369
- const event = createTestEvent({ runId: "run-123" });
363
+ const event = createTestEvent({ runId: 'run-123' });
370
364
  transport.emit(event);
371
365
 
372
366
  expect(callback).toHaveBeenCalledWith(event);
373
367
  });
374
368
 
375
- it("should not call subscriber for different runId", () => {
369
+ it('should not call subscriber for different runId', () => {
376
370
  const callback = vi.fn();
377
- transport.subscribe("run-123", callback);
371
+ transport.subscribe('run-123', callback);
378
372
 
379
- const event = createTestEvent({ runId: "run-456" });
373
+ const event = createTestEvent({ runId: 'run-456' });
380
374
  transport.emit(event);
381
375
 
382
376
  expect(callback).not.toHaveBeenCalled();
383
377
  });
384
378
 
385
- it("should call global subscribers for all events", () => {
379
+ it('should call global subscribers for all events', () => {
386
380
  const callback = vi.fn();
387
381
  transport.subscribeAll(callback);
388
382
 
389
- transport.emit(createTestEvent({ runId: "run-1" }));
390
- transport.emit(createTestEvent({ runId: "run-2" }));
383
+ transport.emit(createTestEvent({ runId: 'run-1' }));
384
+ transport.emit(createTestEvent({ runId: 'run-2' }));
391
385
 
392
386
  expect(callback).toHaveBeenCalledTimes(2);
393
387
  });
394
388
 
395
- it("should unsubscribe correctly", () => {
389
+ it('should unsubscribe correctly', () => {
396
390
  const callback = vi.fn();
397
- const unsubscribe = transport.subscribe("run-123", callback);
391
+ const unsubscribe = transport.subscribe('run-123', callback);
398
392
 
399
393
  unsubscribe();
400
394
 
401
- transport.emit(createTestEvent({ runId: "run-123" }));
395
+ transport.emit(createTestEvent({ runId: 'run-123' }));
402
396
  expect(callback).not.toHaveBeenCalled();
403
397
  });
404
398
 
405
- it("should handle callback errors gracefully", () => {
399
+ it('should handle callback errors gracefully', () => {
406
400
  const errorCallback = vi.fn().mockImplementation(() => {
407
- throw new Error("Callback error");
401
+ throw new Error('Callback error');
408
402
  });
409
403
  const normalCallback = vi.fn();
410
404
 
411
- transport.subscribe("run-123", errorCallback);
412
- transport.subscribe("run-123", normalCallback);
405
+ transport.subscribe('run-123', errorCallback);
406
+ transport.subscribe('run-123', normalCallback);
413
407
 
414
- transport.emit(createTestEvent({ runId: "run-123" }));
408
+ transport.emit(createTestEvent({ runId: 'run-123' }));
415
409
 
416
410
  expect(errorCallback).toHaveBeenCalled();
417
411
  expect(normalCallback).toHaveBeenCalled();
418
412
  });
419
413
  });
420
414
 
421
- describe("close", () => {
422
- it("should clear all endpoints and subscribers", () => {
415
+ describe('close', () => {
416
+ it('should clear all endpoints and subscribers', () => {
423
417
  transport.addEndpoint({
424
- id: "test-1",
425
- url: "https://example.com/webhook",
418
+ id: 'test-1',
419
+ url: 'https://example.com/webhook',
426
420
  });
427
421
 
428
422
  const callback = vi.fn();
429
- transport.subscribe("run-123", callback);
423
+ transport.subscribe('run-123', callback);
430
424
  transport.subscribeAll(vi.fn());
431
425
 
432
426
  transport.close();
@@ -434,91 +428,346 @@ describe("WebhookEventTransport", () => {
434
428
  expect(transport.getEndpoints()).toHaveLength(0);
435
429
 
436
430
  // Callback should not be called after close
437
- transport.emit(createTestEvent({ runId: "run-123" }));
431
+ transport.emit(createTestEvent({ runId: 'run-123' }));
438
432
  expect(callback).not.toHaveBeenCalled();
439
433
  });
440
434
  });
441
435
 
442
- describe("SSRF protection - isBlockedHost", () => {
443
- it("should block 10.x.x.x private range", () => {
436
+ describe('SSRF protection - isBlockedHost', () => {
437
+ it('should block 10.x.x.x private range', () => {
444
438
  expect(() =>
445
- transport.addEndpoint({ id: "ssrf-1", url: "https://10.0.0.1/hook" }),
439
+ transport.addEndpoint({ id: 'ssrf-1', url: 'https://10.0.0.1/hook' })
446
440
  ).toThrow(/blocked/);
447
441
  });
448
442
 
449
- it("should block 172.16-31.x.x private range", () => {
443
+ it('should block 172.16-31.x.x private range', () => {
450
444
  expect(() =>
451
- transport.addEndpoint({ id: "ssrf-2", url: "https://172.16.0.1/hook" }),
445
+ transport.addEndpoint({ id: 'ssrf-2', url: 'https://172.16.0.1/hook' })
452
446
  ).toThrow(/blocked/);
453
447
  expect(() =>
454
- transport.addEndpoint({
455
- id: "ssrf-3",
456
- url: "https://172.31.255.255/hook",
457
- }),
448
+ transport.addEndpoint({ id: 'ssrf-3', url: 'https://172.31.255.255/hook' })
458
449
  ).toThrow(/blocked/);
459
450
  // 172.32 should NOT be blocked
460
- transport.addEndpoint({ id: "ssrf-ok", url: "https://172.32.0.1/hook" });
461
- expect(
462
- transport.getEndpoints().find((e) => e.id === "ssrf-ok"),
463
- ).toBeDefined();
451
+ transport.addEndpoint({ id: 'ssrf-ok', url: 'https://172.32.0.1/hook' });
452
+ expect(transport.getEndpoints().find(e => e.id === 'ssrf-ok')).toBeDefined();
464
453
  });
465
454
 
466
- it("should block 192.168.x.x private range", () => {
455
+ it('should block 192.168.x.x private range', () => {
467
456
  expect(() =>
468
- transport.addEndpoint({
469
- id: "ssrf-4",
470
- url: "https://192.168.1.1/hook",
471
- }),
457
+ transport.addEndpoint({ id: 'ssrf-4', url: 'https://192.168.1.1/hook' })
472
458
  ).toThrow(/blocked/);
473
459
  });
474
460
 
475
- it("should block 169.254.x.x link-local range", () => {
461
+ it('should block 169.254.x.x link-local range', () => {
476
462
  expect(() =>
477
- transport.addEndpoint({
478
- id: "ssrf-5",
479
- url: "https://169.254.1.1/hook",
480
- }),
463
+ transport.addEndpoint({ id: 'ssrf-5', url: 'https://169.254.1.1/hook' })
481
464
  ).toThrow(/blocked/);
482
465
  });
483
466
 
484
- it("should block 0.0.0.0", () => {
467
+ it('should block 0.0.0.0', () => {
485
468
  expect(() =>
486
- transport.addEndpoint({ id: "ssrf-6", url: "https://0.0.0.0/hook" }),
469
+ transport.addEndpoint({ id: 'ssrf-6', url: 'https://0.0.0.0/hook' })
487
470
  ).toThrow(/blocked/);
488
471
  });
489
472
 
490
- it("should block IPv6 link-local (fe80::)", () => {
473
+ it('should block IPv6 link-local (fe80::)', () => {
491
474
  expect(() =>
492
- transport.addEndpoint({ id: "ssrf-7", url: "https://[fe80::1]/hook" }),
475
+ transport.addEndpoint({ id: 'ssrf-7', url: 'https://[fe80::1]/hook' })
493
476
  ).toThrow(/blocked/);
494
477
  });
495
478
 
496
- it("should block localhost and 127.x.x.x", () => {
479
+ it('should block localhost and 127.x.x.x', () => {
497
480
  expect(() =>
498
- transport.addEndpoint({ id: "ssrf-8", url: "https://localhost/hook" }),
481
+ transport.addEndpoint({ id: 'ssrf-8', url: 'https://localhost/hook' })
499
482
  ).toThrow(/blocked/);
500
483
  expect(() =>
501
- transport.addEndpoint({ id: "ssrf-9", url: "https://127.0.0.1/hook" }),
484
+ transport.addEndpoint({ id: 'ssrf-9', url: 'https://127.0.0.1/hook' })
502
485
  ).toThrow(/blocked/);
503
486
  expect(() =>
504
- transport.addEndpoint({ id: "ssrf-10", url: "https://127.0.0.2/hook" }),
487
+ transport.addEndpoint({ id: 'ssrf-10', url: 'https://127.0.0.2/hook' })
505
488
  ).toThrow(/blocked/);
506
489
  });
507
490
 
508
- it("should block cloud metadata endpoint", () => {
491
+ it('should block cloud metadata endpoint', () => {
509
492
  expect(() =>
510
- transport.addEndpoint({
511
- id: "ssrf-11",
512
- url: "https://169.254.169.254/latest/meta-data",
513
- }),
493
+ transport.addEndpoint({ id: 'ssrf-11', url: 'https://169.254.169.254/latest/meta-data' })
514
494
  ).toThrow(/blocked/);
515
495
  });
516
496
  });
517
497
 
518
- describe("emit subscriber error isolation", () => {
519
- it("should catch subscriber error and still call other subscribers", () => {
498
+ describe('SSRF protection - IPv6 coverage', () => {
499
+ it('should block IPv6 loopback ::1 (in any abbreviated form)', () => {
500
+ expect(() =>
501
+ transport.addEndpoint({ id: 'v6-loop-1', url: 'https://[::1]/hook' })
502
+ ).toThrow(/blocked/);
503
+ expect(() =>
504
+ transport.addEndpoint({ id: 'v6-loop-2', url: 'https://[0000:0000:0000:0000:0000:0000:0000:0001]/hook' })
505
+ ).toThrow(/blocked/);
506
+ });
507
+
508
+ it('should block IPv6 unspecified ::', () => {
509
+ expect(() =>
510
+ transport.addEndpoint({ id: 'v6-unspec', url: 'https://[::]/hook' })
511
+ ).toThrow(/blocked/);
512
+ });
513
+
514
+ it('should block link-local fe80::/10 across the full range', () => {
515
+ expect(() =>
516
+ transport.addEndpoint({ id: 'v6-ll-1', url: 'https://[fe80::1]/hook' })
517
+ ).toThrow(/blocked/);
518
+ expect(() =>
519
+ transport.addEndpoint({ id: 'v6-ll-2', url: 'https://[febf::abcd]/hook' })
520
+ ).toThrow(/blocked/);
521
+ // fec0:: is NOT link-local (deprecated site-local), should not be caught by fe80::/10
522
+ // but fe7f:: is below the range and should be allowed
523
+ transport.addEndpoint({ id: 'v6-ll-ok', url: 'https://[fe7f::1]/hook' });
524
+ expect(transport.getEndpoints().find(e => e.id === 'v6-ll-ok')).toBeDefined();
525
+ });
526
+
527
+ it('should block ULA fc00::/7 (fc and fd prefixes)', () => {
528
+ expect(() =>
529
+ transport.addEndpoint({ id: 'v6-ula-1', url: 'https://[fc00::1]/hook' })
530
+ ).toThrow(/blocked/);
531
+ expect(() =>
532
+ transport.addEndpoint({ id: 'v6-ula-2', url: 'https://[fd12:3456::abcd]/hook' })
533
+ ).toThrow(/blocked/);
534
+ expect(() =>
535
+ transport.addEndpoint({ id: 'v6-ula-3', url: 'https://[fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]/hook' })
536
+ ).toThrow(/blocked/);
537
+ });
538
+
539
+ it('should block IPv4-mapped ::ffff:0:0/96 by decoding embedded IPv4', () => {
540
+ // ::ffff:127.0.0.1 → loopback
541
+ expect(() =>
542
+ transport.addEndpoint({ id: 'v6-map-1', url: 'https://[::ffff:127.0.0.1]/hook' })
543
+ ).toThrow(/blocked/);
544
+ // ::ffff:10.0.0.1 → RFC 1918
545
+ expect(() =>
546
+ transport.addEndpoint({ id: 'v6-map-2', url: 'https://[::ffff:10.0.0.1]/hook' })
547
+ ).toThrow(/blocked/);
548
+ // ::ffff:169.254.169.254 → cloud metadata
549
+ expect(() =>
550
+ transport.addEndpoint({ id: 'v6-map-3', url: 'https://[::ffff:169.254.169.254]/hook' })
551
+ ).toThrow(/blocked/);
552
+ // Hex form ::ffff:c0a8:0001 == ::ffff:192.168.0.1
553
+ expect(() =>
554
+ transport.addEndpoint({ id: 'v6-map-4', url: 'https://[::ffff:c0a8:0001]/hook' })
555
+ ).toThrow(/blocked/);
556
+ });
557
+
558
+ it('should block 6to4 2002::/16 by decoding embedded IPv4', () => {
559
+ // 2002:7f00:0001:: == 6to4 wrapping 127.0.0.1
560
+ expect(() =>
561
+ transport.addEndpoint({ id: 'v6-6to4-loop', url: 'https://[2002:7f00:0001::]/hook' })
562
+ ).toThrow(/blocked/);
563
+ // 2002:c0a8:0001:: == 6to4 wrapping 192.168.0.1
564
+ expect(() =>
565
+ transport.addEndpoint({ id: 'v6-6to4-1918', url: 'https://[2002:c0a8:0001::]/hook' })
566
+ ).toThrow(/blocked/);
567
+ });
568
+
569
+ it('should block Teredo 2001::/32', () => {
570
+ expect(() =>
571
+ transport.addEndpoint({ id: 'v6-teredo', url: 'https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/hook' })
572
+ ).toThrow(/blocked/);
573
+ });
574
+
575
+ it('should block IPv6 multicast ff00::/8', () => {
576
+ expect(() =>
577
+ transport.addEndpoint({ id: 'v6-mc', url: 'https://[ff02::1]/hook' })
578
+ ).toThrow(/blocked/);
579
+ });
580
+
581
+ it('should allow legitimate public IPv6 addresses', () => {
582
+ // Google Public DNS — must not be blocked
583
+ transport.addEndpoint({ id: 'v6-pub-1', url: 'https://[2001:4860:4860::8888]/hook' });
584
+ // Cloudflare DNS
585
+ transport.addEndpoint({ id: 'v6-pub-2', url: 'https://[2606:4700:4700::1111]/hook' });
586
+ expect(transport.getEndpoints().find(e => e.id === 'v6-pub-1')).toBeDefined();
587
+ expect(transport.getEndpoints().find(e => e.id === 'v6-pub-2')).toBeDefined();
588
+ });
589
+
590
+ it('should block when DNS resolves to an IPv6 ULA address', async () => {
591
+ const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
592
+ dnsLookup.mockResolvedValueOnce({ address: 'fd00::1', family: 6 });
593
+
594
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
595
+ transport = new WebhookEventTransport({
596
+ fetchFn: mockFetch,
597
+ defaultRetries: 0,
598
+ logger,
599
+ });
600
+
601
+ transport.addEndpoint({
602
+ id: 'v6-dns-ula',
603
+ url: 'https://internal.example.com/hook',
604
+ });
605
+
606
+ transport.emit(createTestEvent());
607
+ await new Promise((r) => setTimeout(r, 50));
608
+
609
+ expect(mockFetch).not.toHaveBeenCalled();
610
+ expect(logger.error).toHaveBeenCalledWith(
611
+ expect.stringContaining('v6-dns-ula'),
612
+ expect.objectContaining({
613
+ message: expect.stringContaining('resolves to blocked IP'),
614
+ })
615
+ );
616
+ });
617
+
618
+ it('should block when DNS resolves to an IPv4-mapped IPv6 loopback', async () => {
619
+ const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
620
+ dnsLookup.mockResolvedValueOnce({ address: '::ffff:127.0.0.1', family: 6 });
621
+
622
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
623
+ transport = new WebhookEventTransport({
624
+ fetchFn: mockFetch,
625
+ defaultRetries: 0,
626
+ logger,
627
+ });
628
+
629
+ transport.addEndpoint({
630
+ id: 'v6-dns-mapped',
631
+ url: 'https://sneaky.example.com/hook',
632
+ });
633
+
634
+ transport.emit(createTestEvent());
635
+ await new Promise((r) => setTimeout(r, 50));
636
+
637
+ expect(mockFetch).not.toHaveBeenCalled();
638
+ });
639
+ });
640
+
641
+ describe('queue back-pressure (memory exhaustion guard)', () => {
642
+ it('should drop deliveries beyond maxQueueDepth and log a warning', async () => {
643
+ // Hold every fetch open so the active slot fills and subsequent emits queue.
644
+ let resolveFetch: ((v: { ok: true }) => void) | undefined;
645
+ const blockingFetch = vi.fn().mockImplementation(
646
+ () => new Promise<{ ok: true }>((r) => { resolveFetch = r; })
647
+ );
648
+
649
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
650
+
651
+ transport = new WebhookEventTransport({
652
+ fetchFn: blockingFetch as unknown as typeof fetch,
653
+ defaultRetries: 0,
654
+ maxConcurrentRequests: 1,
655
+ maxQueueDepth: 2,
656
+ logger,
657
+ });
658
+
659
+ transport.addEndpoint({ id: 'cap-test', url: 'https://example.com/hook' });
660
+
661
+ // 1st emit → consumes the active slot.
662
+ // 2nd, 3rd emit → fill the queue (depth 2/2).
663
+ // 4th, 5th emit → must be dropped.
664
+ for (let i = 0; i < 5; i++) {
665
+ transport.emit(createTestEvent({ runId: `run-${i}` }));
666
+ }
667
+
668
+ // Let the dispatch loop run.
669
+ await new Promise((r) => setTimeout(r, 10));
670
+
671
+ expect(transport.getDroppedRequestCount()).toBe(2);
672
+ expect(transport.getQueueDepth()).toBe(2);
673
+ expect(logger.warn).toHaveBeenCalledTimes(2);
674
+ expect(logger.warn).toHaveBeenCalledWith(
675
+ expect.stringMatching(/cap-test delivery dropped: queue full/)
676
+ );
677
+ expect(logger.warn).toHaveBeenCalledWith(
678
+ expect.stringMatching(/retryAfterMs 1000/)
679
+ );
680
+
681
+ // Drain the in-flight request so .close() doesn't hang.
682
+ resolveFetch?.({ ok: true });
683
+ await new Promise((r) => setTimeout(r, 20));
684
+ });
685
+
686
+ it('should resume accepting deliveries after the queue drains', async () => {
687
+ let pendingResolvers: Array<(v: { ok: true }) => void> = [];
688
+ const blockingFetch = vi.fn().mockImplementation(
689
+ () => new Promise<{ ok: true }>((r) => { pendingResolvers.push(r); })
690
+ );
691
+
692
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
693
+
694
+ transport = new WebhookEventTransport({
695
+ fetchFn: blockingFetch as unknown as typeof fetch,
696
+ defaultRetries: 0,
697
+ maxConcurrentRequests: 1,
698
+ maxQueueDepth: 1,
699
+ logger,
700
+ });
701
+
702
+ transport.addEndpoint({ id: 'drain', url: 'https://example.com/hook' });
703
+
704
+ // 1 active + 1 queued + 1 dropped
705
+ transport.emit(createTestEvent({ runId: 'a' }));
706
+ transport.emit(createTestEvent({ runId: 'b' }));
707
+ transport.emit(createTestEvent({ runId: 'c' }));
708
+ await new Promise((r) => setTimeout(r, 10));
709
+ expect(transport.getDroppedRequestCount()).toBe(1);
710
+
711
+ // Drain everything in flight.
712
+ while (pendingResolvers.length > 0) {
713
+ const next = pendingResolvers.shift()!;
714
+ next({ ok: true });
715
+ await new Promise((r) => setTimeout(r, 5));
716
+ }
717
+ // Allow any newly-promoted queued requests to start and resolve.
718
+ while (pendingResolvers.length > 0) {
719
+ const next = pendingResolvers.shift()!;
720
+ next({ ok: true });
721
+ await new Promise((r) => setTimeout(r, 5));
722
+ }
723
+
724
+ // Now a new emit should be accepted, not dropped.
725
+ transport.emit(createTestEvent({ runId: 'd' }));
726
+ await new Promise((r) => setTimeout(r, 10));
727
+ expect(transport.getDroppedRequestCount()).toBe(1); // unchanged
728
+ });
729
+
730
+ it('should treat maxQueueDepth: 0 as unbounded', async () => {
731
+ let resolveFetch: ((v: { ok: true }) => void) | undefined;
732
+ const blockingFetch = vi.fn().mockImplementation(
733
+ () => new Promise<{ ok: true }>((r) => { resolveFetch = r; })
734
+ );
735
+
736
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
737
+
738
+ transport = new WebhookEventTransport({
739
+ fetchFn: blockingFetch as unknown as typeof fetch,
740
+ defaultRetries: 0,
741
+ maxConcurrentRequests: 1,
742
+ maxQueueDepth: 0, // explicit opt-out
743
+ logger,
744
+ });
745
+
746
+ transport.addEndpoint({ id: 'no-cap', url: 'https://example.com/hook' });
747
+
748
+ for (let i = 0; i < 50; i++) {
749
+ transport.emit(createTestEvent({ runId: `run-${i}` }));
750
+ }
751
+ await new Promise((r) => setTimeout(r, 10));
752
+
753
+ expect(transport.getDroppedRequestCount()).toBe(0);
754
+ expect(logger.warn).not.toHaveBeenCalled();
755
+
756
+ resolveFetch?.({ ok: true });
757
+ await new Promise((r) => setTimeout(r, 20));
758
+ });
759
+
760
+ it('should reject negative maxQueueDepth at construction', () => {
761
+ expect(
762
+ () => new WebhookEventTransport({ maxQueueDepth: -1 })
763
+ ).toThrow(/maxQueueDepth/);
764
+ });
765
+ });
766
+
767
+ describe('emit subscriber error isolation', () => {
768
+ it('should catch subscriber error and still call other subscribers', () => {
520
769
  const errorCallback = vi.fn().mockImplementation(() => {
521
- throw new Error("Subscriber exploded");
770
+ throw new Error('Subscriber exploded');
522
771
  });
523
772
  const normalCallback = vi.fn();
524
773
 
@@ -532,16 +781,16 @@ describe("WebhookEventTransport", () => {
532
781
  expect(normalCallback).toHaveBeenCalledWith(event);
533
782
  });
534
783
 
535
- it("should catch run-subscriber error and still call other run-subscribers", () => {
784
+ it('should catch run-subscriber error and still call other run-subscribers', () => {
536
785
  const errorCallback = vi.fn().mockImplementation(() => {
537
- throw new Error("Run subscriber failed");
786
+ throw new Error('Run subscriber failed');
538
787
  });
539
788
  const normalCallback = vi.fn();
540
789
 
541
- transport.subscribe("run-123", errorCallback);
542
- transport.subscribe("run-123", normalCallback);
790
+ transport.subscribe('run-123', errorCallback);
791
+ transport.subscribe('run-123', normalCallback);
543
792
 
544
- const event = createTestEvent({ runId: "run-123" });
793
+ const event = createTestEvent({ runId: 'run-123' });
545
794
  transport.emit(event);
546
795
 
547
796
  expect(errorCallback).toHaveBeenCalled();
@@ -549,12 +798,12 @@ describe("WebhookEventTransport", () => {
549
798
  });
550
799
  });
551
800
 
552
- describe("close clears all state", () => {
553
- it("should clear endpoints, run subscribers, and global subscribers after close", () => {
554
- transport.addEndpoint({ id: "ep-1", url: "https://example.com/hook" });
801
+ describe('close clears all state', () => {
802
+ it('should clear endpoints, run subscribers, and global subscribers after close', () => {
803
+ transport.addEndpoint({ id: 'ep-1', url: 'https://example.com/hook' });
555
804
  const runCb = vi.fn();
556
805
  const globalCb = vi.fn();
557
- transport.subscribe("run-123", runCb);
806
+ transport.subscribe('run-123', runCb);
558
807
  transport.subscribeAll(globalCb);
559
808
 
560
809
  transport.close();
@@ -562,19 +811,19 @@ describe("WebhookEventTransport", () => {
562
811
  expect(transport.getEndpoints()).toHaveLength(0);
563
812
 
564
813
  // Subscribers should no longer receive events
565
- transport.emit(createTestEvent({ runId: "run-123" }));
814
+ transport.emit(createTestEvent({ runId: 'run-123' }));
566
815
  expect(runCb).not.toHaveBeenCalled();
567
816
  expect(globalCb).not.toHaveBeenCalled();
568
817
  });
569
818
  });
570
819
 
571
- describe("constructor with initial endpoints", () => {
572
- it("should register initial endpoints", () => {
820
+ describe('constructor with initial endpoints', () => {
821
+ it('should register initial endpoints', () => {
573
822
  transport = new WebhookEventTransport({
574
823
  fetchFn: mockFetch,
575
824
  endpoints: [
576
- { id: "ep-1", url: "https://example.com/1" },
577
- { id: "ep-2", url: "https://example.com/2" },
825
+ { id: 'ep-1', url: 'https://example.com/1' },
826
+ { id: 'ep-2', url: 'https://example.com/2' },
578
827
  ],
579
828
  });
580
829
 
@@ -582,17 +831,12 @@ describe("WebhookEventTransport", () => {
582
831
  });
583
832
  });
584
833
 
585
- describe("SSRF protection - DNS resolution", () => {
586
- it("should block webhooks when hostname resolves to private IP", async () => {
834
+ describe('SSRF protection - DNS resolution', () => {
835
+ it('should block webhooks when hostname resolves to private IP', async () => {
587
836
  const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
588
- dnsLookup.mockResolvedValueOnce({ address: "10.0.0.5", family: 4 });
837
+ dnsLookup.mockResolvedValueOnce({ address: '10.0.0.5', family: 4 });
589
838
 
590
- const logger = {
591
- debug: vi.fn(),
592
- info: vi.fn(),
593
- warn: vi.fn(),
594
- error: vi.fn(),
595
- };
839
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
596
840
  transport = new WebhookEventTransport({
597
841
  fetchFn: mockFetch,
598
842
  defaultRetries: 0,
@@ -600,8 +844,8 @@ describe("WebhookEventTransport", () => {
600
844
  });
601
845
 
602
846
  transport.addEndpoint({
603
- id: "ssrf-dns",
604
- url: "https://evil.example.com/hook",
847
+ id: 'ssrf-dns',
848
+ url: 'https://evil.example.com/hook',
605
849
  });
606
850
 
607
851
  transport.emit(createTestEvent());
@@ -610,23 +854,18 @@ describe("WebhookEventTransport", () => {
610
854
 
611
855
  expect(mockFetch).not.toHaveBeenCalled();
612
856
  expect(logger.error).toHaveBeenCalledWith(
613
- expect.stringContaining("ssrf-dns"),
857
+ expect.stringContaining('ssrf-dns'),
614
858
  expect.objectContaining({
615
- message: expect.stringContaining("resolves to blocked IP"),
616
- }),
859
+ message: expect.stringContaining('resolves to blocked IP'),
860
+ })
617
861
  );
618
862
  });
619
863
 
620
- it("should block webhooks when hostname resolves to loopback", async () => {
864
+ it('should block webhooks when hostname resolves to loopback', async () => {
621
865
  const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
622
- dnsLookup.mockResolvedValueOnce({ address: "127.0.0.1", family: 4 });
866
+ dnsLookup.mockResolvedValueOnce({ address: '127.0.0.1', family: 4 });
623
867
 
624
- const logger = {
625
- debug: vi.fn(),
626
- info: vi.fn(),
627
- warn: vi.fn(),
628
- error: vi.fn(),
629
- };
868
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
630
869
  transport = new WebhookEventTransport({
631
870
  fetchFn: mockFetch,
632
871
  defaultRetries: 0,
@@ -634,8 +873,8 @@ describe("WebhookEventTransport", () => {
634
873
  });
635
874
 
636
875
  transport.addEndpoint({
637
- id: "ssrf-loopback",
638
- url: "https://loopback.example.com/hook",
876
+ id: 'ssrf-loopback',
877
+ url: 'https://loopback.example.com/hook',
639
878
  });
640
879
 
641
880
  transport.emit(createTestEvent());
@@ -645,19 +884,11 @@ describe("WebhookEventTransport", () => {
645
884
  expect(mockFetch).not.toHaveBeenCalled();
646
885
  });
647
886
 
648
- it("should block webhooks when hostname resolves to cloud metadata IP", async () => {
887
+ it('should block webhooks when hostname resolves to cloud metadata IP', async () => {
649
888
  const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
650
- dnsLookup.mockResolvedValueOnce({
651
- address: "169.254.169.254",
652
- family: 4,
653
- });
889
+ dnsLookup.mockResolvedValueOnce({ address: '169.254.169.254', family: 4 });
654
890
 
655
- const logger = {
656
- debug: vi.fn(),
657
- info: vi.fn(),
658
- warn: vi.fn(),
659
- error: vi.fn(),
660
- };
891
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
661
892
  transport = new WebhookEventTransport({
662
893
  fetchFn: mockFetch,
663
894
  defaultRetries: 0,
@@ -665,8 +896,8 @@ describe("WebhookEventTransport", () => {
665
896
  });
666
897
 
667
898
  transport.addEndpoint({
668
- id: "ssrf-metadata",
669
- url: "https://metadata.example.com/hook",
899
+ id: 'ssrf-metadata',
900
+ url: 'https://metadata.example.com/hook',
670
901
  });
671
902
 
672
903
  transport.emit(createTestEvent());
@@ -676,13 +907,35 @@ describe("WebhookEventTransport", () => {
676
907
  expect(mockFetch).not.toHaveBeenCalled();
677
908
  });
678
909
 
679
- it("should allow webhooks when hostname resolves to public IP", async () => {
910
+ it('should allow webhooks when hostname resolves to public IP', async () => {
911
+ const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
912
+ dnsLookup.mockResolvedValueOnce({ address: '93.184.216.34', family: 4 });
913
+
914
+ transport.addEndpoint({
915
+ id: 'public-ok',
916
+ url: 'https://example.com/hook',
917
+ });
918
+
919
+ transport.emit(createTestEvent());
920
+
921
+ await new Promise((r) => setTimeout(r, 50));
922
+
923
+ expect(mockFetch).toHaveBeenCalledTimes(1);
924
+ });
925
+
926
+ it('resolves the hostname exactly once per webhook send (no rebinding TOCTOU window)', async () => {
927
+ // Audit M1: the previous impl validated DNS once and then handed the
928
+ // hostname to fetch, which re-resolved at connect time. Two lookups in
929
+ // the same delivery is the symptom we're guarding against — assert the
930
+ // counter does not advance past one when both validation and connect
931
+ // run in the same delivery.
680
932
  const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
681
- dnsLookup.mockResolvedValueOnce({ address: "93.184.216.34", family: 4 });
933
+ dnsLookup.mockClear();
934
+ dnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
682
935
 
683
936
  transport.addEndpoint({
684
- id: "public-ok",
685
- url: "https://example.com/hook",
937
+ id: 'one-lookup',
938
+ url: 'https://example.com/hook',
686
939
  });
687
940
 
688
941
  transport.emit(createTestEvent());
@@ -690,6 +943,7 @@ describe("WebhookEventTransport", () => {
690
943
  await new Promise((r) => setTimeout(r, 50));
691
944
 
692
945
  expect(mockFetch).toHaveBeenCalledTimes(1);
946
+ expect(dnsLookup).toHaveBeenCalledTimes(1);
693
947
  });
694
948
  });
695
949
  });