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.
- package/README.md +1 -0
- package/dist/Challenge.d.ts +38 -0
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +62 -0
- package/dist/Challenge.js.map +1 -1
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/cli.d.ts +26 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1478 -915
- package/dist/cli.js.map +1 -1
- package/dist/client/Mppx.d.ts +2 -0
- package/dist/client/Mppx.d.ts.map +1 -1
- package/dist/client/Mppx.js +2 -0
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +16 -4
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/middlewares/internal/mppx.d.ts +6 -1
- package/dist/middlewares/internal/mppx.d.ts.map +1 -1
- package/dist/middlewares/internal/mppx.js +4 -0
- package/dist/middlewares/internal/mppx.js.map +1 -1
- package/dist/server/Mppx.d.ts +79 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +135 -7
- package/dist/server/Mppx.js.map +1 -1
- package/dist/tempo/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/client/ChannelOps.js +1 -0
- package/dist/tempo/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +4 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +9 -6
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +4 -4
- package/src/Challenge.ts +72 -0
- package/src/bin.ts +4 -0
- package/src/cli.test.ts +180 -252
- package/src/cli.ts +1085 -485
- package/src/client/Mppx.test-d.ts +9 -0
- package/src/client/Mppx.test.ts +78 -0
- package/src/client/Mppx.ts +5 -0
- package/src/client/internal/Fetch.test.ts +1 -1
- package/src/client/internal/Fetch.ts +18 -6
- package/src/middlewares/internal/mppx.test.ts +152 -0
- package/src/middlewares/internal/mppx.ts +22 -3
- package/src/server/Mppx.test-d.ts +94 -299
- package/src/server/Mppx.test.ts +650 -0
- package/src/server/Mppx.ts +213 -9
- package/src/tempo/client/ChannelOps.ts +1 -0
- package/src/tempo/server/Charge.ts +4 -3
- package/src/tempo/session/Chain.ts +8 -5
- package/dist/tempo/internal/simulate.d.ts +0 -21
- package/dist/tempo/internal/simulate.d.ts.map +0 -1
- package/dist/tempo/internal/simulate.js +0 -31
- package/dist/tempo/internal/simulate.js.map +0 -1
- package/src/tempo/internal/simulate.ts +0 -49
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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',
|