@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,30 +1,23 @@
1
- import { describe, it, expect, vi } from "vitest";
2
- import {
3
- sleep,
4
- withRetry,
5
- calculateRetryDelay,
6
- DEFAULT_RETRY_OPTIONS,
7
- } from "./retry";
8
- import { WorkflowCanceledError } from "./errors";
9
-
10
- describe("sleep", () => {
11
- it("should resolve after the given time", async () => {
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { sleep, withRetry, calculateRetryDelay, DEFAULT_RETRY_OPTIONS } from './retry';
3
+ import { WorkflowCanceledError } from './errors';
4
+
5
+ describe('sleep', () => {
6
+ it('should resolve after the given time', async () => {
12
7
  const start = Date.now();
13
8
  await sleep(50);
14
9
  const elapsed = Date.now() - start;
15
10
  expect(elapsed).toBeGreaterThanOrEqual(40);
16
11
  });
17
12
 
18
- it("should reject immediately if signal is already aborted", async () => {
13
+ it('should reject immediately if signal is already aborted', async () => {
19
14
  const controller = new AbortController();
20
15
  controller.abort();
21
16
 
22
- await expect(sleep(1000, controller.signal)).rejects.toThrow(
23
- WorkflowCanceledError,
24
- );
17
+ await expect(sleep(1000, controller.signal)).rejects.toThrow(WorkflowCanceledError);
25
18
  });
26
19
 
27
- it("should reject when signal is aborted during sleep", async () => {
20
+ it('should reject when signal is aborted during sleep', async () => {
28
21
  const controller = new AbortController();
29
22
 
30
23
  const promise = sleep(5000, controller.signal);
@@ -34,79 +27,71 @@ describe("sleep", () => {
34
27
  });
35
28
  });
36
29
 
37
- describe("calculateRetryDelay", () => {
38
- it("should return base delay for first attempt", () => {
30
+ describe('calculateRetryDelay', () => {
31
+ it('should return base delay for first attempt', () => {
39
32
  expect(calculateRetryDelay(1, 1000, 2)).toBe(1000);
40
33
  });
41
34
 
42
- it("should apply backoff multiplier", () => {
35
+ it('should apply backoff multiplier', () => {
43
36
  expect(calculateRetryDelay(2, 1000, 2)).toBe(2000);
44
37
  expect(calculateRetryDelay(3, 1000, 2)).toBe(4000);
45
38
  });
46
39
 
47
- it("should work with non-integer backoff", () => {
40
+ it('should work with non-integer backoff', () => {
48
41
  expect(calculateRetryDelay(2, 100, 1.5)).toBe(150);
49
42
  });
50
43
  });
51
44
 
52
- describe("DEFAULT_RETRY_OPTIONS", () => {
53
- it("should have expected defaults", () => {
45
+ describe('DEFAULT_RETRY_OPTIONS', () => {
46
+ it('should have expected defaults', () => {
54
47
  expect(DEFAULT_RETRY_OPTIONS.maxRetries).toBe(3);
55
48
  expect(DEFAULT_RETRY_OPTIONS.delay).toBe(1000);
56
49
  expect(DEFAULT_RETRY_OPTIONS.backoff).toBe(2);
57
50
  });
58
51
  });
59
52
 
60
- describe("withRetry", () => {
61
- it("should return result on first success", async () => {
62
- const fn = vi.fn().mockResolvedValue("ok");
63
- const result = await withRetry(fn, {
64
- maxRetries: 3,
65
- delay: 10,
66
- backoff: 1,
67
- });
53
+ describe('withRetry', () => {
54
+ it('should return result on first success', async () => {
55
+ const fn = vi.fn().mockResolvedValue('ok');
56
+ const result = await withRetry(fn, { maxRetries: 3, delay: 10, backoff: 1 });
68
57
 
69
- expect(result).toBe("ok");
58
+ expect(result).toBe('ok');
70
59
  expect(fn).toHaveBeenCalledTimes(1);
71
60
  });
72
61
 
73
- it("should retry on failure and succeed", async () => {
62
+ it('should retry on failure and succeed', async () => {
74
63
  let attempt = 0;
75
64
  const fn = vi.fn(async () => {
76
65
  attempt++;
77
66
  if (attempt < 3) throw new Error(`fail ${attempt}`);
78
- return "success";
67
+ return 'success';
79
68
  });
80
69
 
81
- const result = await withRetry(fn, {
82
- maxRetries: 3,
83
- delay: 10,
84
- backoff: 1,
85
- });
70
+ const result = await withRetry(fn, { maxRetries: 3, delay: 10, backoff: 1 });
86
71
 
87
- expect(result).toBe("success");
72
+ expect(result).toBe('success');
88
73
  expect(fn).toHaveBeenCalledTimes(3);
89
74
  });
90
75
 
91
- it("should throw after exhausting retries", async () => {
92
- const fn = vi.fn().mockRejectedValue(new Error("always fails"));
76
+ it('should throw after exhausting retries', async () => {
77
+ const fn = vi.fn().mockRejectedValue(new Error('always fails'));
93
78
 
94
79
  await expect(
95
- withRetry(fn, { maxRetries: 2, delay: 10, backoff: 1 }),
96
- ).rejects.toThrow("always fails");
80
+ withRetry(fn, { maxRetries: 2, delay: 10, backoff: 1 })
81
+ ).rejects.toThrow('always fails');
97
82
 
98
83
  // 1 initial + 2 retries = 3 calls
99
84
  expect(fn).toHaveBeenCalledTimes(3);
100
85
  });
101
86
 
102
- it("should call onRetry callback", async () => {
87
+ it('should call onRetry callback', async () => {
103
88
  let attempt = 0;
104
89
  const onRetryCalls: Array<{ attempt: number; delay: number }> = [];
105
90
 
106
91
  const fn = vi.fn(async () => {
107
92
  attempt++;
108
- if (attempt < 3) throw new Error("fail");
109
- return "ok";
93
+ if (attempt < 3) throw new Error('fail');
94
+ return 'ok';
110
95
  });
111
96
 
112
97
  await withRetry(fn, {
@@ -121,28 +106,23 @@ describe("withRetry", () => {
121
106
  expect(onRetryCalls[1]).toEqual({ attempt: 2, delay: 200 });
122
107
  });
123
108
 
124
- it("should respect abort signal before first attempt", async () => {
109
+ it('should respect abort signal before first attempt', async () => {
125
110
  const controller = new AbortController();
126
111
  controller.abort();
127
112
 
128
- const fn = vi.fn().mockResolvedValue("ok");
113
+ const fn = vi.fn().mockResolvedValue('ok');
129
114
 
130
115
  await expect(
131
- withRetry(fn, {
132
- maxRetries: 3,
133
- delay: 10,
134
- backoff: 1,
135
- signal: controller.signal,
136
- }),
137
- ).rejects.toThrow("Aborted");
116
+ withRetry(fn, { maxRetries: 3, delay: 10, backoff: 1, signal: controller.signal })
117
+ ).rejects.toThrow('canceled');
138
118
 
139
119
  expect(fn).not.toHaveBeenCalled();
140
120
  });
141
121
 
142
- it("should respect abort signal during retry wait", async () => {
122
+ it('should respect abort signal during retry wait', async () => {
143
123
  const controller = new AbortController();
144
124
 
145
- const fn = vi.fn().mockRejectedValue(new Error("fail"));
125
+ const fn = vi.fn().mockRejectedValue(new Error('fail'));
146
126
 
147
127
  const promise = withRetry(fn, {
148
128
  maxRetries: 5,
@@ -157,15 +137,15 @@ describe("withRetry", () => {
157
137
  await expect(promise).rejects.toThrow(WorkflowCanceledError);
158
138
  });
159
139
 
160
- it("should apply backoff between retries", async () => {
140
+ it('should apply backoff between retries', async () => {
161
141
  const timestamps: number[] = [];
162
142
  let attempt = 0;
163
143
 
164
144
  const fn = vi.fn(async () => {
165
145
  timestamps.push(Date.now());
166
146
  attempt++;
167
- if (attempt <= 2) throw new Error("fail");
168
- return "ok";
147
+ if (attempt <= 2) throw new Error('fail');
148
+ return 'ok';
169
149
  });
170
150
 
171
151
  await withRetry(fn, { maxRetries: 3, delay: 50, backoff: 2 });
@@ -178,20 +158,20 @@ describe("withRetry", () => {
178
158
  expect(gap2).toBeGreaterThanOrEqual(80); // ~100ms second delay (50*2)
179
159
  });
180
160
 
181
- it("should convert non-Error throws to Error", async () => {
161
+ it('should convert non-Error throws to Error', async () => {
182
162
  const fn = vi.fn(async () => {
183
- throw "string error";
163
+ throw 'string error';
184
164
  });
185
165
 
186
166
  await expect(
187
- withRetry(fn, { maxRetries: 0, delay: 10, backoff: 1 }),
188
- ).rejects.toThrow("string error");
167
+ withRetry(fn, { maxRetries: 0, delay: 10, backoff: 1 })
168
+ ).rejects.toThrow('string error');
189
169
  });
190
170
 
191
- it("should use default options when none provided", async () => {
192
- const fn = vi.fn().mockResolvedValue("ok");
171
+ it('should use default options when none provided', async () => {
172
+ const fn = vi.fn().mockResolvedValue('ok');
193
173
  const result = await withRetry(fn);
194
174
 
195
- expect(result).toBe("ok");
175
+ expect(result).toBe('ok');
196
176
  });
197
177
  });
@@ -2,7 +2,7 @@
2
2
  * Retry utilities for the workflow engine.
3
3
  */
4
4
 
5
- import { WorkflowCanceledError } from "./errors";
5
+ import { WorkflowCanceledError } from './errors';
6
6
 
7
7
  /**
8
8
  * Options for retry behavior.
@@ -36,13 +36,13 @@ export const DEFAULT_RETRY_OPTIONS: RetryOptions = {
36
36
  export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
37
37
  return new Promise((resolve, reject) => {
38
38
  if (signal?.aborted) {
39
- reject(new WorkflowCanceledError("run"));
39
+ reject(new WorkflowCanceledError('run'));
40
40
  return;
41
41
  }
42
42
 
43
43
  const cleanup = () => {
44
44
  clearTimeout(timeoutId);
45
- if (onAbort) signal?.removeEventListener("abort", onAbort);
45
+ if (onAbort) signal?.removeEventListener('abort', onAbort);
46
46
  };
47
47
 
48
48
  let onAbort: (() => void) | undefined;
@@ -55,9 +55,9 @@ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
55
55
  if (signal) {
56
56
  onAbort = () => {
57
57
  cleanup();
58
- reject(new WorkflowCanceledError("run"));
58
+ reject(new WorkflowCanceledError('run'));
59
59
  };
60
- signal.addEventListener("abort", onAbort, { once: true });
60
+ signal.addEventListener('abort', onAbort, { once: true });
61
61
  }
62
62
  });
63
63
  }
@@ -72,7 +72,7 @@ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
72
72
  */
73
73
  export async function withRetry<T>(
74
74
  fn: () => Promise<T>,
75
- options: Partial<RetryOptions> = {},
75
+ options: Partial<RetryOptions> = {}
76
76
  ): Promise<T> {
77
77
  const opts: RetryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options };
78
78
  let lastError: Error | undefined;
@@ -81,7 +81,7 @@ export async function withRetry<T>(
81
81
  for (let attempt = 1; attempt <= opts.maxRetries + 1; attempt++) {
82
82
  // Check if aborted before attempting
83
83
  if (opts.signal?.aborted) {
84
- throw new Error("Aborted");
84
+ throw new WorkflowCanceledError('run');
85
85
  }
86
86
 
87
87
  try {
@@ -106,7 +106,7 @@ export async function withRetry<T>(
106
106
  }
107
107
 
108
108
  // This should never be reached, but TypeScript needs it
109
- throw lastError ?? new Error("Retry failed");
109
+ throw lastError ?? new Error('Retry failed');
110
110
  }
111
111
 
112
112
  /**
@@ -115,7 +115,7 @@ export async function withRetry<T>(
115
115
  export function calculateRetryDelay(
116
116
  attempt: number,
117
117
  baseDelay: number,
118
- backoff: number,
118
+ backoff: number
119
119
  ): number {
120
120
  return Math.round(baseDelay * Math.pow(backoff, attempt - 1));
121
121
  }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Tests for safeJsonParse — audit findings L3 + M2.
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import {
7
+ safeJsonParse,
8
+ safeParseField,
9
+ safeParseOptionalField,
10
+ getJsonParseCorruptionCount,
11
+ resetJsonParseCorruptionCount,
12
+ } from './safe-json';
13
+
14
+ function makeLogger() {
15
+ return {
16
+ debug: vi.fn(),
17
+ info: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ };
21
+ }
22
+
23
+ describe('safeJsonParse', () => {
24
+ beforeEach(() => {
25
+ resetJsonParseCorruptionCount();
26
+ });
27
+
28
+ it('returns parsed value for valid JSON without logging', () => {
29
+ const logger = makeLogger();
30
+ const result = safeJsonParse('{"a":1}', null, { component: 'X', logger });
31
+ expect(result).toEqual({ a: 1 });
32
+ expect(logger.warn).not.toHaveBeenCalled();
33
+ expect(getJsonParseCorruptionCount()).toBe(0);
34
+ });
35
+
36
+ it('returns fallback on SyntaxError and increments the corruption counter', () => {
37
+ const logger = makeLogger();
38
+ const result = safeJsonParse('not-json', { fallback: true }, {
39
+ component: 'TestAdapter',
40
+ rowId: 'run-123',
41
+ column: 'input_json',
42
+ logger,
43
+ });
44
+ expect(result).toEqual({ fallback: true });
45
+ expect(getJsonParseCorruptionCount()).toBe(1);
46
+ expect(logger.warn).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ it('emits row id, column, length, type — but never the raw value', () => {
50
+ const logger = makeLogger();
51
+ const sensitive = '{"sensitive":"PII-DATA-IN-DATABASE"]';
52
+ safeJsonParse(sensitive, undefined, {
53
+ component: 'TestAdapter',
54
+ rowId: 'run-456',
55
+ column: 'metadata_json',
56
+ logger,
57
+ });
58
+
59
+ expect(logger.warn).toHaveBeenCalledTimes(1);
60
+ const [message, meta] = logger.warn.mock.calls[0];
61
+ expect(message).toContain('TestAdapter');
62
+ expect(message).toContain('Corrupted JSON');
63
+ // Critical: do not leak raw payload via message OR metadata.
64
+ const serialized = JSON.stringify([message, meta]);
65
+ expect(serialized).not.toContain('PII-DATA-IN-DATABASE');
66
+
67
+ expect(meta).toMatchObject({
68
+ rowId: 'run-456',
69
+ column: 'metadata_json',
70
+ length: sensitive.length,
71
+ type: 'string',
72
+ });
73
+ expect(meta.error).toEqual(expect.any(String));
74
+ expect(meta.corruptionCount).toBe(1);
75
+ });
76
+
77
+ it('does not call console.warn or console.error directly', () => {
78
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
79
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
80
+ try {
81
+ safeJsonParse('garbage{', null, { component: 'TestAdapter' });
82
+ expect(consoleWarn).not.toHaveBeenCalled();
83
+ expect(consoleError).not.toHaveBeenCalled();
84
+ } finally {
85
+ consoleWarn.mockRestore();
86
+ consoleError.mockRestore();
87
+ }
88
+ });
89
+
90
+ it('rethrows non-syntax errors', () => {
91
+ // SyntaxError-only catch — make JSON.parse throw something else and
92
+ // confirm it propagates instead of being swallowed.
93
+ const original = JSON.parse;
94
+ (JSON as any).parse = () => {
95
+ throw new TypeError('boom');
96
+ };
97
+ try {
98
+ expect(() =>
99
+ safeJsonParse('{}', null, { component: 'X' })
100
+ ).toThrowError(TypeError);
101
+ } finally {
102
+ (JSON as any).parse = original;
103
+ }
104
+ expect(getJsonParseCorruptionCount()).toBe(0);
105
+ });
106
+
107
+ it('counts corruption events across multiple calls', () => {
108
+ safeJsonParse('bad-1{', null, { component: 'A' });
109
+ safeJsonParse('bad-2{', null, { component: 'B' });
110
+ safeJsonParse('{"ok":1}', null, { component: 'C' });
111
+ expect(getJsonParseCorruptionCount()).toBe(2);
112
+ });
113
+ });
114
+
115
+ describe('safeParseField', () => {
116
+ beforeEach(() => {
117
+ resetJsonParseCorruptionCount();
118
+ });
119
+
120
+ it('parses string values', () => {
121
+ const result = safeParseField('{"x":1}', {}, { component: 'X' });
122
+ expect(result).toEqual({ x: 1 });
123
+ });
124
+
125
+ it('passes non-string values through unchanged', () => {
126
+ const obj = { x: 1 };
127
+ expect(safeParseField(obj, {}, { component: 'X' })).toBe(obj);
128
+ expect(safeParseField(42, {}, { component: 'X' })).toBe(42);
129
+ });
130
+
131
+ it('returns fallback on syntax error', () => {
132
+ const result = safeParseField('garbage{', { fallback: 1 }, { component: 'X' });
133
+ expect(result).toEqual({ fallback: 1 });
134
+ expect(getJsonParseCorruptionCount()).toBe(1);
135
+ });
136
+ });
137
+
138
+ describe('safeParseOptionalField', () => {
139
+ beforeEach(() => {
140
+ resetJsonParseCorruptionCount();
141
+ });
142
+
143
+ it('returns undefined for falsy values', () => {
144
+ expect(safeParseOptionalField(null, { component: 'X' })).toBeUndefined();
145
+ expect(safeParseOptionalField(undefined, { component: 'X' })).toBeUndefined();
146
+ expect(safeParseOptionalField('', { component: 'X' })).toBeUndefined();
147
+ expect(safeParseOptionalField(0, { component: 'X' })).toBeUndefined();
148
+ });
149
+
150
+ it('parses string values', () => {
151
+ expect(
152
+ safeParseOptionalField('{"a":1}', { component: 'X' })
153
+ ).toEqual({ a: 1 });
154
+ });
155
+
156
+ it('passes non-string truthy values through unchanged', () => {
157
+ const obj = { a: 1 };
158
+ expect(safeParseOptionalField(obj, { component: 'X' })).toBe(obj);
159
+ });
160
+
161
+ it('returns undefined on syntax error and increments counter', () => {
162
+ const result = safeParseOptionalField('garbage{', { component: 'X' });
163
+ expect(result).toBeUndefined();
164
+ expect(getJsonParseCorruptionCount()).toBe(1);
165
+ });
166
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Safe JSON-parse helpers shared by storage and scheduler adapters.
3
+ *
4
+ * When a stored JSON column is corrupted, we want to surface the problem
5
+ * operationally — structured log + counter — instead of silently degrading.
6
+ * The logger payload is restricted to safe metadata (component, row id,
7
+ * column, payload length, payload type, parser error message). The raw
8
+ * value is intentionally never logged because it may contain user-provided
9
+ * run / step input.
10
+ *
11
+ * Audit: 2026-04-27 findings L3 (corruption visibility) and M2 (do not
12
+ * bypass the configured `Logger` or leak the raw value via `console`).
13
+ */
14
+
15
+ import type { Logger } from '../core/types';
16
+
17
+ let corruptionCount = 0;
18
+
19
+ /** Cumulative number of `safeJsonParse` failures since process start. */
20
+ export function getJsonParseCorruptionCount(): number {
21
+ return corruptionCount;
22
+ }
23
+
24
+ /** Reset the corruption counter. Tests only. */
25
+ export function resetJsonParseCorruptionCount(): void {
26
+ corruptionCount = 0;
27
+ }
28
+
29
+ export interface SafeJsonParseContext {
30
+ /** Component label, e.g. `SQLiteStorageAdapter`. */
31
+ component: string;
32
+ /** Optional database row id, surfaced in the warning so operators can locate the bad row. */
33
+ rowId?: string;
34
+ /** Optional column name for additional context. */
35
+ column?: string;
36
+ /** Optional structured logger; without one, only the counter is bumped. */
37
+ logger?: Logger;
38
+ }
39
+
40
+ /**
41
+ * Storage-adapter-level slice of `SafeJsonParseContext` — the part each
42
+ * adapter binds once (its component label + optional logger). Row mappers
43
+ * extend this with `rowId` and `column` per field, so corruption logs always
44
+ * identify the bad row.
45
+ *
46
+ * Shared between `src/storage/postgres-core.ts` and
47
+ * `src/storage/sqlite-core.ts` so all storage adapters use one mapper-context
48
+ * shape regardless of backend.
49
+ */
50
+ export type MapperContext = Pick<SafeJsonParseContext, 'component' | 'logger'>;
51
+
52
+ /**
53
+ * Parse a JSON string; on `SyntaxError` return `fallback`, increment the
54
+ * corruption counter, and emit a structured warn log via `ctx.logger` (if
55
+ * supplied). Non-syntax errors propagate so unexpected runtime issues are
56
+ * still loud.
57
+ */
58
+ export function safeJsonParse<T = unknown>(
59
+ json: string,
60
+ fallback: T,
61
+ ctx: SafeJsonParseContext
62
+ ): T | unknown {
63
+ try {
64
+ return JSON.parse(json);
65
+ } catch (error) {
66
+ if (error instanceof SyntaxError) {
67
+ corruptionCount++;
68
+ ctx.logger?.warn(
69
+ `[${ctx.component}] Corrupted JSON in database row, using fallback`,
70
+ {
71
+ rowId: ctx.rowId,
72
+ column: ctx.column,
73
+ length: typeof json === 'string' ? json.length : undefined,
74
+ type: typeof json,
75
+ error: error.message,
76
+ corruptionCount,
77
+ }
78
+ );
79
+ return fallback;
80
+ }
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ /** Parse a column value if it's still a JSON string; pass through otherwise. */
86
+ export function safeParseField(
87
+ value: unknown,
88
+ fallback: unknown,
89
+ ctx: SafeJsonParseContext
90
+ ): unknown {
91
+ if (typeof value === 'string') {
92
+ return safeJsonParse(value, fallback, ctx);
93
+ }
94
+ return value;
95
+ }
96
+
97
+ /** Same as `safeParseField` but returns `undefined` for falsy/missing values. */
98
+ export function safeParseOptionalField(
99
+ value: unknown,
100
+ ctx: SafeJsonParseContext
101
+ ): unknown {
102
+ if (!value) return undefined;
103
+ if (typeof value === 'string') {
104
+ return safeJsonParse(value, undefined, ctx);
105
+ }
106
+ return value;
107
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tests for verifyWebhookSignature / signWebhookPayload — audit finding L2.
3
+ *
4
+ * Round-trips against the WebCrypto-based signer used by `WebhookEventTransport`
5
+ * to confirm both implementations agree on the wire format.
6
+ */
7
+
8
+ import { describe, it, expect } from 'vitest';
9
+ import { signWebhookPayload, verifyWebhookSignature } from './webhook-signature';
10
+
11
+ async function signWithWebCrypto(payload: string, secret: string): Promise<string> {
12
+ const encoder = new TextEncoder();
13
+ const key = await crypto.subtle.importKey(
14
+ 'raw',
15
+ encoder.encode(secret),
16
+ { name: 'HMAC', hash: 'SHA-256' },
17
+ false,
18
+ ['sign'],
19
+ );
20
+ const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(payload));
21
+ const hex = Array.from(new Uint8Array(signature))
22
+ .map((b) => b.toString(16).padStart(2, '0'))
23
+ .join('');
24
+ return `sha256=${hex}`;
25
+ }
26
+
27
+ describe('signWebhookPayload', () => {
28
+ it('produces a sha256= prefixed lowercase hex digest', () => {
29
+ const sig = signWebhookPayload('hello', 'shhh');
30
+ expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
31
+ });
32
+
33
+ it('matches the WebCrypto signer used by WebhookEventTransport', async () => {
34
+ const payload = JSON.stringify({ id: 'evt_1', type: 'run.completed' });
35
+ const secret = 'whsec_test';
36
+ const fromHelper = signWebhookPayload(payload, secret);
37
+ const fromWebCrypto = await signWithWebCrypto(payload, secret);
38
+ expect(fromHelper).toBe(fromWebCrypto);
39
+ });
40
+ });
41
+
42
+ describe('verifyWebhookSignature', () => {
43
+ const secret = 'whsec_test';
44
+ const payload = '{"event":"ping"}';
45
+
46
+ it('verifies a signature produced by the helper', () => {
47
+ const sig = signWebhookPayload(payload, secret);
48
+ expect(verifyWebhookSignature(payload, sig, secret)).toBe(true);
49
+ });
50
+
51
+ it('verifies a signature produced by the WebCrypto signer (cross-impl)', async () => {
52
+ const sig = await signWithWebCrypto(payload, secret);
53
+ expect(verifyWebhookSignature(payload, sig, secret)).toBe(true);
54
+ });
55
+
56
+ it('accepts the digest with or without the sha256= prefix', () => {
57
+ const sig = signWebhookPayload(payload, secret);
58
+ const bare = sig.slice('sha256='.length);
59
+ expect(verifyWebhookSignature(payload, bare, secret)).toBe(true);
60
+ });
61
+
62
+ it('rejects a tampered body', () => {
63
+ const sig = signWebhookPayload(payload, secret);
64
+ expect(verifyWebhookSignature(payload + 'X', sig, secret)).toBe(false);
65
+ });
66
+
67
+ it('rejects a tampered signature', () => {
68
+ const sig = signWebhookPayload(payload, secret);
69
+ const flipped = `sha256=${'0'.repeat(64)}`;
70
+ expect(verifyWebhookSignature(payload, flipped, secret)).toBe(false);
71
+ const tweaked = sig.slice(0, -1) + (sig.endsWith('a') ? 'b' : 'a');
72
+ expect(verifyWebhookSignature(payload, tweaked, secret)).toBe(false);
73
+ });
74
+
75
+ it('rejects when the secret is wrong', () => {
76
+ const sig = signWebhookPayload(payload, secret);
77
+ expect(verifyWebhookSignature(payload, sig, 'other-secret')).toBe(false);
78
+ });
79
+
80
+ it('rejects empty / missing inputs', () => {
81
+ const sig = signWebhookPayload(payload, secret);
82
+ expect(verifyWebhookSignature(payload, undefined, secret)).toBe(false);
83
+ expect(verifyWebhookSignature(payload, null, secret)).toBe(false);
84
+ expect(verifyWebhookSignature(payload, '', secret)).toBe(false);
85
+ expect(verifyWebhookSignature(payload, sig, '')).toBe(false);
86
+ });
87
+
88
+ it('rejects non-hex signature strings without throwing', () => {
89
+ expect(verifyWebhookSignature(payload, 'sha256=not-hex-content', secret)).toBe(false);
90
+ expect(verifyWebhookSignature(payload, 'sha256=zzzz', secret)).toBe(false);
91
+ });
92
+
93
+ it('rejects digests of the wrong length without throwing', () => {
94
+ expect(verifyWebhookSignature(payload, 'sha256=abcd', secret)).toBe(false);
95
+ expect(verifyWebhookSignature(payload, `sha256=${'a'.repeat(63)}`, secret)).toBe(false);
96
+ expect(verifyWebhookSignature(payload, `sha256=${'a'.repeat(65)}`, secret)).toBe(false);
97
+ });
98
+
99
+ it('treats the digest case-insensitively', () => {
100
+ const sig = signWebhookPayload(payload, secret);
101
+ const upper = `sha256=${sig.slice('sha256='.length).toUpperCase()}`;
102
+ expect(verifyWebhookSignature(payload, upper, secret)).toBe(true);
103
+ });
104
+
105
+ it('handles Uint8Array bodies', () => {
106
+ const body = new TextEncoder().encode(payload);
107
+ const sig = signWebhookPayload(body, secret);
108
+ expect(verifyWebhookSignature(body, sig, secret)).toBe(true);
109
+ expect(signWebhookPayload(body, secret)).toBe(signWebhookPayload(payload, secret));
110
+ });
111
+ });