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.
- package/CHANGELOG.md +23 -1
- package/dist/internal/env.d.ts +1 -1
- package/dist/internal/env.d.ts.map +1 -1
- package/dist/internal/env.js +2 -6
- package/dist/internal/env.js.map +1 -1
- package/dist/internal/types.d.ts +23 -0
- package/dist/internal/types.d.ts.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +49 -2
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/internal/types.d.ts +3 -0
- package/dist/stripe/internal/types.d.ts.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +9 -2
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/Methods.d.ts +15 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +27 -3
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Charge.d.ts +21 -0
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +33 -7
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +15 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/account.d.ts +5 -11
- package/dist/tempo/internal/account.d.ts.map +1 -1
- package/dist/tempo/internal/charge.d.ts +20 -0
- package/dist/tempo/internal/charge.d.ts.map +1 -0
- package/dist/tempo/internal/charge.js +23 -0
- package/dist/tempo/internal/charge.js.map +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +15 -3
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +17 -2
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +148 -99
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +17 -2
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -1
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +9 -4
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +25 -6
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts +18 -2
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +18 -14
- package/dist/tempo/session/Chain.js.map +1 -1
- package/package.json +1 -1
- package/src/internal/env.test.ts +12 -12
- package/src/internal/env.ts +2 -6
- package/src/internal/types.ts +25 -0
- package/src/server/Mppx.test.ts +287 -0
- package/src/server/Mppx.ts +59 -5
- package/src/stripe/internal/types.ts +5 -1
- package/src/stripe/server/Charge.test.ts +52 -1
- package/src/stripe/server/Charge.ts +12 -4
- package/src/tempo/Methods.test.ts +79 -0
- package/src/tempo/Methods.ts +53 -17
- package/src/tempo/client/Charge.ts +41 -8
- package/src/tempo/internal/account.ts +7 -14
- package/src/tempo/internal/charge.ts +43 -0
- package/src/tempo/internal/fee-payer.test.ts +33 -14
- package/src/tempo/internal/fee-payer.ts +21 -6
- package/src/tempo/server/Charge.test.ts +231 -0
- package/src/tempo/server/Charge.ts +193 -124
- package/src/tempo/server/Methods.ts +4 -1
- package/src/tempo/server/Session.test.ts +57 -0
- package/src/tempo/server/Session.ts +33 -20
- package/src/tempo/session/Chain.test.ts +25 -5
- package/src/tempo/session/Chain.ts +30 -14
package/src/server/Mppx.test.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/server/Mppx.ts
CHANGED
|
@@ -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')
|
|
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).
|
|
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<{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|