@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
@@ -2,6 +2,21 @@
2
2
  * SQLite storage adapter using better-sqlite3.
3
3
  *
4
4
  * Provides durable persistence for workflow runs, steps, and events.
5
+ *
6
+ * ## Module layout
7
+ *
8
+ * - `SQLiteStorageAdapter` (this file) owns the better-sqlite3 connection,
9
+ * the prepared-statement cache, and every method on `StorageAdapter`. It
10
+ * is the only adapter class — unlike the Postgres adapter there is no
11
+ * transaction-scoped twin, so no abstract CRUD base class is required.
12
+ *
13
+ * - `sqlite-core.ts` holds the pure row-type interfaces and row mappers
14
+ * (`mapRunRow`, `mapStepRow`, `mapEventRow`). The mappers route every
15
+ * JSON-column parse through `safeParseField` / `safeParseOptionalField`
16
+ * in `src/utils/safe-json.ts`, sharing the corruption-counter +
17
+ * structured-warn behavior with the Postgres core (audit M2 / L3,
18
+ * PR #52). The split mirrors `postgres-core.ts` so both adapters
19
+ * expose mappers in the same shape.
5
20
  */
6
21
 
7
22
  import type Database from 'better-sqlite3';
@@ -15,8 +30,17 @@ import type {
15
30
  ListEventsOptions,
16
31
  PaginatedResult,
17
32
  } from './types';
18
- import type { RunStatus, StepStatus, WorkflowError } from '../core/types';
33
+ import type { Logger } from '../core/types';
19
34
  import { sanitizeErrorForStorage } from '../utils/logger';
35
+ import type { MapperContext } from '../utils/safe-json';
36
+ import {
37
+ mapRunRow,
38
+ mapStepRow,
39
+ mapEventRow,
40
+ type SQLiteRunRow,
41
+ type SQLiteStepRow,
42
+ type SQLiteEventRow,
43
+ } from './sqlite-core';
20
44
 
21
45
  // ============================================================================
22
46
  // Schema SQL
@@ -82,6 +106,22 @@ CREATE INDEX IF NOT EXISTS idx_workflow_events_run ON workflow_events(run_id);
82
106
  CREATE INDEX IF NOT EXISTS idx_workflow_events_run_ts ON workflow_events(run_id, timestamp);
83
107
  `;
84
108
 
109
+ /**
110
+ * Columns added to {@link CREATE_TABLES_SQL} after the initial schema, expressed
111
+ * as `ALTER TABLE ADD COLUMN` migrations. SQLite's `CREATE TABLE IF NOT EXISTS`
112
+ * does not reconcile column lists against existing tables, so a deploy that ran
113
+ * an older version of this adapter will be missing any column added later. The
114
+ * adapter applies these on startup so existing databases stay usable across
115
+ * upgrades.
116
+ *
117
+ * Append-only: SQLite does not support DROP COLUMN or general ALTER COLUMN, so
118
+ * removing or changing an entry here would break upgrades from older versions.
119
+ * Only add new entries when a column is added to {@link CREATE_TABLES_SQL}.
120
+ */
121
+ const SCHEMA_MIGRATIONS: ReadonlyArray<{ table: string; column: string; type: string }> = [
122
+ { table: 'workflow_runs', column: 'completed_steps_json', type: 'TEXT' },
123
+ ];
124
+
85
125
  // ============================================================================
86
126
  // Configuration
87
127
  // ============================================================================
@@ -107,6 +147,61 @@ export interface SQLiteStorageConfig {
107
147
  * Table names are always prefixed with 'workflow_'.
108
148
  */
109
149
  tablePrefix?: string;
150
+
151
+ /**
152
+ * Optional structured logger used to surface JSON corruption events.
153
+ * Without one, parse failures still increment the corruption counter
154
+ * exposed via `getJsonParseCorruptionCount()` but produce no log output.
155
+ */
156
+ logger?: Logger;
157
+ }
158
+
159
+ // ============================================================================
160
+ // Helpers
161
+ // ============================================================================
162
+
163
+ /**
164
+ * better-sqlite3 raises `SqliteError { code: 'SQLITE_ERROR', message:
165
+ * 'duplicate column name: <name>' }` when an `ALTER TABLE ADD COLUMN` targets
166
+ * a column that already exists. Treat that as a benign no-op so concurrent
167
+ * startups (or any race between the {@link runMigrations} existence check and
168
+ * the ALTER) don't crash. All other errors propagate.
169
+ */
170
+ function isDuplicateColumnError(err: unknown): boolean {
171
+ if (!err || typeof err !== 'object') return false;
172
+ const code = (err as { code?: unknown }).code;
173
+ const message = (err as { message?: unknown }).message;
174
+ return (
175
+ code === 'SQLITE_ERROR' &&
176
+ typeof message === 'string' &&
177
+ message.toLowerCase().includes('duplicate column name')
178
+ );
179
+ }
180
+
181
+ /**
182
+ * Resolve to `true` if `p` settles within a few microtask ticks of being
183
+ * inspected, otherwise `false`. Used by the deprecated async `transaction()`
184
+ * to detect callbacks that suspend on real async I/O — by the time we ask,
185
+ * the synchronous COMMIT has already run, so a still-pending promise means
186
+ * any post-await operations land outside the transaction.
187
+ */
188
+ async function isPromiseSettled(p: Promise<unknown>): Promise<boolean> {
189
+ let settled = false;
190
+ p.then(
191
+ () => {
192
+ settled = true;
193
+ },
194
+ () => {
195
+ settled = true;
196
+ }
197
+ );
198
+ // Drain a handful of microtask ticks. `await Promise.resolve()` callbacks
199
+ // and other already-resolved continuations finish in 1-2 ticks; anything
200
+ // still pending after this is doing real async I/O (timers, network, etc.).
201
+ for (let i = 0; i < 5 && !settled; i++) {
202
+ await Promise.resolve();
203
+ }
204
+ return settled;
110
205
  }
111
206
 
112
207
  // ============================================================================
@@ -136,6 +231,7 @@ export interface SQLiteStorageConfig {
136
231
  export class SQLiteStorageAdapter implements StorageAdapter {
137
232
  private db: Database.Database;
138
233
  private prefix: string;
234
+ private logger: Logger | undefined;
139
235
 
140
236
  // Prepared statements (cached for performance)
141
237
  private stmts: {
@@ -160,6 +256,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
160
256
  constructor(config: SQLiteStorageConfig) {
161
257
  this.db = config.db;
162
258
  this.prefix = config.tablePrefix ?? 'workflow';
259
+ this.logger = config.logger;
163
260
 
164
261
  // Enable foreign keys
165
262
  this.db.pragma('foreign_keys = ON');
@@ -169,6 +266,14 @@ export class SQLiteStorageAdapter implements StorageAdapter {
169
266
  this.createTables();
170
267
  }
171
268
 
269
+ // Always reconcile column-level migrations: the prepared statements below
270
+ // reference the latest schema, so an adapter sitting on top of an older
271
+ // table layout (e.g., a database created by a previous version, or a
272
+ // caller-managed schema with `autoCreateTables: false` that has not been
273
+ // hand-upgraded) would crash on the first INSERT/SELECT. Migrations are
274
+ // idempotent and skip tables that don't exist.
275
+ this.runMigrations();
276
+
172
277
  // Prepare statements
173
278
  this.prepareStatements();
174
279
  }
@@ -180,6 +285,39 @@ export class SQLiteStorageAdapter implements StorageAdapter {
180
285
  this.db.exec(CREATE_TABLES_SQL);
181
286
  }
182
287
 
288
+ /**
289
+ * Apply additive schema migrations to existing tables.
290
+ *
291
+ * `CREATE TABLE IF NOT EXISTS` is a no-op when the table already exists, so
292
+ * any column added to {@link CREATE_TABLES_SQL} after the initial schema is
293
+ * silently absent on databases created by older versions of this adapter.
294
+ * Without this step, the first INSERT/SELECT crashes with
295
+ * `SqliteError: table X has no column named Y`. This mirrors the
296
+ * `ALTER TABLE ADD COLUMN IF NOT EXISTS` block in `postgres.ts`'s
297
+ * `createTables()`.
298
+ *
299
+ * For each entry in {@link SCHEMA_MIGRATIONS}: if the table exists and the
300
+ * column does not, run `ALTER TABLE ADD COLUMN`. The ALTER is wrapped in a
301
+ * try/catch that tolerates `duplicate column` errors so concurrent startups
302
+ * (or any race between the existence check and the ALTER) do not crash.
303
+ * Other SQLite errors propagate.
304
+ */
305
+ private runMigrations(): void {
306
+ for (const { table, column, type } of SCHEMA_MIGRATIONS) {
307
+ const tableInfo = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
308
+ // PRAGMA table_info on a missing table returns []; CREATE TABLE handles that case.
309
+ if (tableInfo.length === 0) continue;
310
+ if (tableInfo.some((col) => col.name === column)) continue;
311
+
312
+ try {
313
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
314
+ } catch (err) {
315
+ if (isDuplicateColumnError(err)) continue;
316
+ throw err;
317
+ }
318
+ }
319
+ }
320
+
183
321
  /**
184
322
  * Prepare all SQL statements for better performance.
185
323
  */
@@ -307,7 +445,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
307
445
 
308
446
  async getRun(runId: string): Promise<WorkflowRunRecord | null> {
309
447
  const row = this.stmts!.getRun.get(runId) as SQLiteRunRow | undefined;
310
- return row ? this.mapRunRow(row) : null;
448
+ return row ? mapRunRow(row, this.mapperCtx) : null;
311
449
  }
312
450
 
313
451
  async updateRun(runId: string, updates: Partial<WorkflowRunRecord>): Promise<void> {
@@ -344,7 +482,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
344
482
  ) as { count: number };
345
483
 
346
484
  return {
347
- items: rows.map(row => this.mapRunRow(row)),
485
+ items: rows.map(row => mapRunRow(row, this.mapperCtx)),
348
486
  total: countResult.count,
349
487
  limit,
350
488
  offset,
@@ -376,7 +514,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
376
514
 
377
515
  async getStep(stepId: string): Promise<WorkflowRunStepRecord | null> {
378
516
  const row = this.stmts!.getStep.get(stepId) as SQLiteStepRow | undefined;
379
- return row ? this.mapStepRow(row) : null;
517
+ return row ? mapStepRow(row, this.mapperCtx) : null;
380
518
  }
381
519
 
382
520
  async updateStep(stepId: string, updates: Partial<WorkflowRunStepRecord>): Promise<void> {
@@ -392,7 +530,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
392
530
 
393
531
  async getStepsForRun(runId: string): Promise<WorkflowRunStepRecord[]> {
394
532
  const rows = this.stmts!.getStepsForRun.all(runId) as SQLiteStepRow[];
395
- return rows.map(row => this.mapStepRow(row));
533
+ return rows.map(row => mapStepRow(row, this.mapperCtx));
396
534
  }
397
535
 
398
536
  // ============================================================================
@@ -426,7 +564,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
426
564
  limit, offset
427
565
  ) as SQLiteEventRow[];
428
566
 
429
- return rows.map(row => this.mapEventRow(row));
567
+ return rows.map(row => mapEventRow(row, this.mapperCtx));
430
568
  }
431
569
 
432
570
  // ============================================================================
@@ -436,16 +574,36 @@ export class SQLiteStorageAdapter implements StorageAdapter {
436
574
  /**
437
575
  * Execute a function within a database transaction (async interface).
438
576
  *
439
- * @deprecated Use `transactionSync()` instead. This method only works when
440
- * the callback performs purely synchronous operations wrapped in async/await.
441
- * If the callback awaits real async I/O (network, timers, etc.), it will
442
- * throw an error. `transactionSync()` makes the synchronous requirement explicit.
577
+ * @deprecated Use {@link transactionSync} instead. better-sqlite3 transactions
578
+ * are synchronous: COMMIT happens the moment the callback returns control to
579
+ * `db.transaction(fn)()`. If the callback awaits real async I/O (network,
580
+ * timers, etc.), the COMMIT has already happened by the time the awaited work
581
+ * runs, so its operations are **not** transactional.
582
+ *
583
+ * To make the footgun visible the adapter installs a runtime guard: when the
584
+ * callback's promise has not settled by the time the COMMIT returns, the
585
+ * deprecated method emits a `console.warn` so that the misuse surfaces in
586
+ * production logs. The promise is still returned so existing callers keep
587
+ * working — switch to `transactionSync` to silence the warning.
443
588
  */
444
589
  async transaction<T>(fn: (tx: StorageAdapter) => Promise<T>): Promise<T> {
445
590
  let fnPromise!: Promise<T>;
446
591
  this.transactionSync(() => {
447
592
  fnPromise = fn(this);
448
593
  });
594
+
595
+ // COMMIT has already executed at this point. If the callback's promise is
596
+ // still pending, the callback is doing async work outside the transaction
597
+ // boundary — log a warning so the misuse is visible in production logs.
598
+ if (!(await isPromiseSettled(fnPromise))) {
599
+ console.warn(
600
+ '[SQLiteStorageAdapter] transaction() callback did not settle synchronously. ' +
601
+ 'better-sqlite3 transactions are synchronous and the COMMIT has already executed; ' +
602
+ 'any async work inside the callback is NOT transactional. ' +
603
+ 'Use transactionSync() with purely synchronous operations instead.'
604
+ );
605
+ }
606
+
449
607
  return fnPromise;
450
608
  }
451
609
 
@@ -497,7 +655,7 @@ export class SQLiteStorageAdapter implements StorageAdapter {
497
655
  */
498
656
  async getInterruptedRuns(): Promise<WorkflowRunRecord[]> {
499
657
  const rows = this.stmts!.getInterruptedRuns.all() as SQLiteRunRow[];
500
- return rows.map(row => this.mapRunRow(row));
658
+ return rows.map(row => mapRunRow(row, this.mapperCtx));
501
659
  }
502
660
 
503
661
  /**
@@ -506,55 +664,22 @@ export class SQLiteStorageAdapter implements StorageAdapter {
506
664
  */
507
665
  async getLastCompletedStep(runId: string): Promise<WorkflowRunStepRecord | null> {
508
666
  const row = this.stmts!.getLastCompletedStep.get(runId) as SQLiteStepRow | undefined;
509
- return row ? this.mapStepRow(row) : null;
667
+ return row ? mapStepRow(row, this.mapperCtx) : null;
510
668
  }
511
669
 
512
670
  // ============================================================================
513
671
  // Row Mapping
514
672
  // ============================================================================
515
-
516
- private mapRunRow(row: SQLiteRunRow): WorkflowRunRecord {
517
- return {
518
- id: row.id,
519
- kind: row.kind,
520
- status: row.status as RunStatus,
521
- parentRunId: row.parent_run_id ?? undefined,
522
- input: JSON.parse(row.input_json),
523
- metadata: JSON.parse(row.metadata_json),
524
- context: JSON.parse(row.context_json),
525
- completedSteps: row.completed_steps_json ? JSON.parse(row.completed_steps_json) : undefined,
526
- error: row.error_json ? JSON.parse(row.error_json) : undefined,
527
- createdAt: new Date(row.created_at),
528
- startedAt: row.started_at ? new Date(row.started_at) : undefined,
529
- finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
530
- };
531
- }
532
-
533
- private mapStepRow(row: SQLiteStepRow): WorkflowRunStepRecord {
534
- return {
535
- id: row.id,
536
- runId: row.run_id,
537
- stepKey: row.step_key,
538
- stepName: row.step_name,
539
- status: row.status as StepStatus,
540
- attempt: row.attempt,
541
- result: row.result_json ? JSON.parse(row.result_json) : undefined,
542
- error: row.error_json ? JSON.parse(row.error_json) : undefined,
543
- startedAt: row.started_at ? new Date(row.started_at) : undefined,
544
- finishedAt: row.finished_at ? new Date(row.finished_at) : undefined,
545
- };
546
- }
547
-
548
- private mapEventRow(row: SQLiteEventRow): WorkflowEventRecord {
549
- return {
550
- id: row.id,
551
- runId: row.run_id,
552
- stepKey: row.step_key ?? undefined,
553
- eventType: row.event_type,
554
- level: row.level as 'info' | 'warn' | 'error',
555
- payload: row.payload_json ? JSON.parse(row.payload_json) : undefined,
556
- timestamp: new Date(row.timestamp),
557
- };
673
+ //
674
+ // The pure row mappers (`mapRunRow`, `mapStepRow`, `mapEventRow`) live in
675
+ // `./sqlite-core.ts`. Each call site here builds a `MapperContext` once via
676
+ // `this.mapperCtx` and hands it to the mapper, which extends it with
677
+ // `rowId` + `column` per JSON-parse so corruption logs identify the bad row
678
+ // (audit M2 / L3 — PR #52).
679
+
680
+ /** Mapper context paired with the adapter's component label + optional logger. */
681
+ private get mapperCtx(): MapperContext {
682
+ return { component: 'SQLiteStorageAdapter', logger: this.logger };
558
683
  }
559
684
 
560
685
  // ============================================================================
@@ -587,41 +712,6 @@ export class SQLiteStorageAdapter implements StorageAdapter {
587
712
  // ============================================================================
588
713
  // SQLite Row Types
589
714
  // ============================================================================
590
-
591
- interface SQLiteRunRow {
592
- id: string;
593
- kind: string;
594
- status: string;
595
- parent_run_id: string | null;
596
- input_json: string;
597
- metadata_json: string;
598
- context_json: string;
599
- completed_steps_json: string | null;
600
- error_json: string | null;
601
- created_at: string;
602
- started_at: string | null;
603
- finished_at: string | null;
604
- }
605
-
606
- interface SQLiteStepRow {
607
- id: string;
608
- run_id: string;
609
- step_key: string;
610
- step_name: string;
611
- status: string;
612
- attempt: number;
613
- result_json: string | null;
614
- error_json: string | null;
615
- started_at: string | null;
616
- finished_at: string | null;
617
- }
618
-
619
- interface SQLiteEventRow {
620
- id: string;
621
- run_id: string;
622
- step_key: string | null;
623
- event_type: string;
624
- level: string;
625
- payload_json: string | null;
626
- timestamp: string;
627
- }
715
+ //
716
+ // `SQLiteRunRow`, `SQLiteStepRow`, and `SQLiteEventRow` now live in
717
+ // `./sqlite-core.ts` alongside their pure row mappers.
@@ -6,3 +6,5 @@ export * from './id';
6
6
  export * from './errors';
7
7
  export * from './logger';
8
8
  export * from './retry';
9
+ export * from './safe-json';
10
+ export * from './webhook-signature';
@@ -0,0 +1,84 @@
1
+ /**
2
+ * pinnedFetch is the linchpin of the M1 (DNS rebinding TOCTOU) fix: by
3
+ * passing a `lookup` function that returns a caller-supplied IP, the TCP
4
+ * connection cannot be re-resolved to a different address. We test it
5
+ * against a real loopback HTTP server because ESM forbids spying on the
6
+ * `http` module's exports and we want to exercise the real request path
7
+ * end-to-end.
8
+ */
9
+
10
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11
+ import * as http from 'node:http';
12
+ import { pinnedFetch } from './pinned-fetch';
13
+
14
+ describe('pinnedFetch', () => {
15
+ let server: http.Server;
16
+ let port: number;
17
+ let lastRequest: { hostHeader?: string; method?: string; path?: string; body: string } = {
18
+ body: '',
19
+ };
20
+
21
+ beforeAll(async () => {
22
+ server = http.createServer((req, res) => {
23
+ const chunks: Buffer[] = [];
24
+ req.on('data', (c: Buffer) => chunks.push(c));
25
+ req.on('end', () => {
26
+ lastRequest = {
27
+ hostHeader: req.headers.host,
28
+ method: req.method,
29
+ path: req.url,
30
+ body: Buffer.concat(chunks).toString('utf8'),
31
+ };
32
+ res.writeHead(204, 'No Content');
33
+ res.end();
34
+ });
35
+ });
36
+ await new Promise<void>((resolve) => {
37
+ server.listen(0, '127.0.0.1', () => resolve());
38
+ });
39
+ port = (server.address() as { port: number }).port;
40
+ });
41
+
42
+ afterAll(async () => {
43
+ await new Promise<void>((resolve) => server.close(() => resolve()));
44
+ });
45
+
46
+ it('connects to the pinned IP even when the URL hostname is unrelated', async () => {
47
+ // The URL hostname is one that would never resolve to 127.0.0.1 via DNS.
48
+ // pinnedFetch must ignore the URL's would-be DNS answer and connect to
49
+ // the pinned address. If pinning is broken, the connection will fail
50
+ // or hit a different host.
51
+ const response = await pinnedFetch(
52
+ `http://rebind.invalid:${port}/hook`,
53
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"ok":1}' },
54
+ '127.0.0.1',
55
+ 4,
56
+ );
57
+
58
+ expect(response.ok).toBe(true);
59
+ expect(response.status).toBe(204);
60
+ expect(lastRequest.method).toBe('POST');
61
+ expect(lastRequest.path).toBe('/hook');
62
+ expect(lastRequest.body).toBe('{"ok":1}');
63
+ });
64
+
65
+ it('preserves the original hostname in the Host header (vhost / TLS SNI)', async () => {
66
+ await pinnedFetch(
67
+ `http://example.test:${port}/`,
68
+ { method: 'GET' },
69
+ '127.0.0.1',
70
+ 4,
71
+ );
72
+
73
+ // The Host header must reflect the URL hostname, not the pinned IP, so
74
+ // that virtual-host routing and TLS certificate validation work.
75
+ expect(lastRequest.hostHeader).toContain('example.test');
76
+ });
77
+
78
+ it('rejects when the connection cannot be established', async () => {
79
+ // Pin to a non-listening port on loopback.
80
+ await expect(
81
+ pinnedFetch(`http://example.test:1/`, { method: 'GET' }, '127.0.0.1', 4),
82
+ ).rejects.toThrow();
83
+ });
84
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * DNS-pinned fetch helper.
3
+ *
4
+ * Performs an HTTP(S) request that connects to a caller-supplied IP address
5
+ * instead of re-resolving the URL hostname. Used by the webhook transport to
6
+ * close the SSRF DNS-rebinding TOCTOU window: the hostname is resolved
7
+ * exactly once (by the caller), the resolved IP is validated, and the
8
+ * resolved IP is then pinned for the connection so the request cannot be
9
+ * rerouted by a hostile DNS server.
10
+ *
11
+ * The original hostname is preserved for TLS SNI / certificate verification
12
+ * and for the HTTP `Host` header.
13
+ *
14
+ * Audit: 2026-04-27 finding M1 — DNS TOCTOU between SSRF validation and
15
+ * connect.
16
+ */
17
+
18
+ import * as http from 'node:http';
19
+ import * as https from 'node:https';
20
+ import type { LookupAddress, LookupOptions } from 'node:dns';
21
+
22
+ /** Minimal fetch-compatible Response surface used by the webhook transport. */
23
+ export interface PinnedFetchResponse {
24
+ ok: boolean;
25
+ status: number;
26
+ statusText: string;
27
+ }
28
+
29
+ /** Subset of `RequestInit` accepted by `pinnedFetch`. */
30
+ export interface PinnedFetchInit {
31
+ method?: string;
32
+ headers?: Record<string, string>;
33
+ body?: string;
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ /**
38
+ * Make an HTTP(S) request that connects to `address` instead of resolving
39
+ * `url`'s hostname. Returns a minimal `Response`-shaped object compatible
40
+ * with the webhook transport's status / ok checks.
41
+ *
42
+ * @throws Error on transport failure or abort
43
+ */
44
+ export function pinnedFetch(
45
+ url: string,
46
+ init: PinnedFetchInit,
47
+ address: string,
48
+ family: 4 | 6 = 4,
49
+ ): Promise<PinnedFetchResponse> {
50
+ const parsed = new URL(url);
51
+ const isHttps = parsed.protocol === 'https:';
52
+ const lib = isHttps ? https : http;
53
+ const port = parsed.port ? Number(parsed.port) : isHttps ? 443 : 80;
54
+
55
+ // `lookup` is called by the agent's socket-create path. By returning the
56
+ // pre-validated address here we guarantee the TCP connection target is the
57
+ // IP whose safety the caller already verified. Node may invoke this with
58
+ // `{ all: true }` (expects an array result) or single-address mode
59
+ // (expects positional address/family) — handle both for cross-version safety.
60
+ const lookup = (
61
+ _hostname: string,
62
+ options: LookupOptions,
63
+ cb: (err: NodeJS.ErrnoException | null, addr: string | LookupAddress[], fam?: number) => void,
64
+ ): void => {
65
+ if (options && options.all === true) {
66
+ cb(null, [{ address, family }]);
67
+ } else {
68
+ cb(null, address, family);
69
+ }
70
+ };
71
+
72
+ return new Promise<PinnedFetchResponse>((resolve, reject) => {
73
+ const req = lib.request(
74
+ {
75
+ hostname: parsed.hostname,
76
+ port,
77
+ path: `${parsed.pathname || '/'}${parsed.search}`,
78
+ method: init.method ?? 'GET',
79
+ headers: init.headers,
80
+ lookup,
81
+ servername: parsed.hostname,
82
+ },
83
+ (res) => {
84
+ // Drain the body — we only inspect status, but leaving the socket
85
+ // half-read would leak it.
86
+ res.resume();
87
+ res.on('end', () => {
88
+ const status = res.statusCode ?? 0;
89
+ resolve({
90
+ ok: status >= 200 && status < 300,
91
+ status,
92
+ statusText: res.statusMessage ?? '',
93
+ });
94
+ });
95
+ res.on('error', reject);
96
+ },
97
+ );
98
+
99
+ req.on('error', reject);
100
+
101
+ if (init.signal) {
102
+ if (init.signal.aborted) {
103
+ req.destroy(new Error('Request aborted'));
104
+ } else {
105
+ init.signal.addEventListener(
106
+ 'abort',
107
+ () => req.destroy(new Error('Request aborted')),
108
+ { once: true },
109
+ );
110
+ }
111
+ }
112
+
113
+ if (init.body !== undefined) {
114
+ req.write(init.body);
115
+ }
116
+ req.end();
117
+ });
118
+ }
@@ -61,3 +61,19 @@ export async function loadPostgresDeps(): Promise<PostgresDeps> {
61
61
 
62
62
  return loadingPromise;
63
63
  }
64
+
65
+ /**
66
+ * Synchronously access the cached PostgreSQL deps. Throws if
67
+ * `loadPostgresDeps()` has not yet completed. Used by code paths that run
68
+ * after `PostgresStorageAdapter.initialize()` and need direct access to
69
+ * `sql` / `Kysely` without re-awaiting.
70
+ */
71
+ export function requirePostgresDeps(): PostgresDeps {
72
+ if (!cached) {
73
+ throw new Error(
74
+ 'PostgreSQL dependencies have not been loaded yet. ' +
75
+ 'Call loadPostgresDeps() (or PostgresStorageAdapter.initialize()) first.'
76
+ );
77
+ }
78
+ return cached;
79
+ }