mppx 0.5.17 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/client/Mppx.d.ts +2 -0
  3. package/dist/client/Mppx.d.ts.map +1 -1
  4. package/dist/client/Mppx.js +4 -1
  5. package/dist/client/Mppx.js.map +1 -1
  6. package/dist/client/index.d.ts +1 -0
  7. package/dist/client/index.d.ts.map +1 -1
  8. package/dist/client/index.js +1 -0
  9. package/dist/client/index.js.map +1 -1
  10. package/dist/client/internal/Fetch.d.ts +4 -0
  11. package/dist/client/internal/Fetch.d.ts.map +1 -1
  12. package/dist/client/internal/Fetch.js +43 -5
  13. package/dist/client/internal/Fetch.js.map +1 -1
  14. package/dist/server/Mppx.d.ts +38 -1
  15. package/dist/server/Mppx.d.ts.map +1 -1
  16. package/dist/server/Mppx.js +70 -1
  17. package/dist/server/Mppx.js.map +1 -1
  18. package/dist/stripe/server/Charge.d.ts.map +1 -1
  19. package/dist/stripe/server/Charge.js +8 -1
  20. package/dist/stripe/server/Charge.js.map +1 -1
  21. package/dist/tempo/server/Charge.d.ts.map +1 -1
  22. package/dist/tempo/server/Charge.js +15 -4
  23. package/dist/tempo/server/Charge.js.map +1 -1
  24. package/dist/tempo/server/Session.d.ts +39 -38
  25. package/dist/tempo/server/Session.d.ts.map +1 -1
  26. package/dist/tempo/server/Session.js +14 -24
  27. package/dist/tempo/server/Session.js.map +1 -1
  28. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  29. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  30. package/dist/tempo/server/internal/html.gen.js +1 -1
  31. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  32. package/dist/tempo/server/internal/request-body.d.ts +8 -0
  33. package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
  34. package/dist/tempo/server/internal/request-body.js +27 -0
  35. package/dist/tempo/server/internal/request-body.js.map +1 -0
  36. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  37. package/dist/tempo/server/internal/transport.js +4 -14
  38. package/dist/tempo/server/internal/transport.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/cli.test.ts +15 -7
  41. package/src/client/Mppx.ts +11 -2
  42. package/src/client/index.ts +1 -0
  43. package/src/client/internal/Fetch.browser.test.ts +58 -0
  44. package/src/client/internal/Fetch.test.ts +173 -0
  45. package/src/client/internal/Fetch.ts +62 -3
  46. package/src/server/Mppx.test-d.ts +36 -0
  47. package/src/server/Mppx.test.ts +926 -1
  48. package/src/server/Mppx.ts +141 -2
  49. package/src/server/Transport.test.ts +2 -1
  50. package/src/stripe/server/Charge.ts +7 -1
  51. package/src/tempo/server/Charge.ts +15 -4
  52. package/src/tempo/server/Session.test.ts +68 -0
  53. package/src/tempo/server/Session.ts +15 -35
  54. package/src/tempo/server/internal/html.gen.ts +1 -1
  55. package/src/tempo/server/internal/request-body.test.ts +142 -0
  56. package/src/tempo/server/internal/request-body.ts +37 -0
  57. package/src/tempo/server/internal/transport.test.ts +42 -2
  58. package/src/tempo/server/internal/transport.ts +4 -16
@@ -70,7 +70,32 @@ 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
+ * ```
96
+ */
97
+ verifyCredential(credential: string | Credential.Credential): Promise<Receipt.Receipt>
98
+ }
74
99
 
75
100
  /** Extracts the transport override from a method, if any. */
76
101
  type TransportOverrideOf<mi> = mi extends { transport?: infer transport }
@@ -136,6 +161,21 @@ type Handlers<
136
161
  } & UniqueIntentHandlers<methods, transport> &
137
162
  NestedHandlers<methods, transport>
138
163
 
164
+ /** Nested challenge generators: `mppx.challenge.tempo.charge(...)`. */
165
+ type ChallengeHandlers<methods extends readonly Method.AnyServer[]> = {
166
+ [name in methods[number]['name']]: {
167
+ [mi in Extract<methods[number], { name: name }> as mi['intent']]: ChallengeFn<
168
+ mi,
169
+ NonNullable<mi['defaults']>
170
+ >
171
+ }
172
+ }
173
+
174
+ /** A function that generates a Challenge object from intent options. */
175
+ type ChallengeFn<method extends Method.Method, defaults extends Record<string, unknown>> = (
176
+ options: MethodFn.Options<method, defaults>,
177
+ ) => Promise<Challenge.Challenge>
178
+
139
179
  /**
140
180
  * Creates a server-side payment handler from methods.
141
181
  *
@@ -200,6 +240,56 @@ export function create<
200
240
  ;(handlers[mi.name] as Record<string, unknown>)[mi.intent] = fn
201
241
  }
202
242
 
243
+ // Build challenge generators: mppx.challenge.tempo.charge(...)
244
+ const challengeHandlers: Record<string, Record<string, unknown>> = {}
245
+ for (const mi of methods) {
246
+ if (!challengeHandlers[mi.name]) challengeHandlers[mi.name] = {}
247
+ challengeHandlers[mi.name]![mi.intent] = createChallengeFn({
248
+ defaults: mi.defaults,
249
+ method: mi,
250
+ realm,
251
+ request: mi.request as never,
252
+ secretKey,
253
+ })
254
+ }
255
+
256
+ // verifyCredential: single-call end-to-end verification
257
+ async function verifyCredentialFn(
258
+ input: string | Credential.Credential,
259
+ ): Promise<Receipt.Receipt> {
260
+ const credential = typeof input === 'string' ? Credential.deserialize(input) : input
261
+
262
+ // HMAC provenance check (secretKey is guaranteed non-null by the guard at the top of create())
263
+ if (!Challenge.verify(credential.challenge, { secretKey: secretKey! }))
264
+ throw new Errors.InvalidChallengeError({
265
+ id: credential.challenge.id,
266
+ reason: 'challenge was not issued by this server',
267
+ })
268
+
269
+ // Expiry check
270
+ Expires.assert(credential.challenge.expires, credential.challenge.id)
271
+
272
+ // Find matching method by name + intent
273
+ const { method: credMethod, intent: credIntent } = credential.challenge
274
+ const mi = (methods as readonly Method.AnyServer[]).find(
275
+ (m) => m.name === credMethod && m.intent === credIntent,
276
+ )
277
+ if (!mi)
278
+ throw new Errors.InvalidChallengeError({
279
+ id: credential.challenge.id,
280
+ reason: `no registered method for ${credMethod}/${credIntent}`,
281
+ })
282
+
283
+ // Validate payload against method schema
284
+ mi.schema.credential.payload.parse(credential.payload)
285
+
286
+ // The challenge already contains the request params (HMAC-bound),
287
+ // so we use them directly — no need for the caller to re-supply.
288
+ const request = credential.challenge.request as z.input<typeof mi.schema.request>
289
+
290
+ return mi.verify({ credential, request } as never)
291
+ }
292
+
203
293
  function composeFn(
204
294
  ...entries: readonly [
205
295
  Method.AnyServer | AnyMethodFnWithMethod | string,
@@ -225,9 +315,11 @@ export function create<
225
315
 
226
316
  return {
227
317
  methods,
318
+ challenge: challengeHandlers,
228
319
  compose: composeFn,
229
320
  realm: realm as string | undefined,
230
321
  transport,
322
+ verifyCredential: verifyCredentialFn,
231
323
  ...handlers,
232
324
  } as never
233
325
  }
@@ -482,6 +574,53 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
482
574
  }
483
575
  }
484
576
 
577
+ /**
578
+ * Creates a challenge generator for a single method+intent.
579
+ * Applies the same defaults and request transform as createMethodFn,
580
+ * but returns a Challenge object directly instead of a request handler.
581
+ */
582
+ function createChallengeFn(parameters: {
583
+ defaults?: Record<string, unknown>
584
+ method: Method.Method
585
+ realm: string | undefined
586
+ request?: Method.RequestFn<Method.Method>
587
+ secretKey: string
588
+ }): (options: Record<string, unknown>) => Promise<Challenge.Challenge> {
589
+ const { defaults, method, realm, secretKey } = parameters
590
+
591
+ return async (options) => {
592
+ const { description, meta, ...rest } = options as {
593
+ description?: string
594
+ expires?: string
595
+ meta?: Record<string, string>
596
+ [key: string]: unknown
597
+ }
598
+ const merged = { ...defaults, ...rest }
599
+ const expires =
600
+ 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5)
601
+
602
+ // Transform request if method provides a `request` function.
603
+ const request = (
604
+ parameters.request
605
+ ? await (parameters.request as (opts: { request: unknown }) => unknown)({
606
+ request: merged,
607
+ })
608
+ : merged
609
+ ) as never
610
+
611
+ const effectiveRealm = realm ?? defaultRealm
612
+
613
+ return Challenge.fromMethod(method, {
614
+ description,
615
+ expires,
616
+ meta,
617
+ realm: effectiveRealm,
618
+ request,
619
+ secretKey,
620
+ })
621
+ }
622
+ }
623
+
485
624
  function getSafeCredentialReason(error: unknown): string | undefined {
486
625
  if (error instanceof Credential.InvalidCredentialEncodingError) return error.message
487
626
  if (error instanceof Credential.MissingAuthorizationHeaderError) return error.message
@@ -568,8 +707,8 @@ function captureRequestFromInput(input: unknown): Method.CapturedRequest {
568
707
  }
569
708
 
570
709
  return {
571
- hasBody: source.body !== undefined && source.body !== null,
572
710
  headers: new Headers(source.headers),
711
+ hasBody: source.body === undefined ? undefined : source.body !== null,
573
712
  method: source.method ?? 'POST',
574
713
  url: Transport.safeUrl(source.url),
575
714
  }
@@ -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 = Methods.charge.schema.request.parse(request)
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 = Methods.charge.schema.request.parse(request)
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 (feePayer && methodDetails?.feePayer !== false) {
321
+ if (feePayerAccount && methodDetails?.feePayer !== false) {
311
322
  const sponsored = FeePayer.prepareSponsoredTransaction({
312
- account: feePayer,
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 = Methods.session.schema.request.parse(request)
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
- isBillableContentRequest(envelope.capturedRequest) &&
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 capturedRequest = envelope?.capturedRequest ?? {
300
- hasBody: input.body !== null,
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
- if (voucher.cumulativeAmount <= onChain.settled) {
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
  })