mppx 0.8.1 → 0.8.3
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 +16 -0
- package/dist/Errors.d.ts +13 -0
- package/dist/Errors.d.ts.map +1 -1
- package/dist/Errors.js +12 -0
- package/dist/Errors.js.map +1 -1
- package/dist/cli/cli.d.ts +2 -1
- package/dist/cli/cli.d.ts.map +1 -1
- package/dist/cli/cli.js +35 -4
- package/dist/cli/cli.js.map +1 -1
- package/dist/cli/plugins/tempo.d.ts +15 -0
- package/dist/cli/plugins/tempo.d.ts.map +1 -1
- package/dist/cli/plugins/tempo.js +125 -14
- package/dist/cli/plugins/tempo.js.map +1 -1
- package/dist/cli/utils.d.ts +2 -2
- package/dist/cli/utils.d.ts.map +1 -1
- package/dist/cli/utils.js +7 -1
- package/dist/cli/utils.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 -1
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/Transport.d.ts.map +1 -1
- package/dist/client/Transport.js +11 -12
- package/dist/client/Transport.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +55 -50
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/client/internal/protocols/Mcp.d.ts +6 -0
- package/dist/client/internal/protocols/Mcp.d.ts.map +1 -1
- package/dist/client/internal/protocols/Mcp.js +28 -10
- package/dist/client/internal/protocols/Mcp.js.map +1 -1
- package/dist/client/internal/protocols/X402.d.ts.map +1 -1
- package/dist/client/internal/protocols/X402.js +18 -12
- package/dist/client/internal/protocols/X402.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/client/Charge.d.ts.map +1 -1
- package/dist/tempo/client/Charge.js +7 -1
- package/dist/tempo/client/Charge.js.map +1 -1
- package/dist/tempo/internal/auto-swap.d.ts.map +1 -1
- package/dist/tempo/internal/auto-swap.js +15 -5
- package/dist/tempo/internal/auto-swap.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +57 -1
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/internal/fee-token.js +1 -1
- package/dist/tempo/internal/fee-token.js.map +1 -1
- 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/SessionManager.d.ts.map +1 -1
- package/dist/tempo/session/client/SessionManager.js +47 -5
- package/dist/tempo/session/client/SessionManager.js.map +1 -1
- package/dist/x402/Header.d.ts +10 -1
- package/dist/x402/Header.d.ts.map +1 -1
- package/dist/x402/Header.js +27 -1
- package/dist/x402/Header.js.map +1 -1
- package/package.json +4 -4
- package/src/Errors.test.ts +28 -0
- package/src/Errors.ts +19 -0
- package/src/cli/cli.test.ts +194 -0
- package/src/cli/cli.ts +43 -7
- package/src/cli/plugins/tempo.ts +161 -17
- package/src/cli/utils.ts +14 -2
- package/src/client/Mppx.test-d.ts +9 -0
- package/src/client/Mppx.test.ts +41 -0
- package/src/client/Mppx.ts +4 -0
- package/src/client/Transport.test.ts +124 -0
- package/src/client/Transport.ts +11 -9
- package/src/client/internal/Fetch.test-d.ts +9 -0
- package/src/client/internal/Fetch.test.ts +186 -4
- package/src/client/internal/Fetch.ts +85 -76
- package/src/client/internal/protocols/Mcp.ts +39 -15
- package/src/client/internal/protocols/X402.ts +9 -5
- package/src/mcp/client/McpClient.integration.test.ts +12 -1
- package/src/server/Mppx.test.ts +3 -0
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/client/Charge.test.ts +56 -0
- package/src/tempo/client/Charge.ts +20 -2
- package/src/tempo/internal/auto-swap.test.ts +68 -1
- package/src/tempo/internal/auto-swap.ts +38 -5
- package/src/tempo/internal/fee-payer.test.ts +106 -0
- package/src/tempo/internal/fee-payer.ts +71 -1
- package/src/tempo/internal/fee-token.ts +1 -1
- package/src/tempo/server/Charge.test.ts +31 -16
- package/src/tempo/server/internal/html/package.json +2 -2
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/client/SessionManager.test.ts +57 -4
- package/src/tempo/session/client/SessionManager.ts +50 -6
- package/src/x402/Header.ts +45 -0
|
@@ -466,6 +466,53 @@ describe('Session', () => {
|
|
|
466
466
|
expect(s.channelId).not.toBe(storedChannelId)
|
|
467
467
|
})
|
|
468
468
|
|
|
469
|
+
test('rolls back an optimistic open when a replacement challenge does not reference it', async () => {
|
|
470
|
+
const { store, delete: remove } = makeChannelStore()
|
|
471
|
+
const postedPayloads: SessionCredentialPayload[] = []
|
|
472
|
+
const challenge = (feePayer: boolean) =>
|
|
473
|
+
makeChallenge({
|
|
474
|
+
methodDetails: {
|
|
475
|
+
escrowContract: tip20ChannelEscrow,
|
|
476
|
+
chainId: 4217,
|
|
477
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
478
|
+
feePayer,
|
|
479
|
+
},
|
|
480
|
+
})
|
|
481
|
+
let openCount = 0
|
|
482
|
+
const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
|
|
483
|
+
const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
|
|
484
|
+
const payload = authorization
|
|
485
|
+
? Credential.deserialize<SessionCredentialPayload>(authorization).payload
|
|
486
|
+
: undefined
|
|
487
|
+
if (payload) postedPayloads.push(payload)
|
|
488
|
+
|
|
489
|
+
if (!payload) return Promise.resolve(make402Response(challenge(true)))
|
|
490
|
+
if (payload.action === 'open') {
|
|
491
|
+
openCount++
|
|
492
|
+
if (openCount === 1) return Promise.resolve(make402Response(challenge(false)))
|
|
493
|
+
return Promise.resolve(makeOkResponse())
|
|
494
|
+
}
|
|
495
|
+
return Promise.resolve(new Response('unexpected voucher', { status: 500 }))
|
|
496
|
+
})
|
|
497
|
+
const s = sessionManager({
|
|
498
|
+
account,
|
|
499
|
+
client,
|
|
500
|
+
fetch: mockFetch as typeof globalThis.fetch,
|
|
501
|
+
maxDeposit: '10',
|
|
502
|
+
channelStore: store,
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
const response = await s.fetch('https://api.example.com/data')
|
|
506
|
+
|
|
507
|
+
expect(response.status).toBe(200)
|
|
508
|
+
// A replacement challenge with no snapshot means the server did not
|
|
509
|
+
// acknowledge the optimistic open, so the next credential must open again.
|
|
510
|
+
expect(postedPayloads.map((payload) => payload.action)).toEqual(['open', 'open'])
|
|
511
|
+
expect(postedPayloads[0]?.channelId).not.toBe(postedPayloads[1]?.channelId)
|
|
512
|
+
expect(remove).toHaveBeenCalledOnce()
|
|
513
|
+
expect(s.channelId).toBe(postedPayloads[1]?.channelId)
|
|
514
|
+
})
|
|
515
|
+
|
|
469
516
|
test('does not bootstrap when disabled', async () => {
|
|
470
517
|
const mockFetch = vi.fn().mockResolvedValue(makeOkResponse())
|
|
471
518
|
const s = sessionManager({
|
|
@@ -686,6 +733,7 @@ describe('Session', () => {
|
|
|
686
733
|
methodDetails: {
|
|
687
734
|
escrowContract: tip20ChannelEscrow,
|
|
688
735
|
chainId: 4217,
|
|
736
|
+
sessionProtocol: Constants.SessionProtocols.v2,
|
|
689
737
|
sessionSnapshot: {
|
|
690
738
|
acceptedCumulative: '2000000',
|
|
691
739
|
chainId: 4217,
|
|
@@ -887,8 +935,7 @@ describe('Session', () => {
|
|
|
887
935
|
}
|
|
888
936
|
if (callCount === 1)
|
|
889
937
|
return Promise.resolve(make402Response(makeChallenge({ suggestedDeposit: '3000000' })))
|
|
890
|
-
if (callCount
|
|
891
|
-
if (callCount === 4) return Promise.resolve(make402Response())
|
|
938
|
+
if (callCount >= 3 && callCount <= 6) return Promise.resolve(make402Response())
|
|
892
939
|
return Promise.resolve(makeOkResponse())
|
|
893
940
|
})
|
|
894
941
|
|
|
@@ -907,8 +954,14 @@ describe('Session', () => {
|
|
|
907
954
|
expect(s.cumulative).toBe(1000000n)
|
|
908
955
|
|
|
909
956
|
await s.topUp('1')
|
|
910
|
-
expect(postedPayloads.map((payload) => payload.action)).toEqual([
|
|
911
|
-
|
|
957
|
+
expect(postedPayloads.map((payload) => payload.action)).toEqual([
|
|
958
|
+
'open',
|
|
959
|
+
'voucher',
|
|
960
|
+
'voucher',
|
|
961
|
+
'voucher',
|
|
962
|
+
'topUp',
|
|
963
|
+
])
|
|
964
|
+
const topUpPayload = postedPayloads[4]
|
|
912
965
|
if (topUpPayload?.action !== 'topUp') throw new Error('expected top-up payload')
|
|
913
966
|
expect(topUpPayload.additionalDeposit).toBe('1000000')
|
|
914
967
|
})
|
|
@@ -201,9 +201,12 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
201
201
|
|
|
202
202
|
// Tracks one fetch's channel reuse so stale stored entries can be evicted once.
|
|
203
203
|
type ChannelUse = {
|
|
204
|
+
challengesReceived: number
|
|
205
|
+
created: Map<string, ChannelEntry>
|
|
204
206
|
seenExisting: Set<string>
|
|
205
|
-
|
|
207
|
+
previous: RuntimeSnapshot
|
|
206
208
|
resumed: ChannelEntry | undefined
|
|
209
|
+
trackCreates: boolean
|
|
207
210
|
}
|
|
208
211
|
let channelUse: ChannelUse | undefined
|
|
209
212
|
|
|
@@ -219,14 +222,15 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
219
222
|
const entry = await getReusable(key)
|
|
220
223
|
if (entry && channelUse) {
|
|
221
224
|
channelUse.seenExisting.add(key)
|
|
222
|
-
if (!channelUse.
|
|
225
|
+
if (!channelUse.created.has(key)) channelUse.resumed ??= entry
|
|
223
226
|
}
|
|
224
227
|
return entry
|
|
225
228
|
},
|
|
226
229
|
async set(entry) {
|
|
227
230
|
const key = entryKey(entry)
|
|
228
231
|
if (entry.opened) ignoredChannelIds.delete(entry.channelId)
|
|
229
|
-
if (channelUse && !channelUse.seenExisting.has(key))
|
|
232
|
+
if (channelUse?.trackCreates && !channelUse.seenExisting.has(key))
|
|
233
|
+
channelUse.created.set(key, entry)
|
|
230
234
|
await backing.set(entry)
|
|
231
235
|
},
|
|
232
236
|
delete: (key) => backing.delete(key),
|
|
@@ -238,6 +242,31 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
238
242
|
await Promise.resolve(backing.delete(entryKey(entry))).catch(() => undefined)
|
|
239
243
|
}
|
|
240
244
|
|
|
245
|
+
async function rollbackCreatedChannelsForRetry() {
|
|
246
|
+
const use = channelUse
|
|
247
|
+
if (!use?.created.size) return
|
|
248
|
+
for (const [key, entry] of use.created) {
|
|
249
|
+
ignoredChannelIds.add(entry.channelId)
|
|
250
|
+
await Promise.resolve(backing.delete(key)).catch(() => undefined)
|
|
251
|
+
}
|
|
252
|
+
use.created.clear()
|
|
253
|
+
restoreRuntime(use.previous)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function referencesCreatedChannel(challenge: Challenge.Challenge): boolean {
|
|
257
|
+
const snapshot = Constants.getMethodDetail<{ channelId?: unknown }>(
|
|
258
|
+
challenge.request.methodDetails,
|
|
259
|
+
Constants.MethodDetailKeys.sessionSnapshot,
|
|
260
|
+
)
|
|
261
|
+
if (typeof snapshot?.channelId !== 'string') return false
|
|
262
|
+
const use = channelUse
|
|
263
|
+
if (!use?.created.size) return false
|
|
264
|
+
for (const entry of use.created.values()) {
|
|
265
|
+
if (entry.channelId.toLowerCase() === snapshot.channelId.toLowerCase()) return true
|
|
266
|
+
}
|
|
267
|
+
return false
|
|
268
|
+
}
|
|
269
|
+
|
|
241
270
|
function dispatch(event: Parameters<typeof dispatchSessionEvent>[1]) {
|
|
242
271
|
return dispatchSessionEvent(runtime, event)
|
|
243
272
|
}
|
|
@@ -273,6 +302,14 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
273
302
|
methods: [method],
|
|
274
303
|
onChallenge: async (challenge, _helpers) => {
|
|
275
304
|
if (!isTempoSessionChallenge(challenge)) return undefined
|
|
305
|
+
const use = channelUse
|
|
306
|
+
const isRepeatedRetryChallenge =
|
|
307
|
+
use && use.challengesReceived > 0 && challenge.id === runtime.lastChallenge?.id
|
|
308
|
+
if (!referencesCreatedChannel(challenge)) {
|
|
309
|
+
if (use?.created.size) await rollbackCreatedChannelsForRetry()
|
|
310
|
+
else if (isRepeatedRetryChallenge) restoreRuntime(use.previous)
|
|
311
|
+
}
|
|
312
|
+
if (use) use.challengesReceived++
|
|
276
313
|
runtime.lastChallenge = challenge
|
|
277
314
|
dispatch({ type: 'challengeReceived', challengeId: challenge.id })
|
|
278
315
|
if (runtime.channel?.opened && runtime.lastUrl) {
|
|
@@ -498,9 +535,6 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
498
535
|
throw new Error(
|
|
499
536
|
'SessionManager: a request is already in flight; concurrent requests on one manager are not supported',
|
|
500
537
|
)
|
|
501
|
-
const use: ChannelUse = { seenExisting: new Set(), createdKeys: new Set(), resumed: undefined }
|
|
502
|
-
channelUse = use
|
|
503
|
-
|
|
504
538
|
runtime.lastUrl = input
|
|
505
539
|
|
|
506
540
|
const previous = captureRuntimeStateSnapshot({
|
|
@@ -508,12 +542,22 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
|
|
|
508
542
|
spent: runtime.spent,
|
|
509
543
|
state: runtime.state,
|
|
510
544
|
})
|
|
545
|
+
const use: ChannelUse = {
|
|
546
|
+
challengesReceived: 0,
|
|
547
|
+
created: new Map(),
|
|
548
|
+
previous,
|
|
549
|
+
seenExisting: new Set(),
|
|
550
|
+
resumed: undefined,
|
|
551
|
+
trackCreates: false,
|
|
552
|
+
}
|
|
553
|
+
channelUse = use
|
|
511
554
|
|
|
512
555
|
// Cold starts resume from `channelStore` after the 402 reveals the scope.
|
|
513
556
|
const liveHint = runtime.channel?.opened ? runtime.channel.channelId : undefined
|
|
514
557
|
|
|
515
558
|
try {
|
|
516
559
|
await bootstrapSession(input, init)
|
|
560
|
+
use.trackCreates = true
|
|
517
561
|
|
|
518
562
|
let effectiveInit = requestInitWithSessionHint(input, init, liveHint)
|
|
519
563
|
// Stored channels may be stale, so retry once after evicting the resumed entry.
|
package/src/x402/Header.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
+
import { Base64 } from 'ox'
|
|
2
|
+
|
|
1
3
|
import * as HeaderCodec from '../internal/HeaderCodec.js'
|
|
2
4
|
import {
|
|
5
|
+
ExtensionsSchema,
|
|
3
6
|
PaymentPayloadSchema,
|
|
4
7
|
PaymentRequiredSchema,
|
|
8
|
+
ResourceInfoSchema,
|
|
5
9
|
SettleResponseSchema,
|
|
10
|
+
type Extensions,
|
|
6
11
|
type PaymentPayload,
|
|
7
12
|
type PaymentRequired,
|
|
13
|
+
type ResourceInfo,
|
|
8
14
|
type SettleResponse,
|
|
15
|
+
type Version,
|
|
9
16
|
} from './Types.js'
|
|
10
17
|
|
|
11
18
|
const paymentRequired = HeaderCodec.createJson(PaymentRequiredSchema)
|
|
@@ -19,6 +26,24 @@ export const encodePaymentRequired: (paymentRequired: PaymentRequired) => string
|
|
|
19
26
|
/** Decodes an x402 `PAYMENT-REQUIRED` header value. */
|
|
20
27
|
export const decodePaymentRequired: (value: string) => PaymentRequired = paymentRequired.decode
|
|
21
28
|
|
|
29
|
+
/** Tolerant x402 `PAYMENT-REQUIRED` envelope used before filtering supported accepts. @internal */
|
|
30
|
+
export type PaymentRequiredEnvelope = {
|
|
31
|
+
accepts: unknown[]
|
|
32
|
+
extensions?: Extensions | undefined
|
|
33
|
+
resource: ResourceInfo
|
|
34
|
+
x402Version: Version
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Decodes only the x402 `PAYMENT-REQUIRED` envelope, leaving accepts unvalidated. @internal */
|
|
38
|
+
export function decodePaymentRequiredEnvelope(value: string): PaymentRequiredEnvelope {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(Base64.toString(value)) as unknown
|
|
41
|
+
return parsePaymentRequiredEnvelope(parsed)
|
|
42
|
+
} catch {
|
|
43
|
+
throw new HeaderCodec.InvalidJsonHeaderError()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
/** Encodes an x402 payment payload for the `PAYMENT-SIGNATURE` header. */
|
|
23
48
|
export const encodePaymentSignature: (paymentPayload: PaymentPayload) => string =
|
|
24
49
|
paymentSignature.encode
|
|
@@ -32,3 +57,23 @@ export const encodePaymentResponse: (paymentResponse: SettleResponse) => string
|
|
|
32
57
|
|
|
33
58
|
/** Decodes an x402 `PAYMENT-RESPONSE` header value. */
|
|
34
59
|
export const decodePaymentResponse: (value: string) => SettleResponse = paymentResponse.decode
|
|
60
|
+
|
|
61
|
+
const parsePaymentRequiredEnvelope = (value: unknown): PaymentRequiredEnvelope => {
|
|
62
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
63
|
+
throw new HeaderCodec.InvalidJsonHeaderError()
|
|
64
|
+
|
|
65
|
+
const record = value as Record<string, unknown>
|
|
66
|
+
if (record.x402Version !== 2 || !Array.isArray(record.accepts))
|
|
67
|
+
throw new HeaderCodec.InvalidJsonHeaderError()
|
|
68
|
+
|
|
69
|
+
const resource = ResourceInfoSchema.parse(record.resource)
|
|
70
|
+
const extensions =
|
|
71
|
+
record.extensions === undefined ? undefined : ExtensionsSchema.parse(record.extensions)
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
accepts: record.accepts,
|
|
75
|
+
...(extensions ? { extensions } : {}),
|
|
76
|
+
resource,
|
|
77
|
+
x402Version: 2,
|
|
78
|
+
}
|
|
79
|
+
}
|