@multiplier-labs/stepflow 0.3.4 → 0.3.8
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/README.md +9 -0
- package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
- package/dist/chunk-4IDPWWGS.js.map +1 -0
- package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
- package/dist/chunk-T6GAZBTQ.js.map +1 -0
- package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
- package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
- package/dist/events/index.d.ts +43 -3
- package/dist/events/index.js +7 -3
- package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +159 -46
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +2 -2
- package/dist/scheduler/index.js +1 -1
- package/dist/storage/index.d.ts +6 -4
- package/dist/storage/index.js +1 -1
- package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
- package/package.json +26 -14
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
- package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
- package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
- package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
- package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
- package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
- package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
- package/.github/CODEOWNERS +0 -15
- package/.github/dependabot.yml +0 -23
- package/.github/workflows/ci.yml +0 -38
- package/.github/workflows/publish.yml +0 -42
- package/.github/workflows/security.yml +0 -29
- package/CHANGELOG.md +0 -34
- package/SECURITY.md +0 -40
- package/dist/chunk-5XJBAPXF.js.map +0 -1
- package/dist/chunk-U3PCVK6O.js.map +0 -1
- package/docs/github-packages-setup.md +0 -97
- package/docs/stepflow-api-reference.md +0 -1663
- package/src/core/engine/concurrency.ts +0 -229
- package/src/core/engine/lifecycle.ts +0 -176
- package/src/core/engine.test.ts +0 -1435
- package/src/core/engine.ts +0 -598
- package/src/core/index.ts +0 -7
- package/src/core/orchestrator/persistence.ts +0 -165
- package/src/core/orchestrator/plan-resolution.ts +0 -140
- package/src/core/orchestrator/state-transitions.ts +0 -109
- package/src/core/orchestrator/step-runner.ts +0 -427
- package/src/core/orchestrator.ts +0 -252
- package/src/core/types.ts +0 -220
- package/src/events/index.ts +0 -8
- package/src/events/memory.test.ts +0 -107
- package/src/events/memory.ts +0 -92
- package/src/events/socketio.test.ts +0 -378
- package/src/events/socketio.ts +0 -262
- package/src/events/types.ts +0 -118
- package/src/events/webhook.test.ts +0 -949
- package/src/events/webhook.ts +0 -807
- package/src/index.ts +0 -270
- package/src/planning/evaluate-condition.test.ts +0 -153
- package/src/planning/evaluate-condition.ts +0 -105
- package/src/planning/index.ts +0 -55
- package/src/planning/planner.ts +0 -494
- package/src/planning/planning.test.ts +0 -727
- package/src/planning/registry.test.ts +0 -398
- package/src/planning/registry.ts +0 -283
- package/src/planning/types.ts +0 -566
- package/src/scheduler/cron.test.ts +0 -479
- package/src/scheduler/cron.ts +0 -438
- package/src/scheduler/index.ts +0 -8
- package/src/scheduler/postgres-persistence.ts +0 -530
- package/src/scheduler/sqlite-persistence.test.ts +0 -235
- package/src/scheduler/sqlite-persistence.ts +0 -286
- package/src/scheduler/types.ts +0 -73
- package/src/storage/index.ts +0 -27
- package/src/storage/memory.test.ts +0 -349
- package/src/storage/memory.ts +0 -227
- package/src/storage/postgres-core.ts +0 -550
- package/src/storage/postgres-migration.test.ts +0 -69
- package/src/storage/postgres-transaction-pagination.test.ts +0 -107
- package/src/storage/postgres.ts +0 -835
- package/src/storage/sqlite-core.ts +0 -151
- package/src/storage/sqlite.test.ts +0 -642
- package/src/storage/sqlite.ts +0 -717
- package/src/storage/types.ts +0 -343
- package/src/utils/errors.test.ts +0 -150
- package/src/utils/errors.ts +0 -161
- package/src/utils/id.ts +0 -21
- package/src/utils/index.ts +0 -10
- package/src/utils/logger.test.ts +0 -121
- package/src/utils/logger.ts +0 -96
- package/src/utils/pinned-fetch.test.ts +0 -84
- package/src/utils/pinned-fetch.ts +0 -118
- package/src/utils/postgres-deps.ts +0 -79
- package/src/utils/retry.test.ts +0 -177
- package/src/utils/retry.ts +0 -121
- package/src/utils/safe-json.test.ts +0 -166
- package/src/utils/safe-json.ts +0 -107
- package/src/utils/webhook-signature.test.ts +0 -111
- package/src/utils/webhook-signature.ts +0 -74
- package/tsconfig.json +0 -21
- package/tsup.config.ts +0 -14
- package/vitest.config.ts +0 -9
package/src/utils/retry.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Retry utilities for the workflow engine.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { WorkflowCanceledError } from './errors';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Options for retry behavior.
|
|
9
|
-
*/
|
|
10
|
-
export interface RetryOptions {
|
|
11
|
-
/** Maximum number of retry attempts (default: 3) */
|
|
12
|
-
maxRetries: number;
|
|
13
|
-
/** Initial delay between retries in ms (default: 1000) */
|
|
14
|
-
delay: number;
|
|
15
|
-
/** Backoff multiplier (default: 2) */
|
|
16
|
-
backoff: number;
|
|
17
|
-
/** Optional abort signal to cancel retries */
|
|
18
|
-
signal?: AbortSignal;
|
|
19
|
-
/** Optional callback before each retry */
|
|
20
|
-
onRetry?: (attempt: number, error: Error, nextDelay: number) => void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Default retry options.
|
|
25
|
-
*/
|
|
26
|
-
export const DEFAULT_RETRY_OPTIONS: RetryOptions = {
|
|
27
|
-
maxRetries: 3,
|
|
28
|
-
delay: 1000,
|
|
29
|
-
backoff: 2,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Sleep for a given number of milliseconds.
|
|
34
|
-
* Can be canceled via AbortSignal.
|
|
35
|
-
*/
|
|
36
|
-
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
if (signal?.aborted) {
|
|
39
|
-
reject(new WorkflowCanceledError('run'));
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const cleanup = () => {
|
|
44
|
-
clearTimeout(timeoutId);
|
|
45
|
-
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
let onAbort: (() => void) | undefined;
|
|
49
|
-
|
|
50
|
-
const timeoutId = setTimeout(() => {
|
|
51
|
-
cleanup();
|
|
52
|
-
resolve();
|
|
53
|
-
}, ms);
|
|
54
|
-
|
|
55
|
-
if (signal) {
|
|
56
|
-
onAbort = () => {
|
|
57
|
-
cleanup();
|
|
58
|
-
reject(new WorkflowCanceledError('run'));
|
|
59
|
-
};
|
|
60
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Execute a function with retry logic.
|
|
67
|
-
*
|
|
68
|
-
* @param fn - The async function to execute
|
|
69
|
-
* @param options - Retry configuration
|
|
70
|
-
* @returns The result of the function
|
|
71
|
-
* @throws The last error if all retries are exhausted
|
|
72
|
-
*/
|
|
73
|
-
export async function withRetry<T>(
|
|
74
|
-
fn: () => Promise<T>,
|
|
75
|
-
options: Partial<RetryOptions> = {}
|
|
76
|
-
): Promise<T> {
|
|
77
|
-
const opts: RetryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options };
|
|
78
|
-
let lastError: Error | undefined;
|
|
79
|
-
let currentDelay = opts.delay;
|
|
80
|
-
|
|
81
|
-
for (let attempt = 1; attempt <= opts.maxRetries + 1; attempt++) {
|
|
82
|
-
// Check if aborted before attempting
|
|
83
|
-
if (opts.signal?.aborted) {
|
|
84
|
-
throw new WorkflowCanceledError('run');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
return await fn();
|
|
89
|
-
} catch (error) {
|
|
90
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
91
|
-
|
|
92
|
-
// If this was the last attempt, throw
|
|
93
|
-
if (attempt > opts.maxRetries) {
|
|
94
|
-
throw lastError;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Call onRetry callback if provided
|
|
98
|
-
opts.onRetry?.(attempt, lastError, currentDelay);
|
|
99
|
-
|
|
100
|
-
// Wait before next attempt
|
|
101
|
-
await sleep(currentDelay, opts.signal);
|
|
102
|
-
|
|
103
|
-
// Apply backoff multiplier
|
|
104
|
-
currentDelay = Math.round(currentDelay * opts.backoff);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// This should never be reached, but TypeScript needs it
|
|
109
|
-
throw lastError ?? new Error('Retry failed');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Calculate the delay for a specific retry attempt.
|
|
114
|
-
*/
|
|
115
|
-
export function calculateRetryDelay(
|
|
116
|
-
attempt: number,
|
|
117
|
-
baseDelay: number,
|
|
118
|
-
backoff: number
|
|
119
|
-
): number {
|
|
120
|
-
return Math.round(baseDelay * Math.pow(backoff, attempt - 1));
|
|
121
|
-
}
|
|
@@ -1,166 +0,0 @@
|
|
|
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
|
-
});
|
package/src/utils/safe-json.ts
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,111 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helpers for signing and verifying inbound / outbound webhook payloads.
|
|
3
|
-
*
|
|
4
|
-
* Both sides use HMAC-SHA256 with a `sha256=<lowercase-hex>` envelope —
|
|
5
|
-
* the same shape produced by `WebhookEventTransport.signPayload`, matching
|
|
6
|
-
* common third-party providers (Stripe, GitHub, etc.).
|
|
7
|
-
*
|
|
8
|
-
* Audit: 2026-04-27 finding L2 — without a shipped verifier, consumers
|
|
9
|
-
* receiving inbound webhooks tend to roll their own comparisons that are
|
|
10
|
-
* vulnerable to timing attacks.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Compute the canonical `X-Webhook-Signature` value for a request body.
|
|
17
|
-
*
|
|
18
|
-
* Returns a string of the form `sha256=<lowercase-hex>`.
|
|
19
|
-
*/
|
|
20
|
-
export function signWebhookPayload(body: string | Uint8Array, secret: string): string {
|
|
21
|
-
const hmac = createHmac('sha256', secret);
|
|
22
|
-
hmac.update(body);
|
|
23
|
-
return `sha256=${hmac.digest('hex')}`;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Constant-time verification of an inbound webhook signature.
|
|
28
|
-
*
|
|
29
|
-
* @param body The raw request body that was signed.
|
|
30
|
-
* @param signature The header value sent by the producer (e.g. `sha256=…`).
|
|
31
|
-
* @param secret The shared HMAC secret.
|
|
32
|
-
* @returns `true` only if the computed digest matches the supplied signature.
|
|
33
|
-
*
|
|
34
|
-
* Properties:
|
|
35
|
-
* - Accepts an optional `sha256=` prefix on the signature, but does not
|
|
36
|
-
* require it (mirrors common third-party header conventions).
|
|
37
|
-
* - Uses `timingSafeEqual` so an attacker cannot probe digit-by-digit.
|
|
38
|
-
* - Returns `false` for missing/empty values, non-hex input, and length
|
|
39
|
-
* mismatches without throwing.
|
|
40
|
-
* - Returns `false` if the secret is empty — an empty secret is almost
|
|
41
|
-
* always a misconfiguration we do not want to silently accept.
|
|
42
|
-
*/
|
|
43
|
-
export function verifyWebhookSignature(
|
|
44
|
-
body: string | Uint8Array,
|
|
45
|
-
signature: string | undefined | null,
|
|
46
|
-
secret: string,
|
|
47
|
-
): boolean {
|
|
48
|
-
if (!secret || typeof signature !== 'string' || signature.length === 0) {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const provided = signature.startsWith('sha256=')
|
|
53
|
-
? signature.slice('sha256='.length)
|
|
54
|
-
: signature;
|
|
55
|
-
|
|
56
|
-
// Hex-encoded SHA-256 is always exactly 64 characters; reject anything
|
|
57
|
-
// else so we don't mistake other algorithms or junk strings for ours.
|
|
58
|
-
if (!/^[0-9a-f]+$/i.test(provided)) {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const expectedHex = signWebhookPayload(body, secret).slice('sha256='.length);
|
|
63
|
-
|
|
64
|
-
// Length mismatch — `timingSafeEqual` would throw; exposing the digest
|
|
65
|
-
// length is fine since it's a public property of SHA-256.
|
|
66
|
-
if (provided.length !== expectedHex.length) {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const a = Buffer.from(provided.toLowerCase(), 'hex');
|
|
71
|
-
const b = Buffer.from(expectedHex, 'hex');
|
|
72
|
-
if (a.length !== b.length || a.length === 0) return false;
|
|
73
|
-
return timingSafeEqual(a, b);
|
|
74
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"moduleResolution": "bundler",
|
|
6
|
-
"lib": ["ES2022", "DOM"],
|
|
7
|
-
"strict": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"skipLibCheck": true,
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"declarationMap": true,
|
|
12
|
-
"outDir": "./dist",
|
|
13
|
-
"rootDir": "./src",
|
|
14
|
-
"forceConsistentCasingInFileNames": true,
|
|
15
|
-
"resolveJsonModule": true,
|
|
16
|
-
"isolatedModules": true,
|
|
17
|
-
"noEmit": true
|
|
18
|
-
},
|
|
19
|
-
"include": ["src/**/*"],
|
|
20
|
-
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
|
21
|
-
}
|
package/tsup.config.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'tsup';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
entry: {
|
|
5
|
-
index: 'src/index.ts',
|
|
6
|
-
'storage/index': 'src/storage/index.ts',
|
|
7
|
-
'events/index': 'src/events/index.ts',
|
|
8
|
-
'scheduler/index': 'src/scheduler/index.ts',
|
|
9
|
-
},
|
|
10
|
-
format: ['esm'],
|
|
11
|
-
dts: true,
|
|
12
|
-
clean: true,
|
|
13
|
-
sourcemap: true,
|
|
14
|
-
});
|