mppx 0.3.14 → 0.3.16

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 (60) hide show
  1. package/README.md +1 -0
  2. package/dist/Challenge.d.ts +38 -0
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +62 -0
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/bin.d.ts +3 -0
  7. package/dist/bin.d.ts.map +1 -0
  8. package/dist/bin.js +4 -0
  9. package/dist/bin.js.map +1 -0
  10. package/dist/cli.d.ts +26 -2
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +1478 -915
  13. package/dist/cli.js.map +1 -1
  14. package/dist/client/Mppx.d.ts +2 -0
  15. package/dist/client/Mppx.d.ts.map +1 -1
  16. package/dist/client/Mppx.js +2 -0
  17. package/dist/client/Mppx.js.map +1 -1
  18. package/dist/client/internal/Fetch.d.ts.map +1 -1
  19. package/dist/client/internal/Fetch.js +16 -4
  20. package/dist/client/internal/Fetch.js.map +1 -1
  21. package/dist/middlewares/internal/mppx.d.ts +6 -1
  22. package/dist/middlewares/internal/mppx.d.ts.map +1 -1
  23. package/dist/middlewares/internal/mppx.js +4 -0
  24. package/dist/middlewares/internal/mppx.js.map +1 -1
  25. package/dist/server/Mppx.d.ts +79 -1
  26. package/dist/server/Mppx.d.ts.map +1 -1
  27. package/dist/server/Mppx.js +135 -7
  28. package/dist/server/Mppx.js.map +1 -1
  29. package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
  30. package/dist/tempo/client/ChannelOps.js +1 -0
  31. package/dist/tempo/client/ChannelOps.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts.map +1 -1
  33. package/dist/tempo/server/Charge.js +4 -4
  34. package/dist/tempo/server/Charge.js.map +1 -1
  35. package/dist/tempo/session/Chain.d.ts.map +1 -1
  36. package/dist/tempo/session/Chain.js +9 -6
  37. package/dist/tempo/session/Chain.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/Challenge.ts +72 -0
  40. package/src/bin.ts +4 -0
  41. package/src/cli.test.ts +180 -252
  42. package/src/cli.ts +1085 -485
  43. package/src/client/Mppx.test-d.ts +9 -0
  44. package/src/client/Mppx.test.ts +78 -0
  45. package/src/client/Mppx.ts +5 -0
  46. package/src/client/internal/Fetch.test.ts +1 -1
  47. package/src/client/internal/Fetch.ts +18 -6
  48. package/src/middlewares/internal/mppx.test.ts +152 -0
  49. package/src/middlewares/internal/mppx.ts +22 -3
  50. package/src/server/Mppx.test-d.ts +94 -299
  51. package/src/server/Mppx.test.ts +650 -0
  52. package/src/server/Mppx.ts +213 -9
  53. package/src/tempo/client/ChannelOps.ts +1 -0
  54. package/src/tempo/server/Charge.ts +4 -3
  55. package/src/tempo/session/Chain.ts +8 -5
  56. package/dist/tempo/internal/simulate.d.ts +0 -21
  57. package/dist/tempo/internal/simulate.d.ts.map +0 -1
  58. package/dist/tempo/internal/simulate.js +0 -31
  59. package/dist/tempo/internal/simulate.js.map +0 -1
  60. package/src/tempo/internal/simulate.ts +0 -49
@@ -436,6 +436,656 @@ describe('receipt handling', () => {
436
436
  })
437
437
  })
438
438
 
439
+ describe('compose', () => {
440
+ const mockChargeA = Method.from({
441
+ name: 'alpha',
442
+ intent: 'charge',
443
+ schema: {
444
+ credential: {
445
+ payload: z.object({ token: z.string() }),
446
+ },
447
+ request: z.object({
448
+ amount: z.string(),
449
+ currency: z.string(),
450
+ decimals: z.number(),
451
+ recipient: z.string(),
452
+ }),
453
+ },
454
+ })
455
+
456
+ const mockChargeB = Method.from({
457
+ name: 'beta',
458
+ intent: 'charge',
459
+ schema: {
460
+ credential: {
461
+ payload: z.object({ token: z.string() }),
462
+ },
463
+ request: z.object({
464
+ amount: z.string(),
465
+ currency: z.string(),
466
+ decimals: z.number(),
467
+ recipient: z.string(),
468
+ }),
469
+ },
470
+ })
471
+
472
+ function mockReceipt(name: string) {
473
+ return {
474
+ method: name,
475
+ reference: `tx-${name}`,
476
+ status: 'success' as const,
477
+ timestamp: new Date().toISOString(),
478
+ }
479
+ }
480
+
481
+ const alphaMethod = Method.toServer(mockChargeA, {
482
+ async verify() {
483
+ return mockReceipt('alpha')
484
+ },
485
+ })
486
+
487
+ const betaMethod = Method.toServer(mockChargeB, {
488
+ async verify() {
489
+ return mockReceipt('beta')
490
+ },
491
+ })
492
+
493
+ const challengeOpts = {
494
+ amount: '1000',
495
+ currency: '0x0000000000000000000000000000000000000001',
496
+ decimals: 6,
497
+ expires: new Date(Date.now() + 60_000).toISOString(),
498
+ recipient: '0x0000000000000000000000000000000000000002',
499
+ }
500
+
501
+ test('returns 402 with multiple WWW-Authenticate headers when no credential', async () => {
502
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
503
+
504
+ const result = await mppx.compose(
505
+ [alphaMethod, challengeOpts],
506
+ [betaMethod, challengeOpts],
507
+ )(new Request('https://example.com/resource'))
508
+
509
+ expect(result.status).toBe(402)
510
+ if (result.status !== 402) throw new Error()
511
+
512
+ const wwwAuth = result.challenge.headers.get('WWW-Authenticate')!
513
+ expect(wwwAuth).toContain('method="alpha"')
514
+ expect(wwwAuth).toContain('method="beta"')
515
+ })
516
+
517
+ test('dispatches to matching handler when credential matches alpha', async () => {
518
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
519
+
520
+ const handle = mppx.compose([alphaMethod, challengeOpts], [betaMethod, challengeOpts])
521
+
522
+ // Get challenges
523
+ const firstResult = await handle(new Request('https://example.com/resource'))
524
+ expect(firstResult.status).toBe(402)
525
+ if (firstResult.status !== 402) throw new Error()
526
+
527
+ // Parse the alpha challenge from the merged response
528
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
529
+ const alphaChallenge = challenges[0]!
530
+
531
+ const credential = Credential.from({
532
+ challenge: alphaChallenge,
533
+ payload: { token: 'valid' },
534
+ })
535
+
536
+ const result = await handle(
537
+ new Request('https://example.com/resource', {
538
+ headers: { Authorization: Credential.serialize(credential) },
539
+ }),
540
+ )
541
+
542
+ expect(result.status).toBe(200)
543
+ })
544
+
545
+ test('dispatches to matching handler when credential matches beta', async () => {
546
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
547
+
548
+ const handle = mppx.compose([alphaMethod, challengeOpts], [betaMethod, challengeOpts])
549
+
550
+ // Get challenges
551
+ const firstResult = await handle(new Request('https://example.com/resource'))
552
+ expect(firstResult.status).toBe(402)
553
+ if (firstResult.status !== 402) throw new Error()
554
+
555
+ // Parse the beta challenge from the merged response
556
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
557
+ const betaChallenge = challenges[1]!
558
+
559
+ const credential = Credential.from({
560
+ challenge: betaChallenge,
561
+ payload: { token: 'valid' },
562
+ })
563
+
564
+ const result = await handle(
565
+ new Request('https://example.com/resource', {
566
+ headers: { Authorization: Credential.serialize(credential) },
567
+ }),
568
+ )
569
+
570
+ expect(result.status).toBe(200)
571
+ })
572
+
573
+ test('returns 402 when credential method does not match any handler', async () => {
574
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
575
+
576
+ const handle = mppx.compose([alphaMethod, challengeOpts])
577
+
578
+ const wrongChallenge = Challenge.from({
579
+ id: 'wrong-id',
580
+ intent: 'charge',
581
+ method: 'unknown',
582
+ realm,
583
+ request: { amount: '1000', currency: '0x01', recipient: '0x02' },
584
+ })
585
+ const credential = Credential.from({
586
+ challenge: wrongChallenge,
587
+ payload: { token: 'test' },
588
+ })
589
+
590
+ const result = await handle(
591
+ new Request('https://example.com/resource', {
592
+ headers: { Authorization: Credential.serialize(credential) },
593
+ }),
594
+ )
595
+
596
+ expect(result.status).toBe(402)
597
+ })
598
+
599
+ test('cross-route protection works through compose()', async () => {
600
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
601
+
602
+ // Get a challenge from a cheap route
603
+ const cheapHandle = mppx.compose([alphaMethod, { ...challengeOpts, amount: '1' }])
604
+ const cheapResult = await cheapHandle(new Request('https://example.com/cheap'))
605
+ expect(cheapResult.status).toBe(402)
606
+ if (cheapResult.status !== 402) throw new Error()
607
+
608
+ const cheapChallenge = Challenge.fromResponse(cheapResult.challenge)
609
+ const credential = Credential.from({
610
+ challenge: cheapChallenge,
611
+ payload: { token: 'valid' },
612
+ })
613
+
614
+ // Present it at an expensive route
615
+ const expensiveHandle = mppx.compose([alphaMethod, { ...challengeOpts, amount: '1000000' }])
616
+ const result = await expensiveHandle(
617
+ new Request('https://example.com/expensive', {
618
+ headers: { Authorization: Credential.serialize(credential) },
619
+ }),
620
+ )
621
+
622
+ expect(result.status).toBe(402)
623
+ if (result.status !== 402) throw new Error()
624
+ const body = (await result.challenge.json()) as { detail: string }
625
+ expect(body.detail).toContain('does not match')
626
+ })
627
+
628
+ test('withReceipt works through compose()', async () => {
629
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
630
+
631
+ const handle = mppx.compose([alphaMethod, challengeOpts])
632
+
633
+ const firstResult = await handle(new Request('https://example.com/resource'))
634
+ expect(firstResult.status).toBe(402)
635
+ if (firstResult.status !== 402) throw new Error()
636
+
637
+ const challenge = Challenge.fromResponse(firstResult.challenge)
638
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
639
+
640
+ const result = await handle(
641
+ new Request('https://example.com/resource', {
642
+ headers: { Authorization: Credential.serialize(credential) },
643
+ }),
644
+ )
645
+ expect(result.status).toBe(200)
646
+ if (result.status !== 200) throw new Error()
647
+
648
+ const response = result.withReceipt(Response.json({ data: 'ok' }))
649
+ expect(response.headers.get('Payment-Receipt')).toBeTruthy()
650
+ })
651
+
652
+ test('throws when called with no entries', () => {
653
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
654
+ expect(() => mppx.compose()).toThrow('compose() requires at least one entry')
655
+ })
656
+
657
+ test('throws when method is not in the methods array', () => {
658
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
659
+ expect(() => mppx.compose([betaMethod, challengeOpts] as never)).toThrow(
660
+ 'No handler for "beta/charge"',
661
+ )
662
+ })
663
+
664
+ test('accepts string keys instead of method references', async () => {
665
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
666
+
667
+ const handle = mppx.compose(['alpha/charge', challengeOpts], ['beta/charge', challengeOpts])
668
+
669
+ const firstResult = await handle(new Request('https://example.com/resource'))
670
+ expect(firstResult.status).toBe(402)
671
+ if (firstResult.status !== 402) throw new Error()
672
+
673
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
674
+ expect(challenges).toHaveLength(2)
675
+ expect(challenges[0]!.method).toBe('alpha')
676
+ expect(challenges[1]!.method).toBe('beta')
677
+
678
+ // Dispatch with a credential for alpha
679
+ const credential = Credential.from({
680
+ challenge: challenges[0]!,
681
+ payload: { token: 'valid' },
682
+ })
683
+
684
+ const result = await handle(
685
+ new Request('https://example.com/resource', {
686
+ headers: { Authorization: Credential.serialize(credential) },
687
+ }),
688
+ )
689
+ expect(result.status).toBe(200)
690
+ })
691
+
692
+ test('throws when string key does not match any registered method', () => {
693
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
694
+ expect(() => mppx.compose(['unknown/charge' as never, challengeOpts])).toThrow(
695
+ 'No handler for "unknown/charge"',
696
+ )
697
+ })
698
+
699
+ test('mixes string keys and method references', async () => {
700
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
701
+
702
+ const handle = mppx.compose(['alpha/charge', challengeOpts], [betaMethod, challengeOpts])
703
+
704
+ const firstResult = await handle(new Request('https://example.com/resource'))
705
+ expect(firstResult.status).toBe(402)
706
+ if (firstResult.status !== 402) throw new Error()
707
+
708
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
709
+ expect(challenges).toHaveLength(2)
710
+ })
711
+
712
+ test('dispatches correctly with same name/intent but different currencies', async () => {
713
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
714
+
715
+ const currencyA = '0x0000000000000000000000000000000000000001'
716
+ const currencyB = '0x0000000000000000000000000000000000000099'
717
+
718
+ const handle = mppx.compose(
719
+ [alphaMethod, { ...challengeOpts, currency: currencyA }],
720
+ [alphaMethod, { ...challengeOpts, currency: currencyB }],
721
+ )
722
+
723
+ // Get merged 402 with both challenges
724
+ const firstResult = await handle(new Request('https://example.com/resource'))
725
+ expect(firstResult.status).toBe(402)
726
+ if (firstResult.status !== 402) throw new Error()
727
+
728
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
729
+ expect(challenges).toHaveLength(2)
730
+
731
+ // Present credential for the SECOND currency — should dispatch correctly
732
+ const secondChallenge = challenges[1]!
733
+ expect((secondChallenge.request as Record<string, unknown>).currency).toBe(currencyB)
734
+
735
+ const credential = Credential.from({
736
+ challenge: secondChallenge,
737
+ payload: { token: 'valid' },
738
+ })
739
+
740
+ const result = await handle(
741
+ new Request('https://example.com/resource', {
742
+ headers: { Authorization: Credential.serialize(credential) },
743
+ }),
744
+ )
745
+
746
+ expect(result.status).toBe(200)
747
+ })
748
+
749
+ test('dispatches correctly with same name/intent but different recipients', async () => {
750
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
751
+
752
+ const recipientA = '0x0000000000000000000000000000000000000002'
753
+ const recipientB = '0x0000000000000000000000000000000000000088'
754
+
755
+ const handle = mppx.compose(
756
+ [alphaMethod, { ...challengeOpts, recipient: recipientA }],
757
+ [alphaMethod, { ...challengeOpts, recipient: recipientB }],
758
+ )
759
+
760
+ const firstResult = await handle(new Request('https://example.com/resource'))
761
+ expect(firstResult.status).toBe(402)
762
+ if (firstResult.status !== 402) throw new Error()
763
+
764
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
765
+ expect(challenges).toHaveLength(2)
766
+
767
+ // Present credential for the SECOND recipient
768
+ const secondChallenge = challenges[1]!
769
+ const credential = Credential.from({
770
+ challenge: secondChallenge,
771
+ payload: { token: 'valid' },
772
+ })
773
+
774
+ const result = await handle(
775
+ new Request('https://example.com/resource', {
776
+ headers: { Authorization: Credential.serialize(credential) },
777
+ }),
778
+ )
779
+
780
+ expect(result.status).toBe(200)
781
+ })
782
+ })
783
+
784
+ describe('compose: pre-dispatch narrowing edge cases', () => {
785
+ const mockCharge = Method.from({
786
+ name: 'alpha',
787
+ intent: 'charge',
788
+ schema: {
789
+ credential: {
790
+ payload: z.object({ token: z.string() }),
791
+ },
792
+ request: z.object({
793
+ amount: z.string(),
794
+ currency: z.string(),
795
+ decimals: z.number(),
796
+ recipient: z.string(),
797
+ }),
798
+ },
799
+ })
800
+
801
+ function mockReceipt() {
802
+ return {
803
+ method: 'alpha',
804
+ reference: 'tx-alpha',
805
+ status: 'success' as const,
806
+ timestamp: new Date().toISOString(),
807
+ }
808
+ }
809
+
810
+ const alphaMethod = Method.toServer(mockCharge, {
811
+ async verify() {
812
+ return mockReceipt()
813
+ },
814
+ })
815
+
816
+ const challengeOpts = {
817
+ amount: '1000',
818
+ currency: '0x0000000000000000000000000000000000000001',
819
+ decimals: 6,
820
+ expires: new Date(Date.now() + 60_000).toISOString(),
821
+ recipient: '0x0000000000000000000000000000000000000002',
822
+ }
823
+
824
+ test('dispatches correctly when handlers differ only in amount', async () => {
825
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
826
+
827
+ const handle = mppx.compose(
828
+ [alphaMethod, { ...challengeOpts, amount: '100' }],
829
+ [alphaMethod, { ...challengeOpts, amount: '999' }],
830
+ )
831
+
832
+ // Get 402 with both challenges
833
+ const firstResult = await handle(new Request('https://example.com/resource'))
834
+ expect(firstResult.status).toBe(402)
835
+ if (firstResult.status !== 402) throw new Error()
836
+
837
+ // Present credential for second challenge (amount=999)
838
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
839
+ expect(challenges).toHaveLength(2)
840
+
841
+ const secondChallenge = challenges[1]!
842
+ const credential = Credential.from({
843
+ challenge: secondChallenge,
844
+ payload: { token: 'valid' },
845
+ })
846
+
847
+ const result = await handle(
848
+ new Request('https://example.com/resource', {
849
+ headers: { Authorization: Credential.serialize(credential) },
850
+ }),
851
+ )
852
+
853
+ // Amount is now included in narrowing, so the second handler is correctly selected.
854
+ expect(result.status).toBe(200)
855
+ })
856
+
857
+ test('first handler succeeds when handlers differ only in amount and credential matches first', async () => {
858
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
859
+
860
+ const handle = mppx.compose(
861
+ [alphaMethod, { ...challengeOpts, amount: '100' }],
862
+ [alphaMethod, { ...challengeOpts, amount: '999' }],
863
+ )
864
+
865
+ const firstResult = await handle(new Request('https://example.com/resource'))
866
+ expect(firstResult.status).toBe(402)
867
+ if (firstResult.status !== 402) throw new Error()
868
+
869
+ // Present credential for the FIRST challenge — narrowing picks first too
870
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
871
+ const firstChallenge = challenges[0]!
872
+ const credential = Credential.from({
873
+ challenge: firstChallenge,
874
+ payload: { token: 'valid' },
875
+ })
876
+
877
+ const result = await handle(
878
+ new Request('https://example.com/resource', {
879
+ headers: { Authorization: Credential.serialize(credential) },
880
+ }),
881
+ )
882
+
883
+ expect(result.status).toBe(200)
884
+ })
885
+
886
+ test('dispatches when credential method/intent does not match — falls back to first handler with 402', async () => {
887
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
888
+
889
+ const handle = mppx.compose([alphaMethod, challengeOpts])
890
+
891
+ // Forge a credential with a non-existent method
892
+ const wrongChallenge = Challenge.from({
893
+ id: 'forged',
894
+ intent: 'charge',
895
+ method: 'nonexistent',
896
+ realm,
897
+ request: { amount: '1' },
898
+ })
899
+ const credential = Credential.from({
900
+ challenge: wrongChallenge,
901
+ payload: { token: 'test' },
902
+ })
903
+
904
+ const result = await handle(
905
+ new Request('https://example.com/resource', {
906
+ headers: { Authorization: Credential.serialize(credential) },
907
+ }),
908
+ )
909
+
910
+ // Falls back to handlers[0] which rejects via HMAC
911
+ expect(result.status).toBe(402)
912
+ })
913
+
914
+ test('handles malformed Authorization header in compose() gracefully', async () => {
915
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
916
+ const handle = mppx.compose([alphaMethod, challengeOpts])
917
+
918
+ const result = await handle(
919
+ new Request('https://example.com/resource', {
920
+ headers: { Authorization: 'Payment invalid-base64-garbage' },
921
+ }),
922
+ )
923
+
924
+ // Credential parse fails silently, falls back to handlers[0]
925
+ expect(result.status).toBe(402)
926
+ })
927
+
928
+ test('single handler in compose() returns 402 and then 200', async () => {
929
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
930
+ const handle = mppx.compose([alphaMethod, challengeOpts])
931
+
932
+ const firstResult = await handle(new Request('https://example.com/resource'))
933
+ expect(firstResult.status).toBe(402)
934
+ if (firstResult.status !== 402) throw new Error()
935
+
936
+ const challenge = Challenge.fromResponse(firstResult.challenge)
937
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
938
+
939
+ const result = await handle(
940
+ new Request('https://example.com/resource', {
941
+ headers: { Authorization: Credential.serialize(credential) },
942
+ }),
943
+ )
944
+ expect(result.status).toBe(200)
945
+ })
946
+ })
947
+
948
+ describe('nested accessors', () => {
949
+ const mockChargeA = Method.from({
950
+ name: 'alpha',
951
+ intent: 'charge',
952
+ schema: {
953
+ credential: {
954
+ payload: z.object({ token: z.string() }),
955
+ },
956
+ request: z.object({
957
+ amount: z.string(),
958
+ currency: z.string(),
959
+ decimals: z.number(),
960
+ recipient: z.string(),
961
+ }),
962
+ },
963
+ })
964
+
965
+ const mockChargeB = Method.from({
966
+ name: 'beta',
967
+ intent: 'charge',
968
+ schema: {
969
+ credential: {
970
+ payload: z.object({ token: z.string() }),
971
+ },
972
+ request: z.object({
973
+ amount: z.string(),
974
+ currency: z.string(),
975
+ decimals: z.number(),
976
+ recipient: z.string(),
977
+ }),
978
+ },
979
+ })
980
+
981
+ function mockReceipt(name: string) {
982
+ return {
983
+ method: name,
984
+ reference: `tx-${name}`,
985
+ status: 'success' as const,
986
+ timestamp: new Date().toISOString(),
987
+ }
988
+ }
989
+
990
+ const alphaMethod = Method.toServer(mockChargeA, {
991
+ async verify() {
992
+ return mockReceipt('alpha')
993
+ },
994
+ })
995
+
996
+ const betaMethod = Method.toServer(mockChargeB, {
997
+ async verify() {
998
+ return mockReceipt('beta')
999
+ },
1000
+ })
1001
+
1002
+ const challengeOpts = {
1003
+ amount: '1000',
1004
+ currency: '0x0000000000000000000000000000000000000001',
1005
+ decimals: 6,
1006
+ expires: new Date(Date.now() + 60_000).toISOString(),
1007
+ recipient: '0x0000000000000000000000000000000000000002',
1008
+ }
1009
+
1010
+ test('mppx.alpha.charge returns a working handler (402 then 200)', async () => {
1011
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1012
+
1013
+ const handle = mppx.alpha.charge(challengeOpts)
1014
+
1015
+ const firstResult = await handle(new Request('https://example.com/resource'))
1016
+ expect(firstResult.status).toBe(402)
1017
+ if (firstResult.status !== 402) throw new Error()
1018
+
1019
+ const challenge = Challenge.fromResponse(firstResult.challenge)
1020
+ expect(challenge.method).toBe('alpha')
1021
+ expect(challenge.intent).toBe('charge')
1022
+
1023
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1024
+
1025
+ const result = await handle(
1026
+ new Request('https://example.com/resource', {
1027
+ headers: { Authorization: Credential.serialize(credential) },
1028
+ }),
1029
+ )
1030
+ expect(result.status).toBe(200)
1031
+ })
1032
+
1033
+ test('mppx.beta.charge returns a working handler', async () => {
1034
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1035
+
1036
+ const handle = mppx.beta.charge(challengeOpts)
1037
+
1038
+ const firstResult = await handle(new Request('https://example.com/resource'))
1039
+ expect(firstResult.status).toBe(402)
1040
+ if (firstResult.status !== 402) throw new Error()
1041
+
1042
+ const challenge = Challenge.fromResponse(firstResult.challenge)
1043
+ expect(challenge.method).toBe('beta')
1044
+
1045
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1046
+
1047
+ const result = await handle(
1048
+ new Request('https://example.com/resource', {
1049
+ headers: { Authorization: Credential.serialize(credential) },
1050
+ }),
1051
+ )
1052
+ expect(result.status).toBe(200)
1053
+ })
1054
+
1055
+ test('nested accessor is the same handler as the slash key', () => {
1056
+ const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
1057
+ expect(mppx.alpha.charge).toBe(mppx['alpha/charge'])
1058
+ })
1059
+
1060
+ test('nested accessors work with Mppx.compose() static function', async () => {
1061
+ const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey })
1062
+
1063
+ const handle = Mppx.compose(mppx.alpha.charge(challengeOpts), mppx.beta.charge(challengeOpts))
1064
+
1065
+ const firstResult = await handle(new Request('https://example.com/resource'))
1066
+ expect(firstResult.status).toBe(402)
1067
+ if (firstResult.status !== 402) throw new Error()
1068
+
1069
+ const challenges = Challenge.fromResponseList(firstResult.challenge)
1070
+ expect(challenges).toHaveLength(2)
1071
+ expect(challenges[0]!.method).toBe('alpha')
1072
+ expect(challenges[1]!.method).toBe('beta')
1073
+
1074
+ // Dispatch with beta credential
1075
+ const credential = Credential.from({
1076
+ challenge: challenges[1]!,
1077
+ payload: { token: 'valid' },
1078
+ })
1079
+
1080
+ const result = await handle(
1081
+ new Request('https://example.com/resource', {
1082
+ headers: { Authorization: Credential.serialize(credential) },
1083
+ }),
1084
+ )
1085
+ expect(result.status).toBe(200)
1086
+ })
1087
+ })
1088
+
439
1089
  describe('withReceipt', () => {
440
1090
  const mockCharge = Method.from({
441
1091
  name: 'mock',