@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.
Files changed (109) hide show
  1. package/README.md +9 -0
  2. package/dist/{chunk-5XJBAPXF.js → chunk-4IDPWWGS.js} +15 -4
  3. package/dist/chunk-4IDPWWGS.js.map +1 -0
  4. package/dist/{chunk-U3PCVK6O.js → chunk-T6GAZBTQ.js} +70 -38
  5. package/dist/chunk-T6GAZBTQ.js.map +1 -0
  6. package/dist/{chunk-FMPJBK2N.js → chunk-TVSADUYU.js} +3 -5
  7. package/dist/{chunk-FMPJBK2N.js.map → chunk-TVSADUYU.js.map} +1 -1
  8. package/dist/events/index.d.ts +43 -3
  9. package/dist/events/index.js +7 -3
  10. package/dist/{index-Cd0IZ40L.d.ts → index-CdXDv30T.d.ts} +10 -2
  11. package/dist/index.d.ts +5 -4
  12. package/dist/index.js +159 -46
  13. package/dist/index.js.map +1 -1
  14. package/dist/scheduler/index.d.ts +2 -2
  15. package/dist/scheduler/index.js +1 -1
  16. package/dist/storage/index.d.ts +6 -4
  17. package/dist/storage/index.js +1 -1
  18. package/dist/{types-DSEjiLMj.d.ts → types-BfWe5yP6.d.ts} +2 -0
  19. package/package.json +26 -14
  20. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +0 -146
  21. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +0 -74
  22. package/.codekin/reports/comments/2026-04-04_comment-assessment.md +0 -179
  23. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +0 -161
  24. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +0 -34
  25. package/.codekin/reports/complexity/2026-04-03_complexity-report.md +0 -152
  26. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +0 -152
  27. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +0 -34
  28. package/.codekin/reports/dependencies/2026-04-04_dependency-health.md +0 -94
  29. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +0 -93
  30. package/.codekin/reports/docs-audit/2026-04-04_docs-audit.md +0 -10
  31. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +0 -138
  32. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +0 -121
  33. package/.codekin/reports/security/2026-04-03_security-audit.md +0 -208
  34. package/.codekin/reports/security/2026-04-11_security-audit.md +0 -297
  35. package/.github/CODEOWNERS +0 -15
  36. package/.github/dependabot.yml +0 -23
  37. package/.github/workflows/ci.yml +0 -38
  38. package/.github/workflows/publish.yml +0 -42
  39. package/.github/workflows/security.yml +0 -29
  40. package/CHANGELOG.md +0 -34
  41. package/SECURITY.md +0 -40
  42. package/dist/chunk-5XJBAPXF.js.map +0 -1
  43. package/dist/chunk-U3PCVK6O.js.map +0 -1
  44. package/docs/github-packages-setup.md +0 -97
  45. package/docs/stepflow-api-reference.md +0 -1663
  46. package/src/core/engine/concurrency.ts +0 -229
  47. package/src/core/engine/lifecycle.ts +0 -176
  48. package/src/core/engine.test.ts +0 -1435
  49. package/src/core/engine.ts +0 -598
  50. package/src/core/index.ts +0 -7
  51. package/src/core/orchestrator/persistence.ts +0 -165
  52. package/src/core/orchestrator/plan-resolution.ts +0 -140
  53. package/src/core/orchestrator/state-transitions.ts +0 -109
  54. package/src/core/orchestrator/step-runner.ts +0 -427
  55. package/src/core/orchestrator.ts +0 -252
  56. package/src/core/types.ts +0 -220
  57. package/src/events/index.ts +0 -8
  58. package/src/events/memory.test.ts +0 -107
  59. package/src/events/memory.ts +0 -92
  60. package/src/events/socketio.test.ts +0 -378
  61. package/src/events/socketio.ts +0 -262
  62. package/src/events/types.ts +0 -118
  63. package/src/events/webhook.test.ts +0 -949
  64. package/src/events/webhook.ts +0 -807
  65. package/src/index.ts +0 -270
  66. package/src/planning/evaluate-condition.test.ts +0 -153
  67. package/src/planning/evaluate-condition.ts +0 -105
  68. package/src/planning/index.ts +0 -55
  69. package/src/planning/planner.ts +0 -494
  70. package/src/planning/planning.test.ts +0 -727
  71. package/src/planning/registry.test.ts +0 -398
  72. package/src/planning/registry.ts +0 -283
  73. package/src/planning/types.ts +0 -566
  74. package/src/scheduler/cron.test.ts +0 -479
  75. package/src/scheduler/cron.ts +0 -438
  76. package/src/scheduler/index.ts +0 -8
  77. package/src/scheduler/postgres-persistence.ts +0 -530
  78. package/src/scheduler/sqlite-persistence.test.ts +0 -235
  79. package/src/scheduler/sqlite-persistence.ts +0 -286
  80. package/src/scheduler/types.ts +0 -73
  81. package/src/storage/index.ts +0 -27
  82. package/src/storage/memory.test.ts +0 -349
  83. package/src/storage/memory.ts +0 -227
  84. package/src/storage/postgres-core.ts +0 -550
  85. package/src/storage/postgres-migration.test.ts +0 -69
  86. package/src/storage/postgres-transaction-pagination.test.ts +0 -107
  87. package/src/storage/postgres.ts +0 -835
  88. package/src/storage/sqlite-core.ts +0 -151
  89. package/src/storage/sqlite.test.ts +0 -642
  90. package/src/storage/sqlite.ts +0 -717
  91. package/src/storage/types.ts +0 -343
  92. package/src/utils/errors.test.ts +0 -150
  93. package/src/utils/errors.ts +0 -161
  94. package/src/utils/id.ts +0 -21
  95. package/src/utils/index.ts +0 -10
  96. package/src/utils/logger.test.ts +0 -121
  97. package/src/utils/logger.ts +0 -96
  98. package/src/utils/pinned-fetch.test.ts +0 -84
  99. package/src/utils/pinned-fetch.ts +0 -118
  100. package/src/utils/postgres-deps.ts +0 -79
  101. package/src/utils/retry.test.ts +0 -177
  102. package/src/utils/retry.ts +0 -121
  103. package/src/utils/safe-json.test.ts +0 -166
  104. package/src/utils/safe-json.ts +0 -107
  105. package/src/utils/webhook-signature.test.ts +0 -111
  106. package/src/utils/webhook-signature.ts +0 -74
  107. package/tsconfig.json +0 -21
  108. package/tsup.config.ts +0 -14
  109. package/vitest.config.ts +0 -9
@@ -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
- });
@@ -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
- });
package/vitest.config.ts DELETED
@@ -1,9 +0,0 @@
1
- import { defineConfig } from 'vitest/config';
2
-
3
- export default defineConfig({
4
- test: {
5
- globals: true,
6
- environment: 'node',
7
- include: ['src/**/*.test.ts'],
8
- },
9
- });