mppx 0.8.13 → 0.8.15
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 +37 -0
- package/dist/Errors.d.ts +10 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +11 -2
- package/dist/Errors.js.map +1 -1
- package/dist/Method.d.ts +105 -3
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +102 -2
- package/dist/Method.js.map +1 -1
- package/dist/cli/validate/payment.d.ts.map +1 -1
- package/dist/cli/validate/payment.js +18 -4
- package/dist/cli/validate/payment.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +98 -5
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/MethodChallenge.d.ts +19 -0
- package/dist/client/internal/MethodChallenge.d.ts.map +1 -0
- package/dist/client/internal/MethodChallenge.js +15 -0
- package/dist/client/internal/MethodChallenge.js.map +1 -0
- package/dist/client/internal/MethodResponse.d.ts +15 -0
- package/dist/client/internal/MethodResponse.d.ts.map +1 -1
- package/dist/client/internal/MethodResponse.js +10 -0
- package/dist/client/internal/MethodResponse.js.map +1 -1
- package/dist/server/Mppx.d.ts +23 -4
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +91 -82
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +1 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +1 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +7 -2
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/legacy/client/ChannelOps.js +2 -1
- package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +46 -77
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +289 -209
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +12 -1
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +4 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Relay.d.ts +57 -0
- package/dist/tempo/server/Relay.d.ts.map +1 -0
- package/dist/tempo/server/Relay.js +179 -0
- package/dist/tempo/server/Relay.js.map +1 -0
- package/dist/tempo/server/SponsorBudget.d.ts +59 -0
- package/dist/tempo/server/SponsorBudget.d.ts.map +1 -0
- package/dist/tempo/server/SponsorBudget.js +150 -0
- package/dist/tempo/server/SponsorBudget.js.map +1 -0
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +7 -1
- package/dist/tempo/session/client/ChannelOps.d.ts.map +1 -1
- package/dist/tempo/session/client/ChannelOps.js +9 -4
- package/dist/tempo/session/client/ChannelOps.js.map +1 -1
- package/dist/tempo/session/client/CredentialState.d.ts +5 -1
- package/dist/tempo/session/client/CredentialState.d.ts.map +1 -1
- package/dist/tempo/session/client/CredentialState.js +37 -19
- package/dist/tempo/session/client/CredentialState.js.map +1 -1
- package/dist/tempo/session/client/Runtime.d.ts +8 -0
- package/dist/tempo/session/client/Runtime.d.ts.map +1 -1
- package/dist/tempo/session/client/Runtime.js +17 -0
- package/dist/tempo/session/client/Runtime.js.map +1 -1
- package/dist/tempo/session/client/Session.d.ts +15 -5
- package/dist/tempo/session/client/Session.d.ts.map +1 -1
- package/dist/tempo/session/client/Session.js +216 -45
- package/dist/tempo/session/client/Session.js.map +1 -1
- package/dist/tempo/session/client/SessionManager.d.ts +8 -1
- package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +114 -44
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/tempo/session/client/Transports.d.ts +16 -6
- package/dist/tempo/session/client/Transports.d.ts.map +1 -1
- package/dist/tempo/session/client/Transports.js +84 -37
- package/dist/tempo/session/client/Transports.js.map +1 -1
- package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Chain.js +96 -8
- package/dist/tempo/session/precompile/Chain.js.map +1 -1
- package/dist/tempo/session/precompile/Channel.d.ts +2 -0
- package/dist/tempo/session/precompile/Channel.d.ts.map +1 -1
- package/dist/tempo/session/precompile/Channel.js +4 -0
- package/dist/tempo/session/precompile/Channel.js.map +1 -1
- package/dist/tempo/session/server/RequestState.js +3 -1
- package/dist/tempo/session/server/RequestState.js.map +1 -1
- package/dist/tempo/session/server/Ws.d.ts.map +1 -1
- package/dist/tempo/session/server/Ws.js +3 -0
- package/dist/tempo/session/server/Ws.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +1 -829
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.js +14 -3
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -1
- package/dist/viem/Client.d.ts.map +1 -1
- package/dist/viem/Client.js +6 -1
- package/dist/viem/Client.js.map +1 -1
- package/package.json +2 -2
- package/src/Errors.test.ts +23 -0
- package/src/Errors.ts +21 -2
- package/src/Method.test.ts +102 -1
- package/src/Method.ts +241 -5
- package/src/cli/validate/payment.ts +18 -4
- package/src/cli/validate.test.ts +16 -0
- package/src/client/Mppx.test-d.ts +3 -3
- package/src/client/internal/Fetch.test.ts +77 -0
- package/src/client/internal/Fetch.ts +122 -11
- package/src/client/internal/MethodChallenge.ts +35 -0
- package/src/client/internal/MethodResponse.ts +28 -0
- package/src/server/Mppx.test-d.ts +2 -0
- package/src/server/Mppx.test.ts +205 -0
- package/src/server/Mppx.ts +167 -103
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/PublicExports.test-d.ts +20 -0
- package/src/tempo/internal/fee-payer.ts +7 -3
- package/src/tempo/legacy/client/ChannelOps.test.ts +7 -0
- package/src/tempo/legacy/client/ChannelOps.ts +2 -1
- package/src/tempo/server/Charge.test.ts +337 -63
- package/src/tempo/server/Charge.ts +443 -267
- package/src/tempo/server/Methods.ts +11 -0
- package/src/tempo/server/Relay.test.ts +677 -0
- package/src/tempo/server/Relay.ts +297 -0
- package/src/tempo/server/SponsorBudget.test.ts +154 -0
- package/src/tempo/server/SponsorBudget.ts +219 -0
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/README.md +7 -0
- package/src/tempo/session/client/ChannelOps.test.ts +17 -2
- package/src/tempo/session/client/ChannelOps.ts +12 -4
- package/src/tempo/session/client/CredentialState.ts +61 -20
- package/src/tempo/session/client/Runtime.test.ts +12 -0
- package/src/tempo/session/client/Runtime.ts +22 -0
- package/src/tempo/session/client/Session.test.ts +506 -7
- package/src/tempo/session/client/Session.ts +279 -54
- package/src/tempo/session/client/SessionManager.test.ts +318 -13
- package/src/tempo/session/client/SessionManager.ts +132 -42
- package/src/tempo/session/client/Transports.test.ts +54 -1
- package/src/tempo/session/client/Transports.ts +137 -57
- package/src/tempo/session/precompile/Chain.integration.test.ts +19 -1
- package/src/tempo/session/precompile/Chain.test.ts +197 -10
- package/src/tempo/session/precompile/Chain.ts +102 -8
- package/src/tempo/session/precompile/Channel.test.ts +9 -0
- package/src/tempo/session/precompile/Channel.ts +5 -0
- package/src/tempo/session/server/RequestState.test.ts +8 -1
- package/src/tempo/session/server/RequestState.ts +2 -2
- package/src/tempo/session/server/Session.test.ts +66 -4
- package/src/tempo/session/server/Ws.ts +3 -0
- package/src/tempo/subscription/KeyAuthorization.test.ts +70 -1
- package/src/tempo/subscription/KeyAuthorization.ts +14 -3
- package/src/viem/Client.test.ts +18 -1
- package/src/viem/Client.ts +7 -1
|
@@ -46,6 +46,20 @@ async function provisionAndPayTestnet(
|
|
|
46
46
|
await Promise.all(hashes.map((hash) => waitForTransactionReceipt(client, { hash })))
|
|
47
47
|
if (!silent) console.log(pc.dim(` Using wallet: ${account.address}`))
|
|
48
48
|
|
|
49
|
+
// The faucet tx receipt can land before the RPC's balance reads are
|
|
50
|
+
// consistent with it, so poll briefly for the funded balance to appear
|
|
51
|
+
// rather than racing straight into the charge.
|
|
52
|
+
const currency = (challenge.request as Record<string, unknown>).currency
|
|
53
|
+
if (typeof currency === 'string') {
|
|
54
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
55
|
+
const balance = await Actions.token
|
|
56
|
+
.getBalance(client, { account: account.address, token: currency as Address })
|
|
57
|
+
.catch(() => undefined)
|
|
58
|
+
if (balance && balance.amount > 0n) break
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
49
63
|
const methods = [...tempoMethods({ account })]
|
|
50
64
|
return { methods }
|
|
51
65
|
} catch (error) {
|
|
@@ -198,7 +212,7 @@ export async function validatePaymentFlow(
|
|
|
198
212
|
const mppx = Mppx.create({ methods: provisioned.methods, polyfill: false })
|
|
199
213
|
const cred = await mppx.createCredential(fakeResp)
|
|
200
214
|
results.push(check('Payment: submitted', 'ephemeral testnet wallet'))
|
|
201
|
-
|
|
215
|
+
await sendAndValidateResponse(
|
|
202
216
|
results,
|
|
203
217
|
url,
|
|
204
218
|
endpoint,
|
|
@@ -210,17 +224,17 @@ export async function validatePaymentFlow(
|
|
|
210
224
|
)
|
|
211
225
|
} catch (error) {
|
|
212
226
|
results.push(fail('Payment: create credential', (error as Error).message))
|
|
213
|
-
return results
|
|
214
227
|
}
|
|
215
228
|
} else {
|
|
216
229
|
results.push(
|
|
217
230
|
fail('Payment: auto-provision wallet', 'Failed to create and fund testnet wallet'),
|
|
218
231
|
)
|
|
219
|
-
return results
|
|
220
232
|
}
|
|
233
|
+
options.onResults?.(results)
|
|
221
234
|
}
|
|
222
235
|
|
|
223
|
-
// Try each challenge method (
|
|
236
|
+
// Try each remaining challenge method (tempo testnet already handled above),
|
|
237
|
+
// so validate exercises every payment method the server offers, same as mainnet.
|
|
224
238
|
const loaded = await loadConfig().catch(() => undefined)
|
|
225
239
|
const isInteractive = options.interactive
|
|
226
240
|
const stripeKey = process.env.MPPX_STRIPE_SECRET_KEY ?? resolveStripeKey(verbose)
|
package/src/cli/validate.test.ts
CHANGED
|
@@ -707,6 +707,22 @@ describe('validate: multi-challenge', () => {
|
|
|
707
707
|
const { output } = await serve(['validate', server.url])
|
|
708
708
|
expect(output).toContain('Amount is valid integer string (Got: 1.50)')
|
|
709
709
|
})
|
|
710
|
+
|
|
711
|
+
test("tempo doesn't block other testnet methods", { timeout: 15_000 }, async () => {
|
|
712
|
+
// makeChallenge() defaults to a tempo testnet challenge (chainId 42431),
|
|
713
|
+
// which takes the ephemeral-wallet fast path. That path used to return
|
|
714
|
+
// immediately, so a server offering evm/stripe alongside tempo would
|
|
715
|
+
// never have those methods exercised. It should now fall through and
|
|
716
|
+
// try them too, same as it does for tempo mainnet challenges.
|
|
717
|
+
const server = await multiChallengeServer([makeChallenge(), makeEvmChallenge()])
|
|
718
|
+
const { output } = await serve(['validate', server.url, '--outputJson', '--yes'])
|
|
719
|
+
const jsonStart = output.indexOf('{')
|
|
720
|
+
const jsonEnd = output.lastIndexOf('}')
|
|
721
|
+
const result = JSON.parse(output.slice(jsonStart, jsonEnd + 1))
|
|
722
|
+
const paymentLabels = result.endpoints[0].payment.map((r: { label: string }) => r.label)
|
|
723
|
+
expect(paymentLabels).toContain('Payment: submitted')
|
|
724
|
+
expect(paymentLabels.some((l: string) => l.includes('[evm]'))).toBe(true)
|
|
725
|
+
})
|
|
710
726
|
})
|
|
711
727
|
|
|
712
728
|
describe('validate: payment methods coverage', () => {
|
|
@@ -262,9 +262,9 @@ describe('fetch context', () => {
|
|
|
262
262
|
type FetchInit = NonNullable<Parameters<typeof _mppx.fetch>[1]>
|
|
263
263
|
type Context = NonNullable<FetchInit['context']>
|
|
264
264
|
|
|
265
|
-
// Context is a union of charge and session contexts.
|
|
266
|
-
// `account` exists on both; `autoSwap` only on charge.
|
|
265
|
+
// Context is a union of charge and session contexts; both support auto-swap.
|
|
267
266
|
expectTypeOf<Context>().toHaveProperty('account')
|
|
268
|
-
expectTypeOf<
|
|
267
|
+
expectTypeOf<Context>().toHaveProperty('autoSwap')
|
|
268
|
+
expectTypeOf<Context['autoSwap']>().toEqualTypeOf<AutoSwap.resolve.Value | undefined>()
|
|
269
269
|
})
|
|
270
270
|
})
|
|
@@ -10,6 +10,7 @@ import { rpcUrl } from '~test/tempo/rpc.js'
|
|
|
10
10
|
import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
|
|
11
11
|
|
|
12
12
|
import * as Fetch from './Fetch.js'
|
|
13
|
+
import * as MethodChallenge from './MethodChallenge.js'
|
|
13
14
|
import * as MethodResponse from './MethodResponse.js'
|
|
14
15
|
|
|
15
16
|
const realm = 'api.example.com'
|
|
@@ -337,6 +338,23 @@ const noopMethod = {
|
|
|
337
338
|
createCredential: async () => 'credential',
|
|
338
339
|
} as any
|
|
339
340
|
|
|
341
|
+
function trackedMethod(settlements: MethodResponse.AttemptOutcome['status'][]) {
|
|
342
|
+
const method = {
|
|
343
|
+
...noopMethod,
|
|
344
|
+
async createCredential(parameters: object) {
|
|
345
|
+
const attempt = MethodResponse.getAttempt(parameters)
|
|
346
|
+
if (attempt)
|
|
347
|
+
attempt.settle = (outcome) => {
|
|
348
|
+
settlements.push(outcome.status)
|
|
349
|
+
return outcome.status !== 'pending'
|
|
350
|
+
}
|
|
351
|
+
return 'credential'
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
MethodChallenge.register(method, () => undefined)
|
|
355
|
+
return MethodResponse.register(method, ({ response }) => response)
|
|
356
|
+
}
|
|
357
|
+
|
|
340
358
|
const x402PaymentRequired = {
|
|
341
359
|
accepts: [
|
|
342
360
|
{
|
|
@@ -418,6 +436,30 @@ describe('Fetch.from: method responses', () => {
|
|
|
418
436
|
expect(handle).toHaveBeenCalledWith(expect.objectContaining({ credential: 'credential' }))
|
|
419
437
|
expect(paidResponse?.clone).not.toHaveBeenCalled()
|
|
420
438
|
})
|
|
439
|
+
|
|
440
|
+
test.each([
|
|
441
|
+
['a rejected paid response', () => new Response('rejected', { status: 500 }), 500, undefined],
|
|
442
|
+
['the final payment retry', () => make402(), 402, 1],
|
|
443
|
+
[
|
|
444
|
+
'the paid request throwing',
|
|
445
|
+
() => Promise.reject(new Error('paid request failed')),
|
|
446
|
+
undefined,
|
|
447
|
+
undefined,
|
|
448
|
+
],
|
|
449
|
+
])('settles credential state after %s', async (_name, paidRequest, status, maxPaymentRetries) => {
|
|
450
|
+
const settlements: MethodResponse.AttemptOutcome['status'][] = []
|
|
451
|
+
let calls = 0
|
|
452
|
+
const fetch = Fetch.from({
|
|
453
|
+
fetch: async () => (++calls === 1 ? make402() : paidRequest()),
|
|
454
|
+
maxPaymentRetries,
|
|
455
|
+
methods: [trackedMethod(settlements)],
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
const result = fetch('https://example.com/paid')
|
|
459
|
+
if (status) expect((await result).status).toBe(status)
|
|
460
|
+
else await expect(result).rejects.toThrow('paid request failed')
|
|
461
|
+
expect(settlements).toEqual(['rejected'])
|
|
462
|
+
})
|
|
421
463
|
})
|
|
422
464
|
|
|
423
465
|
describe('Fetch.from: init passthrough (non-402)', () => {
|
|
@@ -1873,6 +1915,41 @@ describe('Fetch.from: 402 retry path', () => {
|
|
|
1873
1915
|
).toEqual(['credential-first', 'credential-second', 'credential-third'])
|
|
1874
1916
|
})
|
|
1875
1917
|
|
|
1918
|
+
test('invalidates a prepared credential across a generic challenge', async () => {
|
|
1919
|
+
const firstCredential = vi.fn(async () => 'first')
|
|
1920
|
+
const first = { ...noopMethod, name: 'first', createCredential: firstCredential }
|
|
1921
|
+
const second = { ...noopMethod, name: 'second', createCredential: async () => 'second' }
|
|
1922
|
+
MethodChallenge.register(first, () => undefined)
|
|
1923
|
+
const responses = [
|
|
1924
|
+
make402({ id: 'first', method: 'first' }),
|
|
1925
|
+
make402({ id: 'second', method: 'second' }),
|
|
1926
|
+
make402({ id: 'first', method: 'first' }),
|
|
1927
|
+
new Response('OK'),
|
|
1928
|
+
]
|
|
1929
|
+
const mockFetch = vi.fn(async () => responses.shift()!)
|
|
1930
|
+
const fetch = Fetch.from({ fetch: mockFetch, methods: [first, second] })
|
|
1931
|
+
|
|
1932
|
+
expect((await fetch('https://example.com/api')).status).toBe(200)
|
|
1933
|
+
expect(firstCredential).toHaveBeenCalledTimes(2)
|
|
1934
|
+
})
|
|
1935
|
+
|
|
1936
|
+
test('invalidates a prepared credential after an explicit context', async () => {
|
|
1937
|
+
const createCredential = vi.fn(async () => 'credential')
|
|
1938
|
+
const method = { ...noopMethod, createCredential }
|
|
1939
|
+
MethodChallenge.register(method, () => undefined)
|
|
1940
|
+
let challenges = 0
|
|
1941
|
+
const mockFetch = vi.fn(async () => (++challenges <= 3 ? make402() : new Response('OK')))
|
|
1942
|
+
const fetch = Fetch.from({
|
|
1943
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
1944
|
+
methods: [method],
|
|
1945
|
+
onChallenge: async (_challenge, { createCredential: create }) =>
|
|
1946
|
+
challenges === 2 ? create({ override: true } as never) : undefined,
|
|
1947
|
+
})
|
|
1948
|
+
|
|
1949
|
+
expect((await fetch('https://example.com/api')).status).toBe(200)
|
|
1950
|
+
expect(createCredential).toHaveBeenCalledTimes(3)
|
|
1951
|
+
})
|
|
1952
|
+
|
|
1876
1953
|
test('caps repeated 402 retries at three', async () => {
|
|
1877
1954
|
let callCount = 0
|
|
1878
1955
|
const createCredential = vi.fn(async () => 'credential')
|
|
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
|
|
|
6
6
|
import type * as Method from '../../Method.js'
|
|
7
7
|
import type * as z from '../../zod.js'
|
|
8
8
|
import * as Transport from '../Transport.js'
|
|
9
|
+
import * as MethodChallenge from './MethodChallenge.js'
|
|
9
10
|
import * as MethodResponse from './MethodResponse.js'
|
|
10
11
|
|
|
11
12
|
// We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
|
|
@@ -18,9 +19,33 @@ type WrappedFetch = typeof globalThis.fetch & {
|
|
|
18
19
|
[MPPX_FETCH_WRAPPER]?: typeof globalThis.fetch
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Credential state retained between payment retries until its method settles it. */
|
|
23
|
+
type PreparedCredential = {
|
|
24
|
+
/** Method-owned preparation and settlement hooks. */
|
|
25
|
+
attempt: MethodResponse.Attempt
|
|
26
|
+
/** Serialized challenge used to recognize the same retry. */
|
|
27
|
+
key?: string | undefined
|
|
28
|
+
/** Method that prepared the credential. */
|
|
29
|
+
method: Method.AnyClient
|
|
30
|
+
/** Memoized credential reused for the same retry. */
|
|
31
|
+
promise: Promise<string>
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
let originalFetch: typeof globalThis.fetch | undefined
|
|
22
35
|
const eventObserverChecks = new WeakMap<object, (name: keyof ClientEventMap) => boolean>()
|
|
23
36
|
|
|
37
|
+
async function settleAttempt(
|
|
38
|
+
prepared: PreparedCredential | undefined,
|
|
39
|
+
outcome: MethodResponse.AttemptOutcome,
|
|
40
|
+
): Promise<boolean> {
|
|
41
|
+
const settle = prepared?.attempt.settle
|
|
42
|
+
if (!settle) return outcome.status !== 'pending'
|
|
43
|
+
prepared.attempt.settle = undefined
|
|
44
|
+
const settled = await settle(outcome)
|
|
45
|
+
if (!settled) prepared.attempt.settle = settle
|
|
46
|
+
return settled
|
|
47
|
+
}
|
|
48
|
+
|
|
24
49
|
export type ClientEventMap<
|
|
25
50
|
methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
|
|
26
51
|
response = Response,
|
|
@@ -209,6 +234,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
209
234
|
let challenge: Challenge.Challenge | undefined
|
|
210
235
|
let challenges: readonly Challenge.Challenge[] | undefined
|
|
211
236
|
let mi: methods[number] | undefined
|
|
237
|
+
let preparedCredential: PreparedCredential | undefined
|
|
212
238
|
|
|
213
239
|
try {
|
|
214
240
|
for (let retry = 0; retry < maxPaymentRetries; retry++) {
|
|
@@ -228,6 +254,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
228
254
|
)
|
|
229
255
|
const selected = orderedCandidates[0]
|
|
230
256
|
if (!selected) {
|
|
257
|
+
await settleAttempt(preparedCredential, {
|
|
258
|
+
challenges,
|
|
259
|
+
response,
|
|
260
|
+
status: 'rejected',
|
|
261
|
+
})
|
|
231
262
|
// A post-payment 402 with no actionable challenge (e.g. rejected credential)
|
|
232
263
|
// is the server's final answer, not a new challenge round.
|
|
233
264
|
if (retry > 0) return response
|
|
@@ -241,9 +272,60 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
241
272
|
mi = selected.method
|
|
242
273
|
if (challenge.expires) Expires.assert(challenge.expires, challenge.id)
|
|
243
274
|
|
|
275
|
+
const paymentInput = resolvePaymentRetryInput(
|
|
276
|
+
response,
|
|
277
|
+
initialRequest.input,
|
|
278
|
+
initialRequest.input,
|
|
279
|
+
)
|
|
280
|
+
const previousCredential = preparedCredential
|
|
281
|
+
const key = Challenge.serialize(selectedChallenge)
|
|
282
|
+
const sameAttempt =
|
|
283
|
+
previousCredential?.method === selected.method && previousCredential?.key === key
|
|
284
|
+
let canReusePrevious = false
|
|
285
|
+
if (previousCredential) {
|
|
286
|
+
const settled = await settleAttempt(previousCredential, {
|
|
287
|
+
challenges,
|
|
288
|
+
response,
|
|
289
|
+
status: sameAttempt ? 'pending' : 'rejected',
|
|
290
|
+
})
|
|
291
|
+
canReusePrevious = sameAttempt && !settled
|
|
292
|
+
}
|
|
293
|
+
preparedCredential = canReusePrevious ? previousCredential : undefined
|
|
244
294
|
const createCredential = memoizeCreateCredential(
|
|
245
|
-
(overrideContext?: AnyContextFor<methods>) =>
|
|
246
|
-
|
|
295
|
+
async (overrideContext?: AnyContextFor<methods>) => {
|
|
296
|
+
if (overrideContext === undefined && canReusePrevious && previousCredential) {
|
|
297
|
+
return previousCredential.promise
|
|
298
|
+
}
|
|
299
|
+
if (canReusePrevious && previousCredential) {
|
|
300
|
+
await settleAttempt(previousCredential, { status: 'rejected' })
|
|
301
|
+
preparedCredential = undefined
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const credentialContext = overrideContext ?? context
|
|
305
|
+
if (!MethodChallenge.has(selected.method))
|
|
306
|
+
return resolveCredential(selectedChallenge, selected.method, credentialContext)
|
|
307
|
+
const attempt: MethodResponse.Attempt = {
|
|
308
|
+
prepare: () =>
|
|
309
|
+
MethodChallenge.handle(selected.method, {
|
|
310
|
+
challenge: selectedChallenge,
|
|
311
|
+
context: credentialContext,
|
|
312
|
+
fetch: baseFetch,
|
|
313
|
+
input: paymentInput,
|
|
314
|
+
}),
|
|
315
|
+
}
|
|
316
|
+
const promise = attempt
|
|
317
|
+
.prepare()
|
|
318
|
+
.then(() =>
|
|
319
|
+
resolveCredential(selectedChallenge, selected.method, credentialContext, attempt),
|
|
320
|
+
)
|
|
321
|
+
preparedCredential = {
|
|
322
|
+
attempt,
|
|
323
|
+
key: overrideContext === undefined ? key : undefined,
|
|
324
|
+
method: selected.method,
|
|
325
|
+
promise,
|
|
326
|
+
}
|
|
327
|
+
return promise
|
|
328
|
+
},
|
|
247
329
|
)
|
|
248
330
|
const eventCredential = await events.emit(
|
|
249
331
|
'challenge.received',
|
|
@@ -265,6 +347,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
265
347
|
})
|
|
266
348
|
: undefined)
|
|
267
349
|
const credential = onChallengeCredential ?? (await createCredential())
|
|
350
|
+
const prepared = preparedCredential
|
|
351
|
+
if (prepared && (await prepared.promise.catch(() => undefined)) !== credential) {
|
|
352
|
+
await settleAttempt(prepared, { status: 'rejected' })
|
|
353
|
+
preparedCredential = undefined
|
|
354
|
+
}
|
|
268
355
|
validateCredentialHeaderValue(credential)
|
|
269
356
|
await events.emit(
|
|
270
357
|
'credential.created',
|
|
@@ -278,11 +365,6 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
278
365
|
}),
|
|
279
366
|
)
|
|
280
367
|
|
|
281
|
-
const paymentInput = resolvePaymentRetryInput(
|
|
282
|
-
response,
|
|
283
|
-
initialRequest.input,
|
|
284
|
-
initialRequest.input,
|
|
285
|
-
)
|
|
286
368
|
response = await baseFetch(
|
|
287
369
|
paymentInput,
|
|
288
370
|
transport.setCredential(
|
|
@@ -294,6 +376,17 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
294
376
|
{ challenge: selectedChallenge },
|
|
295
377
|
),
|
|
296
378
|
)
|
|
379
|
+
const paymentRequired = await transport.isPaymentRequired(
|
|
380
|
+
response,
|
|
381
|
+
transportRequest as never,
|
|
382
|
+
)
|
|
383
|
+
if (!paymentRequired) {
|
|
384
|
+
await settleAttempt(prepared, {
|
|
385
|
+
response,
|
|
386
|
+
status: response.ok ? 'accepted' : 'rejected',
|
|
387
|
+
})
|
|
388
|
+
preparedCredential = undefined
|
|
389
|
+
}
|
|
297
390
|
// Cloning an unobserved stream would tee and buffer it indefinitely.
|
|
298
391
|
if (response.ok && hasEventObservers(events, 'payment.response'))
|
|
299
392
|
await events.emit(
|
|
@@ -307,7 +400,7 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
307
400
|
response,
|
|
308
401
|
}),
|
|
309
402
|
)
|
|
310
|
-
if (!
|
|
403
|
+
if (!paymentRequired) {
|
|
311
404
|
if (!response.ok) return response
|
|
312
405
|
return MethodResponse.handle(selected.method, {
|
|
313
406
|
challenge: selectedChallenge,
|
|
@@ -325,8 +418,24 @@ export function from<const methods extends readonly Method.AnyClient[]>(
|
|
|
325
418
|
})
|
|
326
419
|
}
|
|
327
420
|
}
|
|
421
|
+
challenges = undefined
|
|
422
|
+
try {
|
|
423
|
+
challenges = transport.getChallenges
|
|
424
|
+
? await transport.getChallenges(response, transportRequest as never)
|
|
425
|
+
: [await transport.getChallenge(response, transportRequest as never)]
|
|
426
|
+
} catch {}
|
|
427
|
+
await settleAttempt(preparedCredential, {
|
|
428
|
+
challenges,
|
|
429
|
+
response,
|
|
430
|
+
status: 'rejected',
|
|
431
|
+
})
|
|
328
432
|
return response
|
|
329
433
|
} catch (error) {
|
|
434
|
+
await settleAttempt(preparedCredential, {
|
|
435
|
+
challenges,
|
|
436
|
+
response,
|
|
437
|
+
status: 'rejected',
|
|
438
|
+
})
|
|
330
439
|
await events.emit(
|
|
331
440
|
'payment.failed',
|
|
332
441
|
createPaymentFailedPayload({
|
|
@@ -875,11 +984,13 @@ async function resolveCredential(
|
|
|
875
984
|
challenge: Challenge.Challenge,
|
|
876
985
|
mi: Method.AnyClient,
|
|
877
986
|
context: unknown,
|
|
987
|
+
attempt?: MethodResponse.Attempt | undefined,
|
|
878
988
|
): Promise<string> {
|
|
879
989
|
const parsedContext = mi.context && context !== undefined ? mi.context.parse(context) : undefined
|
|
880
|
-
|
|
881
|
-
parsedContext !== undefined ? { challenge, context: parsedContext } :
|
|
882
|
-
)
|
|
990
|
+
const parameters =
|
|
991
|
+
parsedContext !== undefined ? { challenge, context: parsedContext } : { challenge }
|
|
992
|
+
if (attempt) MethodResponse.attachAttempt(mi, parameters, attempt)
|
|
993
|
+
return mi.createCredential(parameters as never)
|
|
883
994
|
}
|
|
884
995
|
|
|
885
996
|
function resolvePaymentPreferences<methods extends readonly Method.AnyClient[]>(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type * as Challenge from '../../Challenge.js'
|
|
2
|
+
import type { MaybePromise } from '../../internal/types.js'
|
|
3
|
+
import type * as Method from '../../Method.js'
|
|
4
|
+
|
|
5
|
+
const handlers = new WeakMap<Method.AnyClient, Handler>()
|
|
6
|
+
|
|
7
|
+
/** Inputs available before a client method creates a challenge credential. */
|
|
8
|
+
export type HandlerParameters = {
|
|
9
|
+
challenge: Challenge.Challenge
|
|
10
|
+
context?: unknown
|
|
11
|
+
fetch: typeof globalThis.fetch
|
|
12
|
+
input: RequestInfo | URL
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Internal client-method challenge hook. */
|
|
16
|
+
export type Handler = (parameters: HandlerParameters) => MaybePromise<void>
|
|
17
|
+
|
|
18
|
+
/** Registers an internal challenge hook without changing the public method shape. */
|
|
19
|
+
export function register<const method extends Method.AnyClient>(
|
|
20
|
+
method: method,
|
|
21
|
+
handler: Handler,
|
|
22
|
+
): method {
|
|
23
|
+
handlers.set(method, handler)
|
|
24
|
+
return method
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns whether a method registered pre-credential work. */
|
|
28
|
+
export function has(method: Method.AnyClient): boolean {
|
|
29
|
+
return handlers.has(method)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Runs method-specific work before creating a challenge credential. */
|
|
33
|
+
export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<void> {
|
|
34
|
+
return Promise.resolve(handlers.get(method)?.(parameters))
|
|
35
|
+
}
|
|
@@ -3,6 +3,20 @@ import type { MaybePromise } from '../../internal/types.js'
|
|
|
3
3
|
import type * as Method from '../../Method.js'
|
|
4
4
|
|
|
5
5
|
const handlers = new WeakMap<Method.AnyClient, Handler>()
|
|
6
|
+
const attempts = new WeakMap<object, Attempt>()
|
|
7
|
+
|
|
8
|
+
export type AttemptOutcome = {
|
|
9
|
+
challenges?: readonly Challenge.Challenge[] | undefined
|
|
10
|
+
response?: Response | undefined
|
|
11
|
+
status: 'accepted' | 'pending' | 'rejected'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Attempt = {
|
|
15
|
+
/** Re-runs method preparation when serialized state changed before signing. */
|
|
16
|
+
prepare: () => Promise<void>
|
|
17
|
+
/** Settles state created for one credential attempt. */
|
|
18
|
+
settle?: ((outcome: AttemptOutcome) => MaybePromise<boolean>) | undefined
|
|
19
|
+
}
|
|
6
20
|
|
|
7
21
|
/** Inputs available when a client method handles a successful paid response. */
|
|
8
22
|
export type HandlerParameters = {
|
|
@@ -33,6 +47,20 @@ export function unregister(method: Method.AnyClient): void {
|
|
|
33
47
|
handlers.delete(method)
|
|
34
48
|
}
|
|
35
49
|
|
|
50
|
+
/** Adds response lifecycle state to internal credential parameters. */
|
|
51
|
+
export function attachAttempt(
|
|
52
|
+
method: Method.AnyClient,
|
|
53
|
+
parameters: object,
|
|
54
|
+
attempt: Attempt,
|
|
55
|
+
): void {
|
|
56
|
+
if (handlers.has(method)) attempts.set(parameters, attempt)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Reads response lifecycle state when Fetch owns this credential. */
|
|
60
|
+
export function getAttempt(parameters: object): Attempt | undefined {
|
|
61
|
+
return attempts.get(parameters)
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
/** Lets the selected client method handle a successful paid response. */
|
|
37
65
|
export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response> {
|
|
38
66
|
return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response)
|
|
@@ -223,6 +223,8 @@ describe('Mppx type tests', () => {
|
|
|
223
223
|
const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })
|
|
224
224
|
|
|
225
225
|
expectTypeOf(mppx.verifyCredential).toBeFunction()
|
|
226
|
+
expectTypeOf(mppx.broadcastCredential).toBeFunction()
|
|
227
|
+
expectTypeOf(mppx.validateCredential).toBeFunction()
|
|
226
228
|
})
|
|
227
229
|
|
|
228
230
|
test('server events receive typed method context', () => {
|