mppx 0.5.17 → 0.6.1
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 +22 -0
- package/dist/Method.d.ts +2 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.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 +4 -1
- package/dist/client/Mppx.js.map +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -0
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal/Fetch.d.ts +4 -0
- package/dist/client/internal/Fetch.d.ts.map +1 -1
- package/dist/client/internal/Fetch.js +43 -5
- package/dist/client/internal/Fetch.js.map +1 -1
- package/dist/server/Mppx.d.ts +45 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +139 -16
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/Charge.d.ts.map +1 -1
- package/dist/stripe/server/Charge.js +8 -1
- package/dist/stripe/server/Charge.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +15 -4
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +39 -38
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js +14 -24
- package/dist/tempo/server/Session.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/server/internal/request-body.d.ts +8 -0
- package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
- package/dist/tempo/server/internal/request-body.js +27 -0
- package/dist/tempo/server/internal/request-body.js.map +1 -0
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +6 -17
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/package.json +1 -1
- package/src/Method.ts +2 -0
- package/src/cli/cli.test.ts +15 -7
- package/src/client/Mppx.ts +11 -2
- package/src/client/index.ts +1 -0
- package/src/client/internal/Fetch.browser.test.ts +58 -0
- package/src/client/internal/Fetch.test.ts +173 -0
- package/src/client/internal/Fetch.ts +62 -3
- package/src/server/Mppx.test-d.ts +36 -0
- package/src/server/Mppx.test.ts +1073 -1
- package/src/server/Mppx.ts +241 -22
- package/src/server/Transport.test.ts +2 -1
- package/src/stripe/server/Charge.ts +7 -1
- package/src/tempo/server/Charge.ts +15 -4
- package/src/tempo/server/Session.test.ts +68 -0
- package/src/tempo/server/Session.ts +15 -35
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/request-body.test.ts +142 -0
- package/src/tempo/server/internal/request-body.ts +37 -0
- package/src/tempo/server/internal/transport.test.ts +126 -2
- package/src/tempo/server/internal/transport.ts +7 -19
package/src/server/Mppx.ts
CHANGED
|
@@ -70,7 +70,36 @@ export type Mppx<
|
|
|
70
70
|
): (input: Request) => Promise<MethodFn.Response<Transport.Http>>
|
|
71
71
|
}
|
|
72
72
|
: {}) &
|
|
73
|
-
Handlers<FlattenMethods<methods>, transport>
|
|
73
|
+
Handlers<FlattenMethods<methods>, transport> & {
|
|
74
|
+
/**
|
|
75
|
+
* Generate Challenge objects for registered methods without going through
|
|
76
|
+
* the HTTP 402 request lifecycle. Uses the same options, defaults, and
|
|
77
|
+
* schema transforms as the corresponding intent handler.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* const challenge = await mppx.challenge.tempo.charge({ amount: '25.92' })
|
|
82
|
+
* ```
|
|
83
|
+
*/
|
|
84
|
+
challenge: ChallengeHandlers<FlattenMethods<methods>>
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Verify a credential string or object end-to-end: deserialize,
|
|
88
|
+
* HMAC-check, match to a registered method, validate payload schema,
|
|
89
|
+
* check expiry, and call the method's verify function.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```ts
|
|
93
|
+
* const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
|
|
94
|
+
* const receipt = await mppx.verifyCredential(credential)
|
|
95
|
+
* const receipt = await mppx.verifyCredential(credential, { request: { amount: '1000' } })
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
verifyCredential(
|
|
99
|
+
credential: string | Credential.Credential,
|
|
100
|
+
options?: VerifyCredentialOptions | undefined,
|
|
101
|
+
): Promise<Receipt.Receipt>
|
|
102
|
+
}
|
|
74
103
|
|
|
75
104
|
/** Extracts the transport override from a method, if any. */
|
|
76
105
|
type TransportOverrideOf<mi> = mi extends { transport?: infer transport }
|
|
@@ -136,6 +165,28 @@ type Handlers<
|
|
|
136
165
|
} & UniqueIntentHandlers<methods, transport> &
|
|
137
166
|
NestedHandlers<methods, transport>
|
|
138
167
|
|
|
168
|
+
/** Nested challenge generators: `mppx.challenge.tempo.charge(...)`. */
|
|
169
|
+
type ChallengeHandlers<methods extends readonly Method.AnyServer[]> = {
|
|
170
|
+
[name in methods[number]['name']]: {
|
|
171
|
+
[mi in Extract<methods[number], { name: name }> as mi['intent']]: ChallengeFn<
|
|
172
|
+
mi,
|
|
173
|
+
NonNullable<mi['defaults']>
|
|
174
|
+
>
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A function that generates a Challenge object from intent options. */
|
|
179
|
+
type ChallengeFn<method extends Method.Method, defaults extends Record<string, unknown>> = (
|
|
180
|
+
options: MethodFn.Options<method, defaults>,
|
|
181
|
+
) => Promise<Challenge.Challenge>
|
|
182
|
+
|
|
183
|
+
export type VerifyCredentialOptions = {
|
|
184
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
185
|
+
meta?: Record<string, string> | undefined
|
|
186
|
+
realm?: string | undefined
|
|
187
|
+
request?: Record<string, unknown> | undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
139
190
|
/**
|
|
140
191
|
* Creates a server-side payment handler from methods.
|
|
141
192
|
*
|
|
@@ -200,6 +251,92 @@ export function create<
|
|
|
200
251
|
;(handlers[mi.name] as Record<string, unknown>)[mi.intent] = fn
|
|
201
252
|
}
|
|
202
253
|
|
|
254
|
+
// Build challenge generators: mppx.challenge.tempo.charge(...)
|
|
255
|
+
const challengeHandlers: Record<string, Record<string, unknown>> = {}
|
|
256
|
+
for (const mi of methods) {
|
|
257
|
+
if (!challengeHandlers[mi.name]) challengeHandlers[mi.name] = {}
|
|
258
|
+
challengeHandlers[mi.name]![mi.intent] = createChallengeFn({
|
|
259
|
+
defaults: mi.defaults,
|
|
260
|
+
method: mi,
|
|
261
|
+
realm,
|
|
262
|
+
request: mi.request as never,
|
|
263
|
+
secretKey,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// verifyCredential: single-call end-to-end verification
|
|
268
|
+
async function verifyCredentialFn(
|
|
269
|
+
input: string | Credential.Credential,
|
|
270
|
+
options?: VerifyCredentialOptions,
|
|
271
|
+
): Promise<Receipt.Receipt> {
|
|
272
|
+
const credential = typeof input === 'string' ? Credential.deserialize(input) : input
|
|
273
|
+
|
|
274
|
+
// HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
|
|
275
|
+
if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
|
|
276
|
+
throw new Errors.InvalidChallengeError({
|
|
277
|
+
id: credential.challenge.id,
|
|
278
|
+
reason: 'challenge was not issued by this server',
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// Expiry check
|
|
282
|
+
Expires.assert(credential.challenge.expires, credential.challenge.id)
|
|
283
|
+
|
|
284
|
+
// Find matching method by name + intent
|
|
285
|
+
const { method: credMethod, intent: credIntent } = credential.challenge
|
|
286
|
+
const mi = (methods as readonly Method.AnyServer[]).find(
|
|
287
|
+
(m) => m.name === credMethod && m.intent === credIntent,
|
|
288
|
+
)
|
|
289
|
+
if (!mi)
|
|
290
|
+
throw new Errors.InvalidChallengeError({
|
|
291
|
+
id: credential.challenge.id,
|
|
292
|
+
reason: `no registered method for ${credMethod}/${credIntent}`,
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
// Validate payload against method schema
|
|
296
|
+
mi.schema.credential.payload.parse(credential.payload)
|
|
297
|
+
|
|
298
|
+
const shouldValidateRoute =
|
|
299
|
+
options?.capturedRequest !== undefined ||
|
|
300
|
+
options?.meta !== undefined ||
|
|
301
|
+
options?.realm !== undefined ||
|
|
302
|
+
options?.request !== undefined
|
|
303
|
+
|
|
304
|
+
const request = shouldValidateRoute
|
|
305
|
+
? await resolveRouteChallenge({
|
|
306
|
+
capturedRequest: options?.capturedRequest,
|
|
307
|
+
credential,
|
|
308
|
+
defaults: mi.defaults,
|
|
309
|
+
expires: credential.challenge.expires,
|
|
310
|
+
meta: options?.meta,
|
|
311
|
+
method: mi,
|
|
312
|
+
realm: options?.realm ?? realm,
|
|
313
|
+
request: mi.request as never,
|
|
314
|
+
routeRequest: options?.request ?? {},
|
|
315
|
+
secretKey: secretKey!,
|
|
316
|
+
}).then((resolved) => {
|
|
317
|
+
const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge)
|
|
318
|
+
if (mismatch)
|
|
319
|
+
throw new Errors.InvalidChallengeError({
|
|
320
|
+
id: credential.challenge.id,
|
|
321
|
+
reason: `credential ${mismatch} does not match this route's requirements`,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
return resolved.request as z.input<typeof mi.schema.request>
|
|
325
|
+
})
|
|
326
|
+
: (credential.challenge.request as z.input<typeof mi.schema.request>)
|
|
327
|
+
|
|
328
|
+
const envelope = options?.capturedRequest
|
|
329
|
+
? ({
|
|
330
|
+
capturedRequest: options.capturedRequest,
|
|
331
|
+
challenge: credential.challenge,
|
|
332
|
+
credential,
|
|
333
|
+
request,
|
|
334
|
+
} as Method.VerifiedChallengeEnvelope)
|
|
335
|
+
: undefined
|
|
336
|
+
|
|
337
|
+
return mi.verify({ credential, envelope, request } as never)
|
|
338
|
+
}
|
|
339
|
+
|
|
203
340
|
function composeFn(
|
|
204
341
|
...entries: readonly [
|
|
205
342
|
Method.AnyServer | AnyMethodFnWithMethod | string,
|
|
@@ -225,9 +362,11 @@ export function create<
|
|
|
225
362
|
|
|
226
363
|
return {
|
|
227
364
|
methods,
|
|
365
|
+
challenge: challengeHandlers,
|
|
228
366
|
compose: composeFn,
|
|
229
367
|
realm: realm as string | undefined,
|
|
230
368
|
transport,
|
|
369
|
+
verifyCredential: verifyCredentialFn,
|
|
231
370
|
...handlers,
|
|
232
371
|
} as never
|
|
233
372
|
}
|
|
@@ -261,7 +400,6 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
261
400
|
|
|
262
401
|
return (options) => {
|
|
263
402
|
const { description, meta, ...rest } = options
|
|
264
|
-
const merged = { ...defaults, ...rest }
|
|
265
403
|
|
|
266
404
|
return Object.assign(
|
|
267
405
|
async (input: Transport.InputOf): Promise<MethodFn.Response> => {
|
|
@@ -280,26 +418,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
280
418
|
return [null, e as Error] as const
|
|
281
419
|
}
|
|
282
420
|
})()
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
? await parameters.request({ capturedRequest, credential, request: merged } as never)
|
|
288
|
-
: merged
|
|
289
|
-
) as never
|
|
290
|
-
|
|
291
|
-
// Resolve realm: explicit > env var > request Host header.
|
|
292
|
-
const effectiveRealm = realm ?? resolveRealmFromCapturedRequest(capturedRequest)
|
|
293
|
-
|
|
294
|
-
// Recompute challenge from options. The HMAC-bound ID means we don't need to
|
|
295
|
-
// store challenges server-side—if the client echoes back a credential with
|
|
296
|
-
// a matching ID, we know it was issued by us with these exact parameters.
|
|
297
|
-
const challenge = Challenge.fromMethod(method, {
|
|
421
|
+
const { challenge, request } = await resolveRouteChallenge({
|
|
422
|
+
capturedRequest,
|
|
423
|
+
credential,
|
|
424
|
+
defaults,
|
|
298
425
|
description,
|
|
299
426
|
expires,
|
|
300
427
|
meta,
|
|
301
|
-
|
|
302
|
-
|
|
428
|
+
method,
|
|
429
|
+
realm,
|
|
430
|
+
request: parameters.request,
|
|
431
|
+
routeRequest: rest,
|
|
303
432
|
secretKey,
|
|
304
433
|
})
|
|
305
434
|
|
|
@@ -415,6 +544,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
415
544
|
capturedRequest,
|
|
416
545
|
challenge: credential.challenge,
|
|
417
546
|
credential,
|
|
547
|
+
request,
|
|
418
548
|
})
|
|
419
549
|
|
|
420
550
|
// User-provided verification (e.g., check signature, submit tx, verify payment).
|
|
@@ -475,13 +605,51 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
|
|
|
475
605
|
...options,
|
|
476
606
|
name: method.name,
|
|
477
607
|
intent: method.intent,
|
|
478
|
-
_canonicalRequest: PaymentRequest.fromMethod(method,
|
|
608
|
+
_canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
|
|
479
609
|
},
|
|
480
610
|
},
|
|
481
611
|
)
|
|
482
612
|
}
|
|
483
613
|
}
|
|
484
614
|
|
|
615
|
+
/**
|
|
616
|
+
* Creates a challenge generator for a single method+intent.
|
|
617
|
+
* Applies the same defaults and request transform as createMethodFn,
|
|
618
|
+
* but returns a Challenge object directly instead of a request handler.
|
|
619
|
+
*/
|
|
620
|
+
function createChallengeFn(parameters: {
|
|
621
|
+
defaults?: Record<string, unknown>
|
|
622
|
+
method: Method.Method
|
|
623
|
+
realm: string | undefined
|
|
624
|
+
request?: Method.RequestFn<Method.Method>
|
|
625
|
+
secretKey: string
|
|
626
|
+
}): (options: Record<string, unknown>) => Promise<Challenge.Challenge> {
|
|
627
|
+
const { defaults, method, realm, secretKey } = parameters
|
|
628
|
+
|
|
629
|
+
return async (options) => {
|
|
630
|
+
const { description, meta, ...rest } = options as {
|
|
631
|
+
description?: string
|
|
632
|
+
expires?: string
|
|
633
|
+
meta?: Record<string, string>
|
|
634
|
+
[key: string]: unknown
|
|
635
|
+
}
|
|
636
|
+
const expires =
|
|
637
|
+
'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
|
|
638
|
+
|
|
639
|
+
return resolveRouteChallenge({
|
|
640
|
+
defaults,
|
|
641
|
+
description,
|
|
642
|
+
expires,
|
|
643
|
+
meta,
|
|
644
|
+
method,
|
|
645
|
+
realm,
|
|
646
|
+
request: parameters.request,
|
|
647
|
+
routeRequest: rest,
|
|
648
|
+
secretKey,
|
|
649
|
+
}).then((resolved) => resolved.challenge)
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
485
653
|
function getSafeCredentialReason(error: unknown): string | undefined {
|
|
486
654
|
if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
|
|
487
655
|
if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
|
|
@@ -537,6 +705,55 @@ function resolveRealmFromCapturedRequest(capturedRequest: Method.CapturedRequest
|
|
|
537
705
|
return defaultRealm
|
|
538
706
|
}
|
|
539
707
|
|
|
708
|
+
async function resolveRouteChallenge(parameters: {
|
|
709
|
+
capturedRequest?: Method.CapturedRequest | undefined
|
|
710
|
+
credential?: Credential.Credential | null | undefined
|
|
711
|
+
defaults?: Record<string, unknown> | undefined
|
|
712
|
+
description?: string | undefined
|
|
713
|
+
expires?: string | undefined
|
|
714
|
+
meta?: Record<string, string> | undefined
|
|
715
|
+
method: Method.Method
|
|
716
|
+
realm?: string | undefined
|
|
717
|
+
request?: Method.RequestFn<Method.Method> | undefined
|
|
718
|
+
routeRequest: Record<string, unknown>
|
|
719
|
+
secretKey: string
|
|
720
|
+
}): Promise<{
|
|
721
|
+
challenge: Challenge.Challenge
|
|
722
|
+
request: Record<string, unknown>
|
|
723
|
+
}> {
|
|
724
|
+
// Resolve the route's canonical request exactly as the handler path does:
|
|
725
|
+
const request = await (async () => {
|
|
726
|
+
// start from defaults + route options, then let the method request hook
|
|
727
|
+
const merged = { ...parameters.defaults, ...parameters.routeRequest }
|
|
728
|
+
// normalize or enrich it using the captured request and credential.
|
|
729
|
+
return parameters.request
|
|
730
|
+
? ((await parameters.request({
|
|
731
|
+
capturedRequest: parameters.capturedRequest,
|
|
732
|
+
credential: parameters.credential,
|
|
733
|
+
request: merged,
|
|
734
|
+
} as never)) as Record<string, unknown>)
|
|
735
|
+
: merged
|
|
736
|
+
})()
|
|
737
|
+
|
|
738
|
+
const effectiveRealm =
|
|
739
|
+
parameters.realm ??
|
|
740
|
+
(parameters.capturedRequest
|
|
741
|
+
? resolveRealmFromCapturedRequest(parameters.capturedRequest)
|
|
742
|
+
: defaultRealm)
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
challenge: Challenge.fromMethod(parameters.method, {
|
|
746
|
+
description: parameters.description,
|
|
747
|
+
expires: parameters.expires,
|
|
748
|
+
meta: parameters.meta,
|
|
749
|
+
realm: effectiveRealm,
|
|
750
|
+
request: request as never,
|
|
751
|
+
secretKey: parameters.secretKey,
|
|
752
|
+
}),
|
|
753
|
+
request,
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
540
757
|
/**
|
|
541
758
|
* Captures the transport request into a frozen snapshot at the start of the
|
|
542
759
|
* verification flow. This snapshot is threaded through request() → verify() →
|
|
@@ -568,15 +785,15 @@ function captureRequestFromInput(input: unknown): Method.CapturedRequest {
|
|
|
568
785
|
}
|
|
569
786
|
|
|
570
787
|
return {
|
|
571
|
-
hasBody: source.body !== undefined && source.body !== null,
|
|
572
788
|
headers: new Headers(source.headers),
|
|
789
|
+
hasBody: source.body === undefined ? undefined : source.body !== null,
|
|
573
790
|
method: source.method ?? 'POST',
|
|
574
791
|
url: Transport.safeUrl(source.url),
|
|
575
792
|
}
|
|
576
793
|
}
|
|
577
794
|
|
|
578
795
|
const coreBindingFields = ['amount', 'currency', 'recipient'] as const
|
|
579
|
-
const methodBindingFields = ['chainId', 'memo', 'splits'] as const
|
|
796
|
+
const methodBindingFields = ['chainId', 'memo', 'splits', 'unitType'] as const
|
|
580
797
|
const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields] as const
|
|
581
798
|
|
|
582
799
|
type CoreBindingField = (typeof coreBindingFields)[number]
|
|
@@ -646,6 +863,7 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
|
|
|
646
863
|
const memo = normalizeHex(methodDetails.memo)
|
|
647
864
|
const recipient = normalizeScalar(request.recipient ?? methodDetails.recipient)
|
|
648
865
|
const splits = normalizeComparable(methodDetails.splits)
|
|
866
|
+
const unitType = normalizeScalar(request.unitType ?? methodDetails.unitType)
|
|
649
867
|
|
|
650
868
|
return {
|
|
651
869
|
coreBinding: {
|
|
@@ -657,6 +875,7 @@ function getPinnedRequestBinding(request: Record<string, unknown>): PinnedReques
|
|
|
657
875
|
...(chainId !== undefined ? { chainId } : {}),
|
|
658
876
|
...(memo !== undefined ? { memo } : {}),
|
|
659
877
|
...(splits !== undefined ? { splits } : {}),
|
|
878
|
+
...(unitType !== undefined ? { unitType } : {}),
|
|
660
879
|
},
|
|
661
880
|
}
|
|
662
881
|
}
|
|
@@ -38,13 +38,14 @@ describe('http', () => {
|
|
|
38
38
|
const transport = Transport.http()
|
|
39
39
|
const request = new Request('https://example.com/resource?foo=bar', {
|
|
40
40
|
method: 'POST',
|
|
41
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
41
42
|
headers: { Authorization: Credential.serialize(credential), 'X-Test': '1' },
|
|
42
43
|
})
|
|
43
44
|
|
|
44
45
|
const captured = await transport.captureRequest?.(request)
|
|
45
46
|
expect(captured).toEqual({
|
|
46
|
-
hasBody: false,
|
|
47
47
|
headers: new Headers(request.headers),
|
|
48
|
+
hasBody: true,
|
|
48
49
|
method: 'POST',
|
|
49
50
|
url: new URL('https://example.com/resource?foo=bar'),
|
|
50
51
|
})
|
|
@@ -94,7 +94,13 @@ export function charge<const parameters extends charge.Parameters>(parameters: p
|
|
|
94
94
|
|
|
95
95
|
async verify({ credential, request }) {
|
|
96
96
|
const { challenge } = credential
|
|
97
|
-
const resolvedRequest =
|
|
97
|
+
const resolvedRequest = (() => {
|
|
98
|
+
const parsed = Methods.charge.schema.request.safeParse(request)
|
|
99
|
+
if (parsed.success) return parsed.data
|
|
100
|
+
// verifyCredential() passes the HMAC-bound challenge request, which is
|
|
101
|
+
// already in canonical output form and should not be transformed again.
|
|
102
|
+
return request as unknown as z.output<typeof Methods.charge.schema.request>
|
|
103
|
+
})()
|
|
98
104
|
|
|
99
105
|
Expires.assert(challenge.expires, challenge.id)
|
|
100
106
|
|
|
@@ -155,13 +155,24 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
155
155
|
|
|
156
156
|
async verify({ credential, request }) {
|
|
157
157
|
const { challenge } = credential
|
|
158
|
-
const resolvedRequest =
|
|
158
|
+
const resolvedRequest = (() => {
|
|
159
|
+
const parsed = Methods.charge.schema.request.safeParse(request)
|
|
160
|
+
if (parsed.success) return parsed.data
|
|
161
|
+
// verifyCredential() passes the HMAC-bound challenge request, which is
|
|
162
|
+
// already in canonical output form and should not be transformed again.
|
|
163
|
+
return request as unknown as z.output<typeof Methods.charge.schema.request>
|
|
164
|
+
})()
|
|
159
165
|
const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId
|
|
160
|
-
const feePayer = typeof request.feePayer === 'object' ? request.feePayer : undefined
|
|
161
166
|
|
|
162
167
|
const client = await getClient({ chainId })
|
|
163
168
|
|
|
164
169
|
const { amount, methodDetails } = resolvedRequest
|
|
170
|
+
const feePayerAccount =
|
|
171
|
+
typeof request.feePayer === 'object'
|
|
172
|
+
? request.feePayer
|
|
173
|
+
: methodDetails?.feePayer === true
|
|
174
|
+
? feePayer
|
|
175
|
+
: undefined
|
|
165
176
|
const expires = challenge.expires
|
|
166
177
|
const supportedModes = methodDetails?.supportedModes as
|
|
167
178
|
| readonly Methods.ChargeMode[]
|
|
@@ -307,9 +318,9 @@ export function charge<const parameters extends charge.Parameters>(
|
|
|
307
318
|
const resolvedFeeToken = transaction.feeToken ?? expectedFeeToken
|
|
308
319
|
|
|
309
320
|
const serializedTransaction_final = await (async () => {
|
|
310
|
-
if (
|
|
321
|
+
if (feePayerAccount && methodDetails?.feePayer !== false) {
|
|
311
322
|
const sponsored = FeePayer.prepareSponsoredTransaction({
|
|
312
|
-
account:
|
|
323
|
+
account: feePayerAccount,
|
|
313
324
|
challengeExpires: expires,
|
|
314
325
|
chainId: chainId ?? client.chain!.id,
|
|
315
326
|
details: { amount, currency, recipient },
|
|
@@ -1401,6 +1401,32 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
1401
1401
|
).rejects.toThrow('close voucher amount must be >')
|
|
1402
1402
|
})
|
|
1403
1403
|
|
|
1404
|
+
test('allows zero close for an untouched channel', async () => {
|
|
1405
|
+
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1406
|
+
const server = createServer()
|
|
1407
|
+
|
|
1408
|
+
await openServerChannel(server, channelId, serializedTransaction)
|
|
1409
|
+
|
|
1410
|
+
const receipt = await server.verify({
|
|
1411
|
+
credential: {
|
|
1412
|
+
challenge: makeChallenge({ id: 'challenge-zero-close', channelId }),
|
|
1413
|
+
payload: {
|
|
1414
|
+
action: 'close' as const,
|
|
1415
|
+
channelId,
|
|
1416
|
+
cumulativeAmount: '0',
|
|
1417
|
+
signature: await signTestVoucher(channelId, 0n),
|
|
1418
|
+
},
|
|
1419
|
+
},
|
|
1420
|
+
request: makeRequest(),
|
|
1421
|
+
})
|
|
1422
|
+
|
|
1423
|
+
expect(receipt.status).toBe('success')
|
|
1424
|
+
|
|
1425
|
+
const ch = await store.getChannel(channelId)
|
|
1426
|
+
expect(ch).not.toBeNull()
|
|
1427
|
+
expect(ch!.finalized).toBe(true)
|
|
1428
|
+
})
|
|
1429
|
+
|
|
1404
1430
|
test('rejects close exceeding on-chain deposit', async () => {
|
|
1405
1431
|
const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n)
|
|
1406
1432
|
const server = createServer()
|
|
@@ -3380,6 +3406,27 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3380
3406
|
expect(result).toBeUndefined()
|
|
3381
3407
|
})
|
|
3382
3408
|
|
|
3409
|
+
test('returns undefined for open POST with a body stream and no framing headers', () => {
|
|
3410
|
+
const server = createServer()
|
|
3411
|
+
const input = new Request('http://localhost', {
|
|
3412
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
3413
|
+
method: 'POST',
|
|
3414
|
+
})
|
|
3415
|
+
|
|
3416
|
+
expect(input.headers.get('content-length')).toBeNull()
|
|
3417
|
+
|
|
3418
|
+
const result = server.respond!({
|
|
3419
|
+
credential: {
|
|
3420
|
+
challenge: makeChallenge({
|
|
3421
|
+
channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
|
|
3422
|
+
}),
|
|
3423
|
+
payload: { action: 'open' },
|
|
3424
|
+
},
|
|
3425
|
+
input,
|
|
3426
|
+
} as any)
|
|
3427
|
+
expect(result).toBeUndefined()
|
|
3428
|
+
})
|
|
3429
|
+
|
|
3383
3430
|
test('returns undefined for open POST with transfer-encoding header (content request)', () => {
|
|
3384
3431
|
const server = createServer()
|
|
3385
3432
|
const result = server.respond!({
|
|
@@ -3446,6 +3493,27 @@ describe.runIf(isLocalnet)('session', () => {
|
|
|
3446
3493
|
expect(result).toBeUndefined()
|
|
3447
3494
|
})
|
|
3448
3495
|
|
|
3496
|
+
test('returns undefined for voucher POST with a body stream and no framing headers', () => {
|
|
3497
|
+
const server = createServer()
|
|
3498
|
+
const input = new Request('http://localhost', {
|
|
3499
|
+
body: JSON.stringify({ prompt: 'hello' }),
|
|
3500
|
+
method: 'POST',
|
|
3501
|
+
})
|
|
3502
|
+
|
|
3503
|
+
expect(input.headers.get('content-length')).toBeNull()
|
|
3504
|
+
|
|
3505
|
+
const result = server.respond!({
|
|
3506
|
+
credential: {
|
|
3507
|
+
challenge: makeChallenge({
|
|
3508
|
+
channelId: '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex,
|
|
3509
|
+
}),
|
|
3510
|
+
payload: { action: 'voucher' },
|
|
3511
|
+
},
|
|
3512
|
+
input,
|
|
3513
|
+
} as any)
|
|
3514
|
+
expect(result).toBeUndefined()
|
|
3515
|
+
})
|
|
3516
|
+
|
|
3449
3517
|
test('returns undefined for voucher POST with transfer-encoding header (content request)', () => {
|
|
3450
3518
|
const server = createServer()
|
|
3451
3519
|
const result = server.respond!({
|
|
@@ -35,6 +35,7 @@ import type { LooseOmit, NoExtraKeys } from '../../internal/types.js'
|
|
|
35
35
|
import * as Method from '../../Method.js'
|
|
36
36
|
import * as Store from '../../Store.js'
|
|
37
37
|
import * as Client from '../../viem/Client.js'
|
|
38
|
+
import type * as z from '../../zod.js'
|
|
38
39
|
import * as Account from '../internal/account.js'
|
|
39
40
|
import * as defaults from '../internal/defaults.js'
|
|
40
41
|
import * as FeePayer from '../internal/fee-payer.js'
|
|
@@ -52,6 +53,7 @@ import * as ChannelStore from '../session/ChannelStore.js'
|
|
|
52
53
|
import { createSessionReceipt } from '../session/Receipt.js'
|
|
53
54
|
import type { SessionCredentialPayload, SessionReceipt, SignedVoucher } from '../session/Types.js'
|
|
54
55
|
import { parseVoucherFromPayload, verifyVoucher } from '../session/Voucher.js'
|
|
56
|
+
import { captureRequestBodyProbe, isSessionContentRequest } from './internal/request-body.js'
|
|
55
57
|
import * as Transport from './internal/transport.js'
|
|
56
58
|
|
|
57
59
|
/** Challenge methodDetails shape for session methods. */
|
|
@@ -184,7 +186,13 @@ export function session<const parameters extends session.Parameters>(
|
|
|
184
186
|
async verify({ credential, envelope, request }) {
|
|
185
187
|
const { challenge, payload } = credential as Credential.Credential<SessionCredentialPayload>
|
|
186
188
|
|
|
187
|
-
const resolvedRequest =
|
|
189
|
+
const resolvedRequest = (() => {
|
|
190
|
+
const parsed = Methods.session.schema.request.safeParse(request)
|
|
191
|
+
if (parsed.success) return parsed.data
|
|
192
|
+
// verifyCredential() passes the HMAC-bound challenge request, which is
|
|
193
|
+
// already in canonical output form and should not be transformed again.
|
|
194
|
+
return request as unknown as z.output<typeof Methods.session.schema.request>
|
|
195
|
+
})()
|
|
188
196
|
const methodDetails = resolvedRequest.methodDetails as SessionMethodDetails
|
|
189
197
|
const client = await getClient({ chainId: methodDetails.chainId })
|
|
190
198
|
|
|
@@ -262,7 +270,7 @@ export function session<const parameters extends session.Parameters>(
|
|
|
262
270
|
if (
|
|
263
271
|
!parameters.sse &&
|
|
264
272
|
envelope &&
|
|
265
|
-
|
|
273
|
+
isSessionContentRequest(envelope.capturedRequest) &&
|
|
266
274
|
(payload.action === 'open' || payload.action === 'voucher')
|
|
267
275
|
) {
|
|
268
276
|
const charged = await charge(
|
|
@@ -296,14 +304,8 @@ export function session<const parameters extends session.Parameters>(
|
|
|
296
304
|
if (payload.action === 'close') return new Response(null, { status: 204 })
|
|
297
305
|
if (payload.action === 'topUp') return new Response(null, { status: 204 })
|
|
298
306
|
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
headers: input.headers,
|
|
302
|
-
method: input.method,
|
|
303
|
-
url: new URL(input.url),
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
if (isBillableContentRequest(capturedRequest)) return undefined
|
|
307
|
+
const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input)
|
|
308
|
+
if (isSessionContentRequest(request)) return undefined
|
|
307
309
|
return new Response(null, { status: 204 })
|
|
308
310
|
},
|
|
309
311
|
})
|
|
@@ -470,30 +472,6 @@ function validateOnChainChannel(
|
|
|
470
472
|
}
|
|
471
473
|
}
|
|
472
474
|
|
|
473
|
-
function isBillableContentRequest(input: {
|
|
474
|
-
hasBody?: boolean | undefined
|
|
475
|
-
headers: Headers
|
|
476
|
-
method: string
|
|
477
|
-
}): boolean {
|
|
478
|
-
if (input.method === 'POST') return hasCapturedRequestBody(input)
|
|
479
|
-
|
|
480
|
-
if (input.method === 'HEAD') return false
|
|
481
|
-
|
|
482
|
-
return true
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function hasCapturedRequestBody(input: {
|
|
486
|
-
hasBody?: boolean | undefined
|
|
487
|
-
headers: Headers
|
|
488
|
-
}): boolean {
|
|
489
|
-
const contentLength = input.headers.get('content-length')
|
|
490
|
-
const headerIndicatesBody =
|
|
491
|
-
(contentLength !== null && contentLength !== '0') || input.headers.has('transfer-encoding')
|
|
492
|
-
|
|
493
|
-
if (input.hasBody === true) return true
|
|
494
|
-
return headerIndicatesBody
|
|
495
|
-
}
|
|
496
|
-
|
|
497
475
|
/**
|
|
498
476
|
* Shared logic for verifying an incremental voucher and updating channel state.
|
|
499
477
|
* Used by both handleVoucher and (indirectly) handleOpen.
|
|
@@ -888,7 +866,9 @@ async function handleClose(
|
|
|
888
866
|
reason: `close voucher amount must be >= ${channel.spent} (spent)`,
|
|
889
867
|
})
|
|
890
868
|
}
|
|
891
|
-
|
|
869
|
+
const isUntouchedZeroClose =
|
|
870
|
+
voucher.cumulativeAmount === 0n && channel.spent === 0n && onChain.settled === 0n
|
|
871
|
+
if (!isUntouchedZeroClose && voucher.cumulativeAmount <= onChain.settled) {
|
|
892
872
|
throw new VerificationFailedError({
|
|
893
873
|
reason: `close voucher amount must be > ${onChain.settled} (on-chain settled)`,
|
|
894
874
|
})
|