@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.
- package/.codekin/reports/code-review/2026-04-11_code-review-daily.md +146 -0
- package/.codekin/reports/code-review/2026-04-13_code-review-daily.md +74 -0
- package/.codekin/reports/comments/2026-04-11_comment-assessment.md +161 -0
- package/.codekin/reports/comments/2026-04-14_comment-assessment.md +34 -0
- package/.codekin/reports/complexity/2026-04-11_complexity-report.md +152 -0
- package/.codekin/reports/complexity/2026-04-14_complexity-report.md +34 -0
- package/.codekin/reports/dependencies/2026-04-11_dependency-health.md +93 -0
- package/.codekin/reports/docs-audit/2026-04-11_docs-audit.md +138 -0
- package/.codekin/reports/docs-audit/2026-04-13_docs-audit.md +121 -0
- package/.codekin/reports/security/2026-04-11_security-audit.md +297 -0
- package/.github/workflows/publish.yml +6 -0
- package/dist/{chunk-4VOKMGP6.js → chunk-5XJBAPXF.js} +517 -522
- package/dist/chunk-5XJBAPXF.js.map +1 -0
- package/dist/{chunk-KHTUWLVZ.js → chunk-FMPJBK2N.js} +55 -10
- package/dist/chunk-FMPJBK2N.js.map +1 -0
- package/dist/{chunk-ML35PIHX.js → chunk-JPXPB7U5.js} +52 -2
- package/dist/chunk-JPXPB7U5.js.map +1 -0
- package/dist/{chunk-GYPNJBDP.js → chunk-U3PCVK6O.js} +266 -47
- package/dist/chunk-U3PCVK6O.js.map +1 -0
- package/dist/events/index.d.ts +56 -3
- package/dist/events/index.js +1 -1
- package/dist/{index-Dzr437iW.d.ts → index-Cd0IZ40L.d.ts} +50 -11
- package/dist/index.d.ts +2 -12
- package/dist/index.js +764 -485
- package/dist/index.js.map +1 -1
- package/dist/scheduler/index.d.ts +1 -1
- package/dist/scheduler/index.js +2 -2
- package/dist/storage/index.d.ts +313 -47
- package/dist/storage/index.js +4 -2
- package/docs/stepflow-api-reference.md +3 -1
- package/package.json +7 -4
- package/src/core/engine/concurrency.ts +229 -0
- package/src/core/engine/lifecycle.ts +176 -0
- package/src/core/engine.test.ts +70 -0
- package/src/core/engine.ts +91 -185
- package/src/core/orchestrator/persistence.ts +165 -0
- package/src/core/orchestrator/plan-resolution.ts +140 -0
- package/src/core/orchestrator/state-transitions.ts +109 -0
- package/src/core/orchestrator/step-runner.ts +427 -0
- package/src/core/orchestrator.ts +40 -359
- package/src/events/webhook.test.ts +292 -0
- package/src/events/webhook.ts +320 -77
- package/src/planning/evaluate-condition.test.ts +153 -0
- package/src/planning/evaluate-condition.ts +105 -0
- package/src/planning/planner.ts +1 -83
- package/src/planning/registry.ts +3 -85
- package/src/scheduler/cron.test.ts +9 -1
- package/src/scheduler/cron.ts +26 -2
- package/src/scheduler/postgres-persistence.ts +27 -15
- package/src/scheduler/sqlite-persistence.ts +28 -3
- package/src/storage/postgres-core.ts +550 -0
- package/src/storage/postgres-migration.test.ts +69 -0
- package/src/storage/postgres-transaction-pagination.test.ts +107 -0
- package/src/storage/postgres.ts +171 -690
- package/src/storage/sqlite-core.ts +151 -0
- package/src/storage/sqlite.test.ts +177 -1
- package/src/storage/sqlite.ts +183 -93
- package/src/utils/index.ts +2 -0
- package/src/utils/pinned-fetch.test.ts +84 -0
- package/src/utils/pinned-fetch.ts +118 -0
- package/src/utils/postgres-deps.ts +16 -0
- package/src/utils/safe-json.test.ts +166 -0
- package/src/utils/safe-json.ts +107 -0
- package/src/utils/webhook-signature.test.ts +111 -0
- package/src/utils/webhook-signature.ts +74 -0
- package/dist/chunk-4VOKMGP6.js.map +0 -1
- package/dist/chunk-GYPNJBDP.js.map +0 -1
- package/dist/chunk-KHTUWLVZ.js.map +0 -1
- package/dist/chunk-ML35PIHX.js.map +0 -1
package/src/storage/sqlite.ts
CHANGED
|
@@ -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 {
|
|
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 ?
|
|
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 =>
|
|
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 ?
|
|
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 =>
|
|
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 =>
|
|
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
|
|
440
|
-
*
|
|
441
|
-
* If the callback awaits real async I/O (network,
|
|
442
|
-
*
|
|
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 =>
|
|
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 ?
|
|
667
|
+
return row ? mapStepRow(row, this.mapperCtx) : null;
|
|
510
668
|
}
|
|
511
669
|
|
|
512
670
|
// ============================================================================
|
|
513
671
|
// Row Mapping
|
|
514
672
|
// ============================================================================
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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.
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|