@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
@@ -495,6 +495,275 @@ describe('WebhookEventTransport', () => {
495
495
  });
496
496
  });
497
497
 
498
+ describe('SSRF protection - IPv6 coverage', () => {
499
+ it('should block IPv6 loopback ::1 (in any abbreviated form)', () => {
500
+ expect(() =>
501
+ transport.addEndpoint({ id: 'v6-loop-1', url: 'https://[::1]/hook' })
502
+ ).toThrow(/blocked/);
503
+ expect(() =>
504
+ transport.addEndpoint({ id: 'v6-loop-2', url: 'https://[0000:0000:0000:0000:0000:0000:0000:0001]/hook' })
505
+ ).toThrow(/blocked/);
506
+ });
507
+
508
+ it('should block IPv6 unspecified ::', () => {
509
+ expect(() =>
510
+ transport.addEndpoint({ id: 'v6-unspec', url: 'https://[::]/hook' })
511
+ ).toThrow(/blocked/);
512
+ });
513
+
514
+ it('should block link-local fe80::/10 across the full range', () => {
515
+ expect(() =>
516
+ transport.addEndpoint({ id: 'v6-ll-1', url: 'https://[fe80::1]/hook' })
517
+ ).toThrow(/blocked/);
518
+ expect(() =>
519
+ transport.addEndpoint({ id: 'v6-ll-2', url: 'https://[febf::abcd]/hook' })
520
+ ).toThrow(/blocked/);
521
+ // fec0:: is NOT link-local (deprecated site-local), should not be caught by fe80::/10
522
+ // but fe7f:: is below the range and should be allowed
523
+ transport.addEndpoint({ id: 'v6-ll-ok', url: 'https://[fe7f::1]/hook' });
524
+ expect(transport.getEndpoints().find(e => e.id === 'v6-ll-ok')).toBeDefined();
525
+ });
526
+
527
+ it('should block ULA fc00::/7 (fc and fd prefixes)', () => {
528
+ expect(() =>
529
+ transport.addEndpoint({ id: 'v6-ula-1', url: 'https://[fc00::1]/hook' })
530
+ ).toThrow(/blocked/);
531
+ expect(() =>
532
+ transport.addEndpoint({ id: 'v6-ula-2', url: 'https://[fd12:3456::abcd]/hook' })
533
+ ).toThrow(/blocked/);
534
+ expect(() =>
535
+ transport.addEndpoint({ id: 'v6-ula-3', url: 'https://[fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff]/hook' })
536
+ ).toThrow(/blocked/);
537
+ });
538
+
539
+ it('should block IPv4-mapped ::ffff:0:0/96 by decoding embedded IPv4', () => {
540
+ // ::ffff:127.0.0.1 → loopback
541
+ expect(() =>
542
+ transport.addEndpoint({ id: 'v6-map-1', url: 'https://[::ffff:127.0.0.1]/hook' })
543
+ ).toThrow(/blocked/);
544
+ // ::ffff:10.0.0.1 → RFC 1918
545
+ expect(() =>
546
+ transport.addEndpoint({ id: 'v6-map-2', url: 'https://[::ffff:10.0.0.1]/hook' })
547
+ ).toThrow(/blocked/);
548
+ // ::ffff:169.254.169.254 → cloud metadata
549
+ expect(() =>
550
+ transport.addEndpoint({ id: 'v6-map-3', url: 'https://[::ffff:169.254.169.254]/hook' })
551
+ ).toThrow(/blocked/);
552
+ // Hex form ::ffff:c0a8:0001 == ::ffff:192.168.0.1
553
+ expect(() =>
554
+ transport.addEndpoint({ id: 'v6-map-4', url: 'https://[::ffff:c0a8:0001]/hook' })
555
+ ).toThrow(/blocked/);
556
+ });
557
+
558
+ it('should block 6to4 2002::/16 by decoding embedded IPv4', () => {
559
+ // 2002:7f00:0001:: == 6to4 wrapping 127.0.0.1
560
+ expect(() =>
561
+ transport.addEndpoint({ id: 'v6-6to4-loop', url: 'https://[2002:7f00:0001::]/hook' })
562
+ ).toThrow(/blocked/);
563
+ // 2002:c0a8:0001:: == 6to4 wrapping 192.168.0.1
564
+ expect(() =>
565
+ transport.addEndpoint({ id: 'v6-6to4-1918', url: 'https://[2002:c0a8:0001::]/hook' })
566
+ ).toThrow(/blocked/);
567
+ });
568
+
569
+ it('should block Teredo 2001::/32', () => {
570
+ expect(() =>
571
+ transport.addEndpoint({ id: 'v6-teredo', url: 'https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/hook' })
572
+ ).toThrow(/blocked/);
573
+ });
574
+
575
+ it('should block IPv6 multicast ff00::/8', () => {
576
+ expect(() =>
577
+ transport.addEndpoint({ id: 'v6-mc', url: 'https://[ff02::1]/hook' })
578
+ ).toThrow(/blocked/);
579
+ });
580
+
581
+ it('should allow legitimate public IPv6 addresses', () => {
582
+ // Google Public DNS — must not be blocked
583
+ transport.addEndpoint({ id: 'v6-pub-1', url: 'https://[2001:4860:4860::8888]/hook' });
584
+ // Cloudflare DNS
585
+ transport.addEndpoint({ id: 'v6-pub-2', url: 'https://[2606:4700:4700::1111]/hook' });
586
+ expect(transport.getEndpoints().find(e => e.id === 'v6-pub-1')).toBeDefined();
587
+ expect(transport.getEndpoints().find(e => e.id === 'v6-pub-2')).toBeDefined();
588
+ });
589
+
590
+ it('should block when DNS resolves to an IPv6 ULA address', async () => {
591
+ const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
592
+ dnsLookup.mockResolvedValueOnce({ address: 'fd00::1', family: 6 });
593
+
594
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
595
+ transport = new WebhookEventTransport({
596
+ fetchFn: mockFetch,
597
+ defaultRetries: 0,
598
+ logger,
599
+ });
600
+
601
+ transport.addEndpoint({
602
+ id: 'v6-dns-ula',
603
+ url: 'https://internal.example.com/hook',
604
+ });
605
+
606
+ transport.emit(createTestEvent());
607
+ await new Promise((r) => setTimeout(r, 50));
608
+
609
+ expect(mockFetch).not.toHaveBeenCalled();
610
+ expect(logger.error).toHaveBeenCalledWith(
611
+ expect.stringContaining('v6-dns-ula'),
612
+ expect.objectContaining({
613
+ message: expect.stringContaining('resolves to blocked IP'),
614
+ })
615
+ );
616
+ });
617
+
618
+ it('should block when DNS resolves to an IPv4-mapped IPv6 loopback', async () => {
619
+ const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
620
+ dnsLookup.mockResolvedValueOnce({ address: '::ffff:127.0.0.1', family: 6 });
621
+
622
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
623
+ transport = new WebhookEventTransport({
624
+ fetchFn: mockFetch,
625
+ defaultRetries: 0,
626
+ logger,
627
+ });
628
+
629
+ transport.addEndpoint({
630
+ id: 'v6-dns-mapped',
631
+ url: 'https://sneaky.example.com/hook',
632
+ });
633
+
634
+ transport.emit(createTestEvent());
635
+ await new Promise((r) => setTimeout(r, 50));
636
+
637
+ expect(mockFetch).not.toHaveBeenCalled();
638
+ });
639
+ });
640
+
641
+ describe('queue back-pressure (memory exhaustion guard)', () => {
642
+ it('should drop deliveries beyond maxQueueDepth and log a warning', async () => {
643
+ // Hold every fetch open so the active slot fills and subsequent emits queue.
644
+ let resolveFetch: ((v: { ok: true }) => void) | undefined;
645
+ const blockingFetch = vi.fn().mockImplementation(
646
+ () => new Promise<{ ok: true }>((r) => { resolveFetch = r; })
647
+ );
648
+
649
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
650
+
651
+ transport = new WebhookEventTransport({
652
+ fetchFn: blockingFetch as unknown as typeof fetch,
653
+ defaultRetries: 0,
654
+ maxConcurrentRequests: 1,
655
+ maxQueueDepth: 2,
656
+ logger,
657
+ });
658
+
659
+ transport.addEndpoint({ id: 'cap-test', url: 'https://example.com/hook' });
660
+
661
+ // 1st emit → consumes the active slot.
662
+ // 2nd, 3rd emit → fill the queue (depth 2/2).
663
+ // 4th, 5th emit → must be dropped.
664
+ for (let i = 0; i < 5; i++) {
665
+ transport.emit(createTestEvent({ runId: `run-${i}` }));
666
+ }
667
+
668
+ // Let the dispatch loop run.
669
+ await new Promise((r) => setTimeout(r, 10));
670
+
671
+ expect(transport.getDroppedRequestCount()).toBe(2);
672
+ expect(transport.getQueueDepth()).toBe(2);
673
+ expect(logger.warn).toHaveBeenCalledTimes(2);
674
+ expect(logger.warn).toHaveBeenCalledWith(
675
+ expect.stringMatching(/cap-test delivery dropped: queue full/)
676
+ );
677
+ expect(logger.warn).toHaveBeenCalledWith(
678
+ expect.stringMatching(/retryAfterMs 1000/)
679
+ );
680
+
681
+ // Drain the in-flight request so .close() doesn't hang.
682
+ resolveFetch?.({ ok: true });
683
+ await new Promise((r) => setTimeout(r, 20));
684
+ });
685
+
686
+ it('should resume accepting deliveries after the queue drains', async () => {
687
+ let pendingResolvers: Array<(v: { ok: true }) => void> = [];
688
+ const blockingFetch = vi.fn().mockImplementation(
689
+ () => new Promise<{ ok: true }>((r) => { pendingResolvers.push(r); })
690
+ );
691
+
692
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
693
+
694
+ transport = new WebhookEventTransport({
695
+ fetchFn: blockingFetch as unknown as typeof fetch,
696
+ defaultRetries: 0,
697
+ maxConcurrentRequests: 1,
698
+ maxQueueDepth: 1,
699
+ logger,
700
+ });
701
+
702
+ transport.addEndpoint({ id: 'drain', url: 'https://example.com/hook' });
703
+
704
+ // 1 active + 1 queued + 1 dropped
705
+ transport.emit(createTestEvent({ runId: 'a' }));
706
+ transport.emit(createTestEvent({ runId: 'b' }));
707
+ transport.emit(createTestEvent({ runId: 'c' }));
708
+ await new Promise((r) => setTimeout(r, 10));
709
+ expect(transport.getDroppedRequestCount()).toBe(1);
710
+
711
+ // Drain everything in flight.
712
+ while (pendingResolvers.length > 0) {
713
+ const next = pendingResolvers.shift()!;
714
+ next({ ok: true });
715
+ await new Promise((r) => setTimeout(r, 5));
716
+ }
717
+ // Allow any newly-promoted queued requests to start and resolve.
718
+ while (pendingResolvers.length > 0) {
719
+ const next = pendingResolvers.shift()!;
720
+ next({ ok: true });
721
+ await new Promise((r) => setTimeout(r, 5));
722
+ }
723
+
724
+ // Now a new emit should be accepted, not dropped.
725
+ transport.emit(createTestEvent({ runId: 'd' }));
726
+ await new Promise((r) => setTimeout(r, 10));
727
+ expect(transport.getDroppedRequestCount()).toBe(1); // unchanged
728
+ });
729
+
730
+ it('should treat maxQueueDepth: 0 as unbounded', async () => {
731
+ let resolveFetch: ((v: { ok: true }) => void) | undefined;
732
+ const blockingFetch = vi.fn().mockImplementation(
733
+ () => new Promise<{ ok: true }>((r) => { resolveFetch = r; })
734
+ );
735
+
736
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
737
+
738
+ transport = new WebhookEventTransport({
739
+ fetchFn: blockingFetch as unknown as typeof fetch,
740
+ defaultRetries: 0,
741
+ maxConcurrentRequests: 1,
742
+ maxQueueDepth: 0, // explicit opt-out
743
+ logger,
744
+ });
745
+
746
+ transport.addEndpoint({ id: 'no-cap', url: 'https://example.com/hook' });
747
+
748
+ for (let i = 0; i < 50; i++) {
749
+ transport.emit(createTestEvent({ runId: `run-${i}` }));
750
+ }
751
+ await new Promise((r) => setTimeout(r, 10));
752
+
753
+ expect(transport.getDroppedRequestCount()).toBe(0);
754
+ expect(logger.warn).not.toHaveBeenCalled();
755
+
756
+ resolveFetch?.({ ok: true });
757
+ await new Promise((r) => setTimeout(r, 20));
758
+ });
759
+
760
+ it('should reject negative maxQueueDepth at construction', () => {
761
+ expect(
762
+ () => new WebhookEventTransport({ maxQueueDepth: -1 })
763
+ ).toThrow(/maxQueueDepth/);
764
+ });
765
+ });
766
+
498
767
  describe('emit subscriber error isolation', () => {
499
768
  it('should catch subscriber error and still call other subscribers', () => {
500
769
  const errorCallback = vi.fn().mockImplementation(() => {
@@ -653,5 +922,28 @@ describe('WebhookEventTransport', () => {
653
922
 
654
923
  expect(mockFetch).toHaveBeenCalledTimes(1);
655
924
  });
925
+
926
+ it('resolves the hostname exactly once per webhook send (no rebinding TOCTOU window)', async () => {
927
+ // Audit M1: the previous impl validated DNS once and then handed the
928
+ // hostname to fetch, which re-resolved at connect time. Two lookups in
929
+ // the same delivery is the symptom we're guarding against — assert the
930
+ // counter does not advance past one when both validation and connect
931
+ // run in the same delivery.
932
+ const dnsLookup = dns.promises.lookup as ReturnType<typeof vi.fn>;
933
+ dnsLookup.mockClear();
934
+ dnsLookup.mockResolvedValue({ address: '93.184.216.34', family: 4 });
935
+
936
+ transport.addEndpoint({
937
+ id: 'one-lookup',
938
+ url: 'https://example.com/hook',
939
+ });
940
+
941
+ transport.emit(createTestEvent());
942
+
943
+ await new Promise((r) => setTimeout(r, 50));
944
+
945
+ expect(mockFetch).toHaveBeenCalledTimes(1);
946
+ expect(dnsLookup).toHaveBeenCalledTimes(1);
947
+ });
656
948
  });
657
949
  });