@multiplier-labs/stepflow 0.3.3 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
  2. package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
  3. package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
  4. package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
  5. package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
  6. package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
  7. package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
  8. package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
  9. package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
  10. package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
  11. package/.github/workflows/publish.yml +6 -0
  12. package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
  13. package/dist/chunk-5XJBAPXF.js.map +1 -0
  14. package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
  15. package/dist/chunk-FMPJBK2N.js.map +1 -0
  16. package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
  17. package/dist/chunk-JPXPB7U5.js.map +1 -0
  18. package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
  19. package/dist/chunk-U3PCVK6O.js.map +1 -0
  20. package/dist/events/index.d.ts +56 -3
  21. package/dist/events/index.js +1 -1
  22. package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
  23. package/dist/index.d.ts +2 -12
  24. package/dist/index.js +764 -485
  25. package/dist/index.js.map +1 -1
  26. package/dist/scheduler/index.d.ts +1 -1
  27. package/dist/scheduler/index.js +2 -2
  28. package/dist/storage/index.d.ts +313 -47
  29. package/dist/storage/index.js +4 -2
  30. package/docs/stepflow-api-reference.md +3 -1
  31. package/package.json +7 -4
  32. package/src/core/engine/concurrency.ts +229 -0
  33. package/src/core/engine/lifecycle.ts +176 -0
  34. package/src/core/engine.test.ts +70 -0
  35. package/src/core/engine.ts +91 -185
  36. package/src/core/orchestrator/persistence.ts +165 -0
  37. package/src/core/orchestrator/plan-resolution.ts +140 -0
  38. package/src/core/orchestrator/state-transitions.ts +109 -0
  39. package/src/core/orchestrator/step-runner.ts +427 -0
  40. package/src/core/orchestrator.ts +40 -359
  41. package/src/events/webhook.test.ts +292 -0
  42. package/src/events/webhook.ts +320 -77
  43. package/src/planning/evaluate-condition.test.ts +153 -0
  44. package/src/planning/evaluate-condition.ts +105 -0
  45. package/src/planning/planner.ts +1 -83
  46. package/src/planning/registry.ts +3 -85
  47. package/src/scheduler/cron.test.ts +9 -1
  48. package/src/scheduler/cron.ts +26 -2
  49. package/src/scheduler/postgres-persistence.ts +27 -15
  50. package/src/scheduler/sqlite-persistence.ts +28 -3
  51. package/src/storage/postgres-core.ts +550 -0
  52. package/src/storage/postgres-migration.test.ts +69 -0
  53. package/src/storage/postgres-transaction-pagination.test.ts +107 -0
  54. package/src/storage/postgres.ts +171 -690
  55. package/src/storage/sqlite-core.ts +151 -0
  56. package/src/storage/sqlite.test.ts +177 -1
  57. package/src/storage/sqlite.ts +183 -93
  58. package/src/utils/index.ts +2 -0
  59. package/src/utils/pinned-fetch.test.ts +84 -0
  60. package/src/utils/pinned-fetch.ts +118 -0
  61. package/src/utils/postgres-deps.ts +16 -0
  62. package/src/utils/safe-json.test.ts +166 -0
  63. package/src/utils/safe-json.ts +107 -0
  64. package/src/utils/webhook-signature.test.ts +111 -0
  65. package/src/utils/webhook-signature.ts +74 -0
  66. package/dist/chunk-4VOKMGP6.js.map +0 -1
  67. package/dist/chunk-GYPNJBDP.js.map +0 -1
  68. package/dist/chunk-KHTUWLVZ.js.map +0 -1
  69. package/dist/chunk-ML35PIHX.js.map +0 -1
@@ -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
+ });
@@ -0,0 +1,74 @@
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
+ }