@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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +179 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +94 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +10 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-03_security-audit.md +208 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/CODEOWNERS +15 -0
- package/.github/dependabot.yml +23 -0
- package/.github/workflows/ci.yml +38 -0
- package/.github/workflows/publish.yml +17 -5
- package/.github/workflows/security.yml +29 -0
- package/CHANGELOG.md +34 -0
- package/README.md +24 -0
- package/SECURITY.md +40 -0
- package/dist/{chunk-FQCT3I2A.js → chunk-5XJBAPXF.js} +527 -551
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-U5QGN3RT.js → chunk-FMPJBK2N.js} +67 -55
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-GXY66V7Q.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-XCF4PLN7.js → chunk-U3PCVK6O.js} +276 -61
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +60 -7
- package/dist/events/index.js +1 -1
- package/dist/{index-CPfGsmrp.d.ts → index-Cd0IZ40L.d.ts} +56 -17
- package/dist/index.d.ts +15 -23
- package/dist/index.js +784 -547
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +4 -4
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +322 -56
- package/dist/storage/index.js +4 -2
- package/dist/{types-BlwFEGzj.d.ts → types-DSEjiLMj.d.ts} +16 -15
- package/dist/{types-DzkEqnQJ.d.ts → types-DZUdWmCc.d.ts} +2 -2
- package/dist/{types-BVShq7zT.d.ts → types-K5Gjk3H_.d.ts} +4 -4
- package/docs/stepflow-api-reference.md +5 -4
- package/package.json +12 -8
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +536 -522
- package/src/core/engine.ts +116 -235
- package/src/core/index.ts +3 -3
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +57 -395
- package/src/core/types.ts +7 -29
- package/src/events/index.ts +4 -4
- package/src/events/memory.test.ts +39 -41
- package/src/events/memory.ts +7 -10
- package/src/events/socketio.test.ts +112 -162
- package/src/events/socketio.ts +20 -27
- package/src/events/types.ts +16 -19
- package/src/events/webhook.test.ts +489 -235
- package/src/events/webhook.ts +355 -139
- package/src/index.ts +19 -19
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/index.ts +6 -3
- package/src/planning/planner.ts +45 -152
- package/src/planning/planning.test.ts +303 -366
- package/src/planning/registry.test.ts +204 -251
- package/src/planning/registry.ts +20 -106
- package/src/planning/types.ts +23 -27
- package/src/scheduler/cron.test.ts +149 -150
- package/src/scheduler/cron.ts +58 -69
- package/src/scheduler/index.ts +4 -14
- package/src/scheduler/postgres-persistence.ts +89 -105
- package/src/scheduler/sqlite-persistence.test.ts +96 -96
- package/src/scheduler/sqlite-persistence.ts +40 -22
- package/src/scheduler/types.ts +4 -9
- package/src/storage/index.ts +5 -5
- package/src/storage/memory.test.ts +133 -183
- package/src/storage/memory.ts +21 -42
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +222 -937
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +330 -281
- package/src/storage/sqlite.ts +225 -170
- package/src/storage/types.ts +33 -58
- package/src/utils/errors.test.ts +78 -80
- package/src/utils/errors.ts +27 -57
- package/src/utils/id.ts +3 -3
- package/src/utils/index.ts +6 -4
- package/src/utils/logger.test.ts +52 -58
- package/src/utils/logger.ts +4 -4
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +20 -4
- package/src/utils/retry.test.ts +47 -67
- package/src/utils/retry.ts +9 -9
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-FQCT3I2A.js.map +0 -1
- package/dist/chunk-GXY66V7Q.js.map +0 -1
- package/dist/chunk-U5QGN3RT.js.map +0 -1
- package/dist/chunk-XCF4PLN7.js.map +0 -1
- package/src/scheduler/postgres-persistence.test.ts +0 -906
package/src/utils/retry.test.ts
CHANGED
|
@@ -1,30 +1,23 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
38
|
-
it(
|
|
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(
|
|
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(
|
|
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(
|
|
53
|
-
it(
|
|
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(
|
|
61
|
-
it(
|
|
62
|
-
const fn = vi.fn().mockResolvedValue(
|
|
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(
|
|
58
|
+
expect(result).toBe('ok');
|
|
70
59
|
expect(fn).toHaveBeenCalledTimes(1);
|
|
71
60
|
});
|
|
72
61
|
|
|
73
|
-
it(
|
|
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
|
|
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(
|
|
72
|
+
expect(result).toBe('success');
|
|
88
73
|
expect(fn).toHaveBeenCalledTimes(3);
|
|
89
74
|
});
|
|
90
75
|
|
|
91
|
-
it(
|
|
92
|
-
const fn = vi.fn().mockRejectedValue(new Error(
|
|
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(
|
|
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(
|
|
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(
|
|
109
|
-
return
|
|
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(
|
|
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(
|
|
113
|
+
const fn = vi.fn().mockResolvedValue('ok');
|
|
129
114
|
|
|
130
115
|
await expect(
|
|
131
|
-
withRetry(fn, {
|
|
132
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
168
|
-
return
|
|
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(
|
|
161
|
+
it('should convert non-Error throws to Error', async () => {
|
|
182
162
|
const fn = vi.fn(async () => {
|
|
183
|
-
throw
|
|
163
|
+
throw 'string error';
|
|
184
164
|
});
|
|
185
165
|
|
|
186
166
|
await expect(
|
|
187
|
-
withRetry(fn, { maxRetries: 0, delay: 10, backoff: 1 })
|
|
188
|
-
).rejects.toThrow(
|
|
167
|
+
withRetry(fn, { maxRetries: 0, delay: 10, backoff: 1 })
|
|
168
|
+
).rejects.toThrow('string error');
|
|
189
169
|
});
|
|
190
170
|
|
|
191
|
-
it(
|
|
192
|
-
const fn = vi.fn().mockResolvedValue(
|
|
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(
|
|
175
|
+
expect(result).toBe('ok');
|
|
196
176
|
});
|
|
197
177
|
});
|
package/src/utils/retry.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Retry utilities for the workflow engine.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { WorkflowCanceledError } from
|
|
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(
|
|
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(
|
|
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(
|
|
58
|
+
reject(new WorkflowCanceledError('run'));
|
|
59
59
|
};
|
|
60
|
-
signal.addEventListener(
|
|
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
|
|
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(
|
|
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
|
+
});
|