mppx 0.6.20 → 0.6.21

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 (34) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/Mppx.d.ts +12 -1
  3. package/dist/client/Mppx.d.ts.map +1 -1
  4. package/dist/client/Mppx.js +127 -10
  5. package/dist/client/Mppx.js.map +1 -1
  6. package/dist/client/internal/Fetch.d.ts +69 -1
  7. package/dist/client/internal/Fetch.d.ts.map +1 -1
  8. package/dist/client/internal/Fetch.js +250 -20
  9. package/dist/client/internal/Fetch.js.map +1 -1
  10. package/dist/middlewares/internal/mppx.d.ts +1 -1
  11. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  12. package/dist/middlewares/internal/mppx.js +2 -1
  13. package/dist/middlewares/internal/mppx.js.map +1 -1
  14. package/dist/server/Mppx.d.ts +82 -3
  15. package/dist/server/Mppx.d.ts.map +1 -1
  16. package/dist/server/Mppx.js +557 -83
  17. package/dist/server/Mppx.js.map +1 -1
  18. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  19. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  20. package/dist/tempo/server/internal/html.gen.js +1 -1
  21. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  22. package/package.json +1 -1
  23. package/src/client/Mppx.test-d.ts +55 -0
  24. package/src/client/Mppx.test.ts +181 -0
  25. package/src/client/Mppx.ts +248 -16
  26. package/src/client/internal/Fetch.test-d.ts +31 -0
  27. package/src/client/internal/Fetch.test.ts +261 -0
  28. package/src/client/internal/Fetch.ts +467 -24
  29. package/src/middlewares/internal/mppx.ts +5 -6
  30. package/src/proxy/Proxy.test.ts +69 -0
  31. package/src/server/Mppx.test-d.ts +50 -0
  32. package/src/server/Mppx.test.ts +893 -1
  33. package/src/server/Mppx.ts +862 -97
  34. package/src/tempo/server/internal/html.gen.ts +1 -1
@@ -669,6 +669,267 @@ describe('Fetch.from: 402 retry path', () => {
669
669
  expect(headers.Authorization).toBe('credential')
670
670
  })
671
671
 
672
+ test('emits client events and allows challenge handler to provide credential', async () => {
673
+ const events: string[] = []
674
+ const createCredential = vi.fn(async () => 'method-credential')
675
+ let callCount = 0
676
+ const calls: { init: RequestInit | undefined }[] = []
677
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
678
+ calls.push({ init })
679
+ callCount++
680
+ if (callCount === 1) return make402()
681
+ return new Response('OK', { status: 200 })
682
+ }
683
+
684
+ const method = { ...noopMethod, createCredential }
685
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
686
+ eventDispatcher.on('*', (event) => {
687
+ events.push(`*:${event.name}`)
688
+ })
689
+ eventDispatcher.on('challenge.received', async (payload) => {
690
+ events.push(`challenge:${payload.challenge.id}`)
691
+ return 'event-credential'
692
+ })
693
+ eventDispatcher.on('credential.created', (payload) => {
694
+ events.push(`credential:${payload.credential}`)
695
+ throw new Error('observer failed')
696
+ })
697
+ eventDispatcher.on('payment.response', (payload) => {
698
+ events.push(`response:${payload.response.status}`)
699
+ throw new Error('observer failed')
700
+ })
701
+
702
+ const fetch = Fetch.from({
703
+ eventDispatcher,
704
+ fetch: mockFetch,
705
+ methods: [method],
706
+ })
707
+
708
+ const response = await fetch('https://example.com/api')
709
+
710
+ expect(response.status).toBe(200)
711
+ expect(createCredential).not.toHaveBeenCalled()
712
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
713
+ expect(retryHeaders.get('Authorization')).toBe('event-credential')
714
+ expect(events).toEqual([
715
+ 'challenge:abc',
716
+ '*:challenge.received',
717
+ 'credential:event-credential',
718
+ '*:credential.created',
719
+ 'response:200',
720
+ '*:payment.response',
721
+ ])
722
+ })
723
+
724
+ test('uses the first challenge event credential', async () => {
725
+ const events: string[] = []
726
+ const createCredential = vi.fn(async () => 'method-credential')
727
+ const method = { ...noopMethod, createCredential }
728
+ let callCount = 0
729
+ const calls: { init: RequestInit | undefined }[] = []
730
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
731
+ calls.push({ init })
732
+ callCount++
733
+ if (callCount === 1) return make402()
734
+ return new Response('OK', { status: 200 })
735
+ }
736
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
737
+ eventDispatcher.on('challenge.received', () => {
738
+ events.push('first')
739
+ return 'first-credential'
740
+ })
741
+ eventDispatcher.on('challenge.received', () => {
742
+ events.push('second')
743
+ return 'second-credential'
744
+ })
745
+
746
+ const fetch = Fetch.from({
747
+ eventDispatcher,
748
+ fetch: mockFetch,
749
+ methods: [method],
750
+ })
751
+
752
+ await fetch('https://example.com/api')
753
+
754
+ expect(createCredential).not.toHaveBeenCalled()
755
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
756
+ expect(retryHeaders.get('Authorization')).toBe('first-credential')
757
+ expect(events).toEqual(['first'])
758
+ })
759
+
760
+ test('does not emit payment.response for non-ok retry responses', async () => {
761
+ const events: string[] = []
762
+ const createCredential = vi.fn(async () => 'method-credential')
763
+ const method = { ...noopMethod, createCredential }
764
+ let callCount = 0
765
+ const mockFetch: typeof globalThis.fetch = async () => {
766
+ callCount++
767
+ if (callCount === 1) return make402()
768
+ return new Response('Internal Server Error', { status: 500 })
769
+ }
770
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
771
+ eventDispatcher.on('payment.response', (payload) => {
772
+ events.push(`response:${payload.response.status}`)
773
+ })
774
+
775
+ const fetch = Fetch.from({
776
+ eventDispatcher,
777
+ fetch: mockFetch,
778
+ methods: [method],
779
+ })
780
+
781
+ const response = await fetch('https://example.com/api')
782
+
783
+ expect(response.status).toBe(500)
784
+ expect(events).toEqual([])
785
+ })
786
+
787
+ test('ignores empty challenge event credentials and continues handlers', async () => {
788
+ const createCredential = vi.fn(async () => 'method-credential')
789
+ const method = { ...noopMethod, createCredential }
790
+ let callCount = 0
791
+ const calls: { init: RequestInit | undefined }[] = []
792
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
793
+ calls.push({ init })
794
+ callCount++
795
+ if (callCount === 1) return make402()
796
+ return new Response('OK', { status: 200 })
797
+ }
798
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
799
+ eventDispatcher.on('challenge.received', () => '')
800
+ eventDispatcher.on('challenge.received', () => 'second-credential')
801
+
802
+ const fetch = Fetch.from({
803
+ eventDispatcher,
804
+ fetch: mockFetch,
805
+ methods: [method],
806
+ })
807
+
808
+ await fetch('https://example.com/api')
809
+
810
+ expect(createCredential).not.toHaveBeenCalled()
811
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
812
+ expect(retryHeaders.get('Authorization')).toBe('second-credential')
813
+ })
814
+
815
+ test('memoizes createCredential across wildcard observers and fallback', async () => {
816
+ const createCredential = vi.fn(async () => 'method-credential')
817
+ const method = { ...noopMethod, createCredential }
818
+ let callCount = 0
819
+ const mockFetch: typeof globalThis.fetch = async () => {
820
+ callCount++
821
+ if (callCount === 1) return make402()
822
+ return new Response('OK', { status: 200 })
823
+ }
824
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
825
+ eventDispatcher.on('*', async (event) => {
826
+ if (event.name === 'challenge.received') await event.payload.createCredential()
827
+ })
828
+
829
+ const fetch = Fetch.from({
830
+ eventDispatcher,
831
+ fetch: mockFetch,
832
+ methods: [method],
833
+ })
834
+
835
+ await fetch('https://example.com/api')
836
+
837
+ expect(createCredential).toHaveBeenCalledTimes(1)
838
+ })
839
+
840
+ test('does not expose live challenges or init headers to observers', async () => {
841
+ const createCredential = vi.fn(async ({ challenge }) => `amount:${challenge.request.amount}`)
842
+ const method = { ...noopMethod, createCredential }
843
+ let callCount = 0
844
+ const calls: { init: RequestInit | undefined }[] = []
845
+ const mockFetch: typeof globalThis.fetch = async (_input, init) => {
846
+ calls.push({ init })
847
+ callCount++
848
+ if (callCount === 1) return make402()
849
+ return new Response('OK', { status: 200 })
850
+ }
851
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
852
+ eventDispatcher.on('*', (event) => {
853
+ if (event.name !== 'challenge.received') return
854
+ try {
855
+ ;(event.payload.challenge.request as { amount: string }).amount = '999'
856
+ } catch {}
857
+ const headers = new Headers(event.payload.init?.headers)
858
+ headers.set('Authorization', 'attacker')
859
+ if (event.payload.init) event.payload.init.headers = headers
860
+ })
861
+
862
+ const fetch = Fetch.from({
863
+ eventDispatcher,
864
+ fetch: mockFetch,
865
+ methods: [method],
866
+ })
867
+
868
+ await fetch('https://example.com/api', {
869
+ headers: { 'X-Test': '1' },
870
+ })
871
+
872
+ const retryHeaders = new Headers((calls[1]!.init as RequestInit).headers)
873
+ expect(retryHeaders.get('Authorization')).toBe('amount:1')
874
+ })
875
+
876
+ test('continues dispatching observer listeners after one throws', async () => {
877
+ const events: string[] = []
878
+ const method = { ...noopMethod, createCredential: vi.fn(async () => 'credential') }
879
+ let callCount = 0
880
+ const mockFetch: typeof globalThis.fetch = async () => {
881
+ callCount++
882
+ if (callCount === 1) return make402()
883
+ return new Response('OK', { status: 200 })
884
+ }
885
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
886
+ eventDispatcher.on('credential.created', () => {
887
+ events.push('first')
888
+ throw new Error('observer failed')
889
+ })
890
+ eventDispatcher.on('credential.created', () => {
891
+ events.push('second')
892
+ })
893
+
894
+ const fetch = Fetch.from({
895
+ eventDispatcher,
896
+ fetch: mockFetch,
897
+ methods: [method],
898
+ })
899
+
900
+ await fetch('https://example.com/api')
901
+
902
+ expect(events).toEqual(['first', 'second'])
903
+ })
904
+
905
+ test('emits payment.failed when automatic payment handling rejects', async () => {
906
+ const events: string[] = []
907
+ const createCredential = vi.fn(async () => 'credential')
908
+ const mockFetch = vi.fn(async () =>
909
+ make402({ expires: new Date(Date.now() - 60_000).toISOString() }),
910
+ )
911
+ const method = { ...noopMethod, createCredential }
912
+ const eventDispatcher = Fetch.createEventDispatcher<[typeof method]>()
913
+ eventDispatcher.on('*', (event) => {
914
+ events.push(`*:${event.name}`)
915
+ })
916
+ eventDispatcher.on('payment.failed', (payload) => {
917
+ events.push(
918
+ `failed:${payload.error instanceof Errors.PaymentExpiredError}:${payload.challenge?.id}`,
919
+ )
920
+ throw new Error('observer failed')
921
+ })
922
+ const fetch = Fetch.from({
923
+ eventDispatcher,
924
+ fetch: mockFetch as typeof globalThis.fetch,
925
+ methods: [method],
926
+ })
927
+
928
+ await expect(fetch('https://example.com/api')).rejects.toThrow(Errors.PaymentExpiredError)
929
+ expect(createCredential).not.toHaveBeenCalled()
930
+ expect(events).toEqual(['failed:true:abc', '*:payment.failed'])
931
+ })
932
+
672
933
  test('preserves existing headers on retry', async () => {
673
934
  let callCount = 0
674
935
  const calls: { init: RequestInit | undefined }[] = []