mppx 0.4.10 → 0.4.12

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 (74) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/dist/internal/env.d.ts +1 -1
  3. package/dist/internal/env.d.ts.map +1 -1
  4. package/dist/internal/env.js +2 -6
  5. package/dist/internal/env.js.map +1 -1
  6. package/dist/internal/types.d.ts +23 -0
  7. package/dist/internal/types.d.ts.map +1 -1
  8. package/dist/server/Mppx.d.ts +1 -1
  9. package/dist/server/Mppx.d.ts.map +1 -1
  10. package/dist/server/Mppx.js +49 -2
  11. package/dist/server/Mppx.js.map +1 -1
  12. package/dist/stripe/internal/types.d.ts +3 -0
  13. package/dist/stripe/internal/types.d.ts.map +1 -1
  14. package/dist/stripe/server/Charge.d.ts.map +1 -1
  15. package/dist/stripe/server/Charge.js +9 -2
  16. package/dist/stripe/server/Charge.js.map +1 -1
  17. package/dist/tempo/Methods.d.ts +15 -0
  18. package/dist/tempo/Methods.d.ts.map +1 -1
  19. package/dist/tempo/Methods.js +27 -3
  20. package/dist/tempo/Methods.js.map +1 -1
  21. package/dist/tempo/client/Charge.d.ts +21 -0
  22. package/dist/tempo/client/Charge.d.ts.map +1 -1
  23. package/dist/tempo/client/Charge.js +33 -7
  24. package/dist/tempo/client/Charge.js.map +1 -1
  25. package/dist/tempo/client/Methods.d.ts +15 -0
  26. package/dist/tempo/client/Methods.d.ts.map +1 -1
  27. package/dist/tempo/internal/account.d.ts +5 -11
  28. package/dist/tempo/internal/account.d.ts.map +1 -1
  29. package/dist/tempo/internal/charge.d.ts +20 -0
  30. package/dist/tempo/internal/charge.d.ts.map +1 -0
  31. package/dist/tempo/internal/charge.js +23 -0
  32. package/dist/tempo/internal/charge.js.map +1 -0
  33. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-payer.js +15 -3
  35. package/dist/tempo/internal/fee-payer.js.map +1 -1
  36. package/dist/tempo/server/Charge.d.ts +17 -2
  37. package/dist/tempo/server/Charge.d.ts.map +1 -1
  38. package/dist/tempo/server/Charge.js +148 -99
  39. package/dist/tempo/server/Charge.js.map +1 -1
  40. package/dist/tempo/server/Methods.d.ts +17 -2
  41. package/dist/tempo/server/Methods.d.ts.map +1 -1
  42. package/dist/tempo/server/Methods.js +4 -1
  43. package/dist/tempo/server/Methods.js.map +1 -1
  44. package/dist/tempo/server/Session.d.ts +9 -4
  45. package/dist/tempo/server/Session.d.ts.map +1 -1
  46. package/dist/tempo/server/Session.js +25 -6
  47. package/dist/tempo/server/Session.js.map +1 -1
  48. package/dist/tempo/session/Chain.d.ts +18 -2
  49. package/dist/tempo/session/Chain.d.ts.map +1 -1
  50. package/dist/tempo/session/Chain.js +18 -14
  51. package/dist/tempo/session/Chain.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/internal/env.test.ts +12 -12
  54. package/src/internal/env.ts +2 -6
  55. package/src/internal/types.ts +25 -0
  56. package/src/server/Mppx.test.ts +287 -0
  57. package/src/server/Mppx.ts +59 -5
  58. package/src/stripe/internal/types.ts +5 -1
  59. package/src/stripe/server/Charge.test.ts +52 -1
  60. package/src/stripe/server/Charge.ts +12 -4
  61. package/src/tempo/Methods.test.ts +79 -0
  62. package/src/tempo/Methods.ts +53 -17
  63. package/src/tempo/client/Charge.ts +41 -8
  64. package/src/tempo/internal/account.ts +7 -14
  65. package/src/tempo/internal/charge.ts +43 -0
  66. package/src/tempo/internal/fee-payer.test.ts +33 -14
  67. package/src/tempo/internal/fee-payer.ts +21 -6
  68. package/src/tempo/server/Charge.test.ts +231 -0
  69. package/src/tempo/server/Charge.ts +193 -124
  70. package/src/tempo/server/Methods.ts +4 -1
  71. package/src/tempo/server/Session.test.ts +57 -0
  72. package/src/tempo/server/Session.ts +33 -20
  73. package/src/tempo/session/Chain.test.ts +25 -5
  74. package/src/tempo/session/Chain.ts +30 -14
@@ -1567,6 +1567,77 @@ describe('cross-route credential replay via scope binding flaw', () => {
1567
1567
  // The result should be 200 (matched to cheap), not routed to expensive.
1568
1568
  expect(result.status).toBe(200)
1569
1569
  })
1570
+
1571
+ test('rejects no-splits credential replayed at splits route', async () => {
1572
+ // Method whose schema transform moves splits into methodDetails.
1573
+ const splitsMethod = Method.from({
1574
+ name: 'mock',
1575
+ intent: 'charge',
1576
+ schema: {
1577
+ credential: { payload: z.object({ token: z.string() }) },
1578
+ request: z.pipe(
1579
+ z.object({
1580
+ amount: z.string(),
1581
+ currency: z.string(),
1582
+ decimals: z.number(),
1583
+ recipient: z.string(),
1584
+ splits: z.optional(z.array(z.object({ amount: z.string(), recipient: z.string() }))),
1585
+ }),
1586
+ z.transform(({ amount, currency, decimals, recipient, splits }) => ({
1587
+ methodDetails: {
1588
+ amount: String(Number(amount) * 10 ** decimals),
1589
+ currency,
1590
+ recipient,
1591
+ ...(splits && { splits }),
1592
+ },
1593
+ })),
1594
+ ),
1595
+ },
1596
+ })
1597
+
1598
+ const splitsServerMethod = Method.toServer(splitsMethod, {
1599
+ async verify() {
1600
+ return mockReceipt()
1601
+ },
1602
+ })
1603
+
1604
+ const handler = Mppx.create({ methods: [splitsServerMethod], realm, secretKey })
1605
+
1606
+ // Get a challenge from a route with no splits
1607
+ const noSplitsHandle = handler.charge({
1608
+ amount: '1',
1609
+ currency: '0x0000000000000000000000000000000000000001',
1610
+ decimals: 6,
1611
+ expires: new Date(Date.now() + 60_000).toISOString(),
1612
+ recipient: '0x0000000000000000000000000000000000000002',
1613
+ })
1614
+ const noSplitsResult = await noSplitsHandle(new Request('https://example.com/no-splits'))
1615
+ expect(noSplitsResult.status).toBe(402)
1616
+ if (noSplitsResult.status !== 402) throw new Error()
1617
+
1618
+ const noSplitsChallenge = Challenge.fromResponse(noSplitsResult.challenge)
1619
+ const credential = Credential.from({
1620
+ challenge: noSplitsChallenge,
1621
+ payload: { token: 'valid' },
1622
+ })
1623
+
1624
+ // Present at a route that requires splits
1625
+ const splitsHandle = handler.charge({
1626
+ amount: '1',
1627
+ currency: '0x0000000000000000000000000000000000000001',
1628
+ decimals: 6,
1629
+ expires: new Date(Date.now() + 60_000).toISOString(),
1630
+ recipient: '0x0000000000000000000000000000000000000002',
1631
+ splits: [{ amount: '0.2', recipient: '0x0000000000000000000000000000000000000003' }],
1632
+ })
1633
+ const result = await splitsHandle(
1634
+ new Request('https://example.com/with-splits', {
1635
+ headers: { Authorization: Credential.serialize(credential) },
1636
+ }),
1637
+ )
1638
+
1639
+ expect(result.status).toBe(402)
1640
+ })
1570
1641
  })
1571
1642
 
1572
1643
  describe('withReceipt', () => {
@@ -1741,3 +1812,219 @@ describe('withReceipt', () => {
1741
1812
  server.close()
1742
1813
  })
1743
1814
  })
1815
+
1816
+ describe('realm auto-detection', () => {
1817
+ beforeEach(() => {
1818
+ // Clear all env vars that Env.get('realm') probes so realm falls through to request detection
1819
+ for (const name of [
1820
+ 'MPP_REALM',
1821
+ 'FLY_APP_NAME',
1822
+ 'HEROKU_APP_NAME',
1823
+ 'RAILWAY_PUBLIC_DOMAIN',
1824
+ 'RENDER_EXTERNAL_HOSTNAME',
1825
+ 'VERCEL_URL',
1826
+ 'WEBSITE_HOSTNAME',
1827
+ ])
1828
+ vi.stubEnv(name, '')
1829
+ })
1830
+
1831
+ afterEach(() => {
1832
+ vi.unstubAllEnvs()
1833
+ })
1834
+
1835
+ const mockMethod = Method.toServer(
1836
+ Method.from({
1837
+ name: 'mock',
1838
+ intent: 'charge',
1839
+ schema: {
1840
+ credential: { payload: z.object({ token: z.string() }) },
1841
+ request: z.object({ amount: z.string(), currency: z.string(), recipient: z.string() }),
1842
+ },
1843
+ }),
1844
+ {
1845
+ async verify() {
1846
+ return {
1847
+ method: 'mock',
1848
+ reference: 'ref',
1849
+ status: 'success' as const,
1850
+ timestamp: new Date().toISOString(),
1851
+ }
1852
+ },
1853
+ },
1854
+ )
1855
+
1856
+ test.each([
1857
+ { url: 'https://mpp.dev/resource', expected: 'mpp.dev' },
1858
+ { url: 'https://api.example.com/v1/resource', expected: 'api.example.com' },
1859
+ { url: 'https://localhost:8787/resource', expected: 'localhost' },
1860
+ { url: 'https://MPP.DEV/resource', expected: 'mpp.dev' },
1861
+ { url: 'http://staging.mpp.dev:3000/api', expected: 'staging.mpp.dev' },
1862
+ ])('derives realm "$expected" from $url', async ({ url, expected }) => {
1863
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1864
+
1865
+ const result = await handler.charge({
1866
+ amount: '100',
1867
+ currency: '0x0000000000000000000000000000000000000001',
1868
+ recipient: '0x0000000000000000000000000000000000000002',
1869
+ })(new Request(url))
1870
+
1871
+ expect(result.status).toBe(402)
1872
+ if (result.status !== 402) throw new Error()
1873
+
1874
+ const challenge = Challenge.fromResponse(result.challenge)
1875
+ expect(challenge.realm).toBe(expected)
1876
+ })
1877
+
1878
+ test('credential verifies across different casing of same host', async () => {
1879
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1880
+
1881
+ const chargeOpts = {
1882
+ amount: '100',
1883
+ currency: '0x0000000000000000000000000000000000000001',
1884
+ recipient: '0x0000000000000000000000000000000000000002',
1885
+ }
1886
+
1887
+ // Get challenge with uppercase host
1888
+ const result = await handler.charge(chargeOpts)(new Request('https://MPP.DEV/resource'))
1889
+ expect(result.status).toBe(402)
1890
+ if (result.status !== 402) throw new Error()
1891
+
1892
+ const challenge = Challenge.fromResponse(result.challenge)
1893
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1894
+
1895
+ // Verify with lowercase host — should match since both normalize
1896
+ const verifyResult = await handler.charge(chargeOpts)(
1897
+ new Request('https://mpp.dev/resource', {
1898
+ headers: { Authorization: Credential.serialize(credential) },
1899
+ }),
1900
+ )
1901
+ expect(verifyResult.status).toBe(200)
1902
+ })
1903
+
1904
+ test('explicit realm takes precedence over request url', async () => {
1905
+ const handler = Mppx.create({ methods: [mockMethod], realm: 'explicit.example.com', secretKey })
1906
+
1907
+ const request = new Request('https://other.example.com/resource')
1908
+ const result = await handler.charge({
1909
+ amount: '100',
1910
+ currency: '0x0000000000000000000000000000000000000001',
1911
+ recipient: '0x0000000000000000000000000000000000000002',
1912
+ })(request)
1913
+
1914
+ expect(result.status).toBe(402)
1915
+ if (result.status !== 402) throw new Error()
1916
+
1917
+ const challenge = Challenge.fromResponse(result.challenge)
1918
+ expect(challenge.realm).toBe('explicit.example.com')
1919
+ })
1920
+
1921
+ test('challenge and verification use same auto-detected realm', async () => {
1922
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1923
+
1924
+ const url = 'https://mpp.dev/resource'
1925
+
1926
+ // Get challenge
1927
+ const result = await handler.charge({
1928
+ amount: '100',
1929
+ currency: '0x0000000000000000000000000000000000000001',
1930
+ recipient: '0x0000000000000000000000000000000000000002',
1931
+ })(new Request(url))
1932
+
1933
+ expect(result.status).toBe(402)
1934
+ if (result.status !== 402) throw new Error()
1935
+
1936
+ const challenge = Challenge.fromResponse(result.challenge)
1937
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1938
+
1939
+ // Replay with credential from same host — should verify
1940
+ const verifyResult = await handler.charge({
1941
+ amount: '100',
1942
+ currency: '0x0000000000000000000000000000000000000001',
1943
+ recipient: '0x0000000000000000000000000000000000000002',
1944
+ })(new Request(url, { headers: { Authorization: Credential.serialize(credential) } }))
1945
+
1946
+ expect(verifyResult.status).toBe(200)
1947
+ })
1948
+
1949
+ test('credential from one host rejected at different host', async () => {
1950
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1951
+
1952
+ // Get challenge from host A
1953
+ const result = await handler.charge({
1954
+ amount: '100',
1955
+ currency: '0x0000000000000000000000000000000000000001',
1956
+ recipient: '0x0000000000000000000000000000000000000002',
1957
+ })(new Request('https://host-a.example.com/resource'))
1958
+
1959
+ expect(result.status).toBe(402)
1960
+ if (result.status !== 402) throw new Error()
1961
+
1962
+ const challenge = Challenge.fromResponse(result.challenge)
1963
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
1964
+
1965
+ // Present at host B — realm mismatch should reject
1966
+ const verifyResult = await handler.charge({
1967
+ amount: '100',
1968
+ currency: '0x0000000000000000000000000000000000000001',
1969
+ recipient: '0x0000000000000000000000000000000000000002',
1970
+ })(
1971
+ new Request('https://host-b.example.com/resource', {
1972
+ headers: { Authorization: Credential.serialize(credential) },
1973
+ }),
1974
+ )
1975
+
1976
+ expect(verifyResult.status).toBe(402)
1977
+ })
1978
+
1979
+ test('realm undefined on handler when not explicitly set', () => {
1980
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1981
+ expect(handler.realm).toBeUndefined()
1982
+ })
1983
+
1984
+ test('falls back to default realm when input has no url', async () => {
1985
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
1986
+ const handle = handler.charge({
1987
+ amount: '100',
1988
+ currency: '0x0000000000000000000000000000000000000001',
1989
+ recipient: '0x0000000000000000000000000000000000000002',
1990
+ })
1991
+
1992
+ // Simulate a non-HTTP input with no .url — should warn and use fallback
1993
+ const result = await handle({} as any)
1994
+ expect(result.status).toBe(402)
1995
+ if (result.status !== 402) throw new Error()
1996
+ const challenge = Challenge.fromResponse(result.challenge)
1997
+ expect(challenge.realm).toBe('MPP Payment')
1998
+ })
1999
+
2000
+ test('cross-host rejection reports realm mismatch', async () => {
2001
+ const handler = Mppx.create({ methods: [mockMethod], secretKey })
2002
+
2003
+ const result = await handler.charge({
2004
+ amount: '100',
2005
+ currency: '0x0000000000000000000000000000000000000001',
2006
+ recipient: '0x0000000000000000000000000000000000000002',
2007
+ })(new Request('https://host-a.example.com/resource'))
2008
+
2009
+ expect(result.status).toBe(402)
2010
+ if (result.status !== 402) throw new Error()
2011
+
2012
+ const challenge = Challenge.fromResponse(result.challenge)
2013
+ const credential = Credential.from({ challenge, payload: { token: 'valid' } })
2014
+
2015
+ const verifyResult = await handler.charge({
2016
+ amount: '100',
2017
+ currency: '0x0000000000000000000000000000000000000001',
2018
+ recipient: '0x0000000000000000000000000000000000000002',
2019
+ })(
2020
+ new Request('https://host-b.example.com/resource', {
2021
+ headers: { Authorization: Credential.serialize(credential) },
2022
+ }),
2023
+ )
2024
+
2025
+ expect(verifyResult.status).toBe(402)
2026
+ if (verifyResult.status !== 402) throw new Error()
2027
+ const body = (await verifyResult.challenge.json()) as { detail: string }
2028
+ expect(body.detail).toContain('realm')
2029
+ })
2030
+ })
@@ -153,7 +153,7 @@ export function create<
153
153
  const transport extends Transport.AnyTransport = Transport.Http,
154
154
  >(config: create.Config<methods, transport>): Mppx<methods, transport> {
155
155
  const {
156
- realm = Env.get('realm') ?? 'MPP Payment',
156
+ realm = Env.get('realm'),
157
157
  secretKey = Env.get('secretKey'),
158
158
  transport = Transport.http() as transport,
159
159
  } = config
@@ -222,7 +222,7 @@ export function create<
222
222
  return {
223
223
  methods,
224
224
  compose: composeFn,
225
- realm: realm as string,
225
+ realm: realm as string | undefined,
226
226
  transport,
227
227
  ...handlers,
228
228
  } as never
@@ -235,7 +235,7 @@ export declare namespace create {
235
235
  > = {
236
236
  /** Array of configured methods. @example [tempo()] */
237
237
  methods: methods
238
- /** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
238
+ /** Server realm (e.g., hostname). Resolution order: explicit value > env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) > request URL hostname > `"MPP Payment"`. */
239
239
  realm?: string | undefined
240
240
  /** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
241
241
  secretKey?: string | undefined
@@ -283,6 +283,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
283
283
  : merged
284
284
  ) as never
285
285
 
286
+ // Resolve realm: explicit > env var > request Host header.
287
+ const effectiveRealm = realm ?? resolveRealmFromRequest(input)
288
+
286
289
  // Recompute challenge from options. The HMAC-bound ID means we don't need to
287
290
  // store challenges server-side—if the client echoes back a credential with
288
291
  // a matching ID, we know it was issued by us with these exact parameters.
@@ -290,7 +293,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
290
293
  description,
291
294
  expires,
292
295
  meta,
293
- realm,
296
+ realm: effectiveRealm,
294
297
  request,
295
298
  secretKey,
296
299
  })
@@ -389,6 +392,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
389
392
  return { challenge: response, status: 402 }
390
393
  }
391
394
  }
395
+
396
+ // Compare payment-relevant methodDetails fields (memo, splits).
397
+ // These are excluded from the top-level field check above but
398
+ // affect verification semantics — a credential issued for a
399
+ // no-splits route must not be accepted on a splits route.
400
+ for (const field of ['memo', 'splits'] as const) {
401
+ const routeVal = routeDetails[field]
402
+ const echoedVal = echoedDetails[field]
403
+ if (
404
+ routeVal !== undefined &&
405
+ JSON.stringify(routeVal) !== JSON.stringify(echoedVal)
406
+ ) {
407
+ const response = await transport.respondChallenge({
408
+ challenge,
409
+ input,
410
+ error: new Errors.InvalidChallengeError({
411
+ id: credential.challenge.id,
412
+ reason: `credential ${field} does not match this route's requirements`,
413
+ }),
414
+ })
415
+ return { challenge: response, status: 402 }
416
+ }
417
+ }
392
418
  }
393
419
  }
394
420
 
@@ -483,7 +509,7 @@ declare namespace createMethodFn {
483
509
  > = {
484
510
  defaults?: defaults
485
511
  method: method
486
- realm: string
512
+ realm: string | undefined
487
513
  request?: Method.RequestFn<method>
488
514
  respond?: Method.RespondFn<method>
489
515
  secretKey: string
@@ -498,6 +524,34 @@ declare namespace createMethodFn {
498
524
  > = MethodFn<method, transport, defaults>
499
525
  }
500
526
 
527
+ const defaultRealm = 'MPP Payment'
528
+ const Warnings = {
529
+ realmFallback: 'realm-fallback',
530
+ } as const
531
+
532
+ const _warned = new Set<string>()
533
+ function warnOnce(key: string, message: string) {
534
+ if (_warned.has(key)) return
535
+ _warned.add(key)
536
+ console.warn(`[mppx] ${message}`)
537
+ }
538
+
539
+ /** Extracts hostname from the request URL, falling back to a default. */
540
+ function resolveRealmFromRequest(input: unknown): string {
541
+ try {
542
+ const url = typeof (input as any)?.url === 'string' ? (input as any).url : undefined
543
+ if (url) {
544
+ const { protocol, hostname } = new URL(url)
545
+ if (/^https?:$/.test(protocol) && hostname) return hostname
546
+ }
547
+ } catch {}
548
+ warnOnce(
549
+ Warnings.realmFallback,
550
+ `Could not auto-detect realm from request. Falling back to "${defaultRealm}". Set \`realm\` in Mppx.create() or the MPP_REALM env var.`,
551
+ )
552
+ return defaultRealm
553
+ }
554
+
501
555
  export type MethodFn<
502
556
  method extends Method.Method,
503
557
  transport extends Transport.AnyTransport,
@@ -6,7 +6,11 @@
6
6
  */
7
7
  export type StripeClient = {
8
8
  paymentIntents: {
9
- create(...args: any[]): Promise<{ id: string; status: string }>
9
+ create(...args: any[]): Promise<{
10
+ id: string
11
+ status: string
12
+ lastResponse?: { headers?: Record<string, string> }
13
+ }>
10
14
  }
11
15
  }
12
16
 
@@ -16,9 +16,15 @@ function createMockStripeClient(
16
16
  overrides?: Partial<{ status: string; id: string; throws: boolean }>,
17
17
  ): { client: StripeClient; create: ReturnType<typeof vi.fn> } {
18
18
  const { status = 'succeeded', id = 'pi_mock_123', throws = false } = overrides ?? {}
19
+ let callCount = 0
19
20
  const create = vi.fn(async () => {
20
21
  if (throws) throw new Error('Stripe API error')
21
- return { id, status }
22
+ callCount++
23
+ return {
24
+ id,
25
+ status,
26
+ ...(callCount > 1 ? { lastResponse: { headers: { 'idempotent-replayed': 'true' } } } : {}),
27
+ }
22
28
  })
23
29
  return {
24
30
  client: { paymentIntents: { create } },
@@ -196,6 +202,51 @@ describe('stripe.charge with client', () => {
196
202
  expect(body.detail).toContain('requires action')
197
203
  })
198
204
 
205
+ test('behavior: rejects replayed credential', async () => {
206
+ const { client } = createMockStripeClient()
207
+
208
+ const server = Mppx.create({
209
+ methods: [
210
+ stripe.charge({
211
+ client,
212
+ networkId: 'internal',
213
+ paymentMethodTypes: ['card'],
214
+ }),
215
+ ],
216
+ realm,
217
+ secretKey,
218
+ })
219
+
220
+ const handle = server.charge({ amount: '1', currency: 'usd', decimals: 2 })
221
+
222
+ // First request: get challenge
223
+ const firstResult = await handle(new Request('https://example.com'))
224
+ expect(firstResult.status).toBe(402)
225
+ if (firstResult.status !== 402) throw new Error()
226
+
227
+ const challenge = Challenge.fromResponse(firstResult.challenge)
228
+ const credential = Credential.from({
229
+ challenge,
230
+ payload: { spt: 'spt_test_token' },
231
+ })
232
+
233
+ // First payment: should succeed
234
+ const result1 = await handle(
235
+ new Request('https://example.com', {
236
+ headers: { Authorization: Credential.serialize(credential) },
237
+ }),
238
+ )
239
+ expect(result1.status).toBe(200)
240
+
241
+ // Replay same credential: should be rejected
242
+ const result2 = await handle(
243
+ new Request('https://example.com', {
244
+ headers: { Authorization: Credential.serialize(credential) },
245
+ }),
246
+ )
247
+ expect(result2.status).toBe(402)
248
+ })
249
+
199
250
  test('behavior: receipt contains mock reference', async () => {
200
251
  const { client } = createMockStripeClient({ id: 'pi_custom_ref' })
201
252
 
@@ -89,6 +89,9 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
89
89
  metadata: resolvedMetadata,
90
90
  })
91
91
 
92
+ if (pi.replayed)
93
+ throw new VerificationFailedError({ reason: 'Payment has already been processed.' })
94
+
92
95
  if (pi.status === 'requires_action') {
93
96
  throw new PaymentActionRequiredError({ reason: 'Stripe PaymentIntent requires action' })
94
97
  }
@@ -136,7 +139,7 @@ async function createWithClient(parameters: {
136
139
  metadata: Record<string, string>
137
140
  request: { amount: unknown; currency: unknown }
138
141
  spt: string
139
- }): Promise<{ id: string; status: string }> {
142
+ }): Promise<{ id: string; status: string; replayed: boolean }> {
140
143
  const { client, challenge, metadata, request, spt } = parameters
141
144
  try {
142
145
  const result = await client.paymentIntents.create(
@@ -151,7 +154,9 @@ async function createWithClient(parameters: {
151
154
  } as any,
152
155
  { idempotencyKey: `mppx_${challenge.id}_${spt}` },
153
156
  )
154
- return { id: result.id, status: result.status }
157
+ // https://docs.stripe.com/error-low-level#idempotency
158
+ const replayed = result.lastResponse?.headers?.['idempotent-replayed'] === 'true'
159
+ return { id: result.id, status: result.status, replayed }
155
160
  } catch {
156
161
  throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' })
157
162
  }
@@ -164,7 +169,7 @@ async function createWithSecretKey(parameters: {
164
169
  metadata: Record<string, string>
165
170
  request: { amount: unknown; currency: unknown }
166
171
  spt: string
167
- }): Promise<{ id: string; status: string }> {
172
+ }): Promise<{ id: string; status: string; replayed: boolean }> {
168
173
  const { secretKey, challenge, metadata, request, spt } = parameters
169
174
 
170
175
  const body = new URLSearchParams({
@@ -190,7 +195,10 @@ async function createWithSecretKey(parameters: {
190
195
  })
191
196
 
192
197
  if (!response.ok) throw new VerificationFailedError({ reason: 'Stripe PaymentIntent failed' })
193
- return (await response.json()) as { id: string; status: string }
198
+ // https://docs.stripe.com/error-low-level#idempotency
199
+ const replayed = response.headers.get('idempotent-replayed') === 'true'
200
+ const result = (await response.json()) as { id: string; status: string }
201
+ return { ...result, replayed }
194
202
  }
195
203
 
196
204
  /** @internal */
@@ -51,6 +51,85 @@ describe('charge', () => {
51
51
  expect(result.success).toBe(true)
52
52
  })
53
53
 
54
+ test('schema: validates request with splits', () => {
55
+ const result = Methods.charge.schema.request.safeParse({
56
+ amount: '1',
57
+ currency: '0x20c0000000000000000000000000000000000001',
58
+ decimals: 6,
59
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
60
+ splits: [
61
+ {
62
+ amount: '0.25',
63
+ recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
64
+ },
65
+ {
66
+ amount: '0.1',
67
+ memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
68
+ recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
69
+ },
70
+ ],
71
+ })
72
+ expect(result.success).toBe(true)
73
+ if (!result.success) return
74
+
75
+ expect(result.data.methodDetails?.splits).toEqual([
76
+ {
77
+ amount: '250000',
78
+ recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
79
+ },
80
+ {
81
+ amount: '100000',
82
+ memo: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
83
+ recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
84
+ },
85
+ ])
86
+ })
87
+
88
+ test('schema: rejects empty splits', () => {
89
+ const result = Methods.charge.schema.request.safeParse({
90
+ amount: '1',
91
+ currency: '0x20c0000000000000000000000000000000000001',
92
+ decimals: 6,
93
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
94
+ splits: [],
95
+ })
96
+ expect(result.success).toBe(false)
97
+ })
98
+
99
+ test('schema: rejects more than 10 splits', () => {
100
+ const result = Methods.charge.schema.request.safeParse({
101
+ amount: '11',
102
+ currency: '0x20c0000000000000000000000000000000000001',
103
+ decimals: 6,
104
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
105
+ splits: Array.from({ length: 11 }, (_, index) => ({
106
+ amount: '0.1',
107
+ recipient: `0x${(index + 1).toString(16).padStart(40, '0')}`,
108
+ })),
109
+ })
110
+ expect(result.success).toBe(false)
111
+ })
112
+
113
+ test('schema: rejects split totals greater than or equal to amount', () => {
114
+ const result = Methods.charge.schema.request.safeParse({
115
+ amount: '1',
116
+ currency: '0x20c0000000000000000000000000000000000001',
117
+ decimals: 6,
118
+ recipient: '0x1234567890abcdef1234567890abcdef12345678',
119
+ splits: [
120
+ {
121
+ amount: '0.5',
122
+ recipient: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd',
123
+ },
124
+ {
125
+ amount: '0.5',
126
+ recipient: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
127
+ },
128
+ ],
129
+ })
130
+ expect(result.success).toBe(false)
131
+ })
132
+
54
133
  test('schema: rejects invalid request', () => {
55
134
  const result = Methods.charge.schema.request.safeParse({
56
135
  amount: '1',