mppx 0.5.7 → 0.5.9

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 (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +32 -14
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js.map +1 -1
  9. package/dist/Store.d.ts +68 -2
  10. package/dist/Store.d.ts.map +1 -1
  11. package/dist/Store.js +41 -4
  12. package/dist/Store.js.map +1 -1
  13. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  14. package/dist/mcp-sdk/server/Transport.js +7 -0
  15. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  16. package/dist/server/Mppx.d.ts +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +133 -70
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/server/Transport.d.ts +8 -2
  21. package/dist/server/Transport.d.ts.map +1 -1
  22. package/dist/server/Transport.js +26 -1
  23. package/dist/server/Transport.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts +13 -2
  25. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  26. package/dist/tempo/client/SessionManager.js +429 -4
  27. package/dist/tempo/client/SessionManager.js.map +1 -1
  28. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +89 -0
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +4 -1
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +90 -66
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +3 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js +3 -0
  39. package/dist/tempo/server/Methods.js.map +1 -1
  40. package/dist/tempo/server/Session.d.ts +8 -2
  41. package/dist/tempo/server/Session.d.ts.map +1 -1
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/server/index.d.ts +1 -0
  44. package/dist/tempo/server/index.d.ts.map +1 -1
  45. package/dist/tempo/server/index.js +1 -0
  46. package/dist/tempo/server/index.js.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  48. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.js +1 -1
  50. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +16 -6
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  55. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  56. package/dist/tempo/session/ChannelStore.js +55 -14
  57. package/dist/tempo/session/ChannelStore.js.map +1 -1
  58. package/dist/tempo/session/Sse.d.ts +11 -2
  59. package/dist/tempo/session/Sse.d.ts.map +1 -1
  60. package/dist/tempo/session/Sse.js +66 -25
  61. package/dist/tempo/session/Sse.js.map +1 -1
  62. package/dist/tempo/session/Ws.d.ts +87 -0
  63. package/dist/tempo/session/Ws.d.ts.map +1 -0
  64. package/dist/tempo/session/Ws.js +428 -0
  65. package/dist/tempo/session/Ws.js.map +1 -0
  66. package/dist/tempo/session/index.d.ts +1 -0
  67. package/dist/tempo/session/index.d.ts.map +1 -1
  68. package/dist/tempo/session/index.js +1 -0
  69. package/dist/tempo/session/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Challenge.test.ts +1 -1
  72. package/src/Challenge.ts +28 -9
  73. package/src/Method.ts +61 -20
  74. package/src/Store.test-d.ts +80 -2
  75. package/src/Store.test.ts +150 -13
  76. package/src/Store.ts +140 -3
  77. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  78. package/src/mcp-sdk/server/Transport.ts +8 -0
  79. package/src/server/Mppx.test.ts +105 -0
  80. package/src/server/Mppx.ts +178 -88
  81. package/src/server/Transport.test.ts +31 -0
  82. package/src/server/Transport.ts +31 -2
  83. package/src/tempo/client/SessionManager.ts +510 -7
  84. package/src/tempo/internal/fee-payer.test.ts +115 -1
  85. package/src/tempo/internal/fee-payer.ts +138 -1
  86. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  87. package/src/tempo/server/Charge.test.ts +128 -0
  88. package/src/tempo/server/Charge.ts +118 -93
  89. package/src/tempo/server/Methods.ts +3 -0
  90. package/src/tempo/server/Session.test.ts +1044 -47
  91. package/src/tempo/server/Session.ts +8 -2
  92. package/src/tempo/server/Sse.test.ts +29 -0
  93. package/src/tempo/server/index.ts +1 -0
  94. package/src/tempo/server/internal/html/main.ts +9 -10
  95. package/src/tempo/server/internal/html.gen.ts +1 -1
  96. package/src/tempo/server/internal/transport.ts +19 -6
  97. package/src/tempo/session/ChannelStore.test.ts +20 -1
  98. package/src/tempo/session/ChannelStore.ts +77 -14
  99. package/src/tempo/session/Sse.ts +77 -24
  100. package/src/tempo/session/Ws.test.ts +410 -0
  101. package/src/tempo/session/Ws.ts +563 -0
  102. package/src/tempo/session/index.ts +1 -0
@@ -62,8 +62,8 @@ export function charge<const parameters extends charge.Parameters>(
62
62
  memo,
63
63
  waitForConfirmation = true,
64
64
  } = parameters
65
- const store = (parameters.store ?? Store.memory()) as Store.Store<charge.StoreItemMap>
66
- const proofStore = parameters.store as Store.Store<charge.StoreItemMap> | undefined
65
+ const store = (parameters.store ?? Store.memory()) as Store.AtomicStore<charge.StoreItemMap>
66
+ const proofStore = parameters.store as Store.AtomicStore<charge.StoreItemMap> | undefined
67
67
 
68
68
  const { recipient, feePayer, feePayerUrl } = Account.resolve(parameters)
69
69
 
@@ -154,7 +154,7 @@ export function charge<const parameters extends charge.Parameters>(
154
154
  const { challenge } = credential
155
155
  const resolvedRequest = Methods.charge.schema.request.parse(request)
156
156
  const chainId = resolvedRequest.methodDetails?.chainId ?? request.chainId
157
- const feePayer = request.feePayer
157
+ const feePayer = typeof request.feePayer === 'object' ? request.feePayer : undefined
158
158
 
159
159
  const client = await getClient({ chainId })
160
160
 
@@ -177,7 +177,9 @@ export function charge<const parameters extends charge.Parameters>(
177
177
  switch (payload.type) {
178
178
  case 'hash': {
179
179
  const hash = payload.hash as `0x${string}`
180
- await assertHashUnused(store, hash)
180
+ if (!(await markHashUsed(store, hash))) {
181
+ throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
182
+ }
181
183
 
182
184
  const expectedTransfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
183
185
  const receipt = await getTransactionReceipt(client, { hash })
@@ -186,7 +188,6 @@ export function charge<const parameters extends charge.Parameters>(
186
188
  sender: receipt.from,
187
189
  transfers: expectedTransfers,
188
190
  })
189
-
190
191
  // Only verify challenge binding when using auto-generated attribution memos.
191
192
  // Explicit memos (set by the server) are strictly matched by assertTransferLogs
192
193
  // but are NOT challenge-bound — callers that set explicit memos are responsible
@@ -197,8 +198,6 @@ export function charge<const parameters extends charge.Parameters>(
197
198
  realm: challenge.realm,
198
199
  })
199
200
 
200
- await markHashUsed(store, hash)
201
-
202
201
  return toReceipt(receipt)
203
202
  }
204
203
 
@@ -230,9 +229,8 @@ export function charge<const parameters extends charge.Parameters>(
230
229
  })
231
230
  if (!valid) throw new MismatchError('Proof signature does not match source.', {})
232
231
 
233
- if (proofStore) {
234
- await assertProofUnused(proofStore, challenge.id)
235
- await markProofUsed(proofStore, challenge.id)
232
+ if (proofStore && !(await markProofUsed(proofStore, challenge.id))) {
233
+ throw new VerificationFailedError({ reason: 'Proof credential has already been used' })
236
234
  }
237
235
 
238
236
  return {
@@ -248,64 +246,80 @@ export function charge<const parameters extends charge.Parameters>(
248
246
 
249
247
  // Pre-broadcast dedup: catch exact byte-for-byte replays early.
250
248
  const hash = keccak256(serializedTransaction)
251
- await assertHashUnused(store, hash)
252
- await markHashUsed(store, hash)
253
-
254
- if (!FeePayer.isTempoTransaction(serializedTransaction))
255
- throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
256
-
257
- const transaction = Transaction.deserialize(serializedTransaction)
258
- if (!transaction.signature || !transaction.from)
259
- throw new MismatchError(
260
- 'Transaction must be signed by the sender before fee payer co-signing.',
261
- {},
262
- )
249
+ if (!(await markHashUsed(store, hash))) {
250
+ throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
251
+ }
263
252
 
264
- const calls = (transaction.calls ?? []) as readonly {
265
- data?: `0x${string}` | undefined
266
- to?: `0x${string}` | undefined
267
- }[]
268
- const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
269
- const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
270
- assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers })
271
-
272
- if (isFeePayerTx)
273
- FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
274
-
275
- const resolvedFeeToken =
276
- transaction.feeToken ?? defaults.currency[chainId as keyof typeof defaults.currency]
277
-
278
- const serializedTransaction_final = await (async () => {
279
- if (feePayer && methodDetails?.feePayer !== false) {
280
- return signTransaction(client, {
281
- ...transaction,
282
- account: feePayer,
283
- feePayer,
284
- feeToken: resolvedFeeToken,
285
- } as never)
253
+ let releaseReservation = true
254
+
255
+ try {
256
+ if (!FeePayer.isTempoTransaction(serializedTransaction))
257
+ throw new MismatchError('Only Tempo (0x76/0x78) transactions are supported.', {})
258
+
259
+ const transaction = Transaction.deserialize(serializedTransaction)
260
+ if (!transaction.signature || !transaction.from)
261
+ throw new MismatchError(
262
+ 'Transaction must be signed by the sender before fee payer co-signing.',
263
+ {},
264
+ )
265
+
266
+ const calls = (transaction.calls ?? []) as readonly {
267
+ data?: `0x${string}` | undefined
268
+ to?: `0x${string}` | undefined
269
+ }[]
270
+ const transfers = getExpectedTransfers({ amount, memo, methodDetails, recipient })
271
+ const isFeePayerTx = !!(feePayer || feePayerUrl) && methodDetails?.feePayer !== false
272
+ assertTransferCalls(calls, { currency, exactCount: isFeePayerTx, transfers })
273
+
274
+ if (isFeePayerTx)
275
+ FeePayer.validateCalls(transaction.calls, { amount, currency, recipient })
276
+
277
+ const expectedFeeToken = defaults.currency[chainId as keyof typeof defaults.currency]
278
+ const resolvedFeeToken = transaction.feeToken ?? expectedFeeToken
279
+
280
+ const serializedTransaction_final = await (async () => {
281
+ if (feePayer && methodDetails?.feePayer !== false) {
282
+ const sponsored = FeePayer.prepareSponsoredTransaction({
283
+ account: feePayer,
284
+ challengeExpires: expires,
285
+ chainId: chainId ?? client.chain!.id,
286
+ details: { amount, currency, recipient },
287
+ expectedFeeToken,
288
+ transaction: {
289
+ ...transaction,
290
+ ...(resolvedFeeToken ? { feeToken: resolvedFeeToken } : {}),
291
+ },
292
+ })
293
+ return signTransaction(client, sponsored as never)
294
+ }
295
+ return serializedTransaction
296
+ })()
297
+
298
+ if (waitForConfirmation) {
299
+ const receipt = await sendRawTransactionSync(client, {
300
+ serializedTransaction: serializedTransaction_final,
301
+ })
302
+ assertTransferLogs(receipt, {
303
+ currency,
304
+ sender: transaction.from! as `0x${string}`,
305
+ transfers,
306
+ })
307
+ // Post-broadcast dedup: catch malleable input variants
308
+ // (different serialized bytes, same underlying tx) that
309
+ // bypass the pre-broadcast check. Skip if the broadcast
310
+ // hash matches the input hash (already stored above).
311
+ if (
312
+ receipt.transactionHash.toLowerCase() !== hash.toLowerCase() &&
313
+ !(await markHashUsed(store, receipt.transactionHash))
314
+ ) {
315
+ throw new VerificationFailedError({
316
+ reason: 'Transaction hash has already been used',
317
+ })
318
+ }
319
+ releaseReservation = false
320
+ return toReceipt(receipt)
286
321
  }
287
- return serializedTransaction
288
- })()
289
322
 
290
- if (waitForConfirmation) {
291
- const receipt = await sendRawTransactionSync(client, {
292
- serializedTransaction: serializedTransaction_final,
293
- })
294
- assertTransferLogs(receipt, {
295
- currency,
296
- sender: transaction.from! as `0x${string}`,
297
- transfers,
298
- })
299
- // Post-broadcast dedup: catch malleable input variants
300
- // (different serialized bytes, same underlying tx) that
301
- // bypass the pre-broadcast check. Skip if the broadcast
302
- // hash matches the input hash (already stored above).
303
- if (receipt.transactionHash.toLowerCase() !== hash.toLowerCase()) {
304
- await assertHashUnused(store, receipt.transactionHash)
305
- await markHashUsed(store, receipt.transactionHash)
306
- }
307
- return toReceipt(receipt)
308
- } else {
309
323
  // Optimistic path: simulate to catch obvious reverts, then broadcast
310
324
  // without waiting for on-chain confirmation. The returned receipt
311
325
  // assumes success — callers opt into this risk via waitForConfirmation: false.
@@ -319,16 +333,24 @@ export function charge<const parameters extends charge.Parameters>(
319
333
  serializedTransaction: serializedTransaction_final,
320
334
  })
321
335
  // Post-broadcast dedup: same
322
- if (reference.toLowerCase() !== hash.toLowerCase()) {
323
- await assertHashUnused(store, reference)
324
- await markHashUsed(store, reference)
336
+ if (
337
+ reference.toLowerCase() !== hash.toLowerCase() &&
338
+ !(await markHashUsed(store, reference))
339
+ ) {
340
+ throw new VerificationFailedError({
341
+ reason: 'Transaction hash has already been used',
342
+ })
325
343
  }
344
+ releaseReservation = false
326
345
  return {
327
346
  method: 'tempo',
328
347
  status: 'success',
329
348
  timestamp: new Date().toISOString(),
330
349
  reference,
331
350
  } as const
351
+ } catch (error) {
352
+ if (releaseReservation) await releaseHashUse(store, hash)
353
+ throw error
332
354
  }
333
355
  }
334
356
 
@@ -357,10 +379,13 @@ export declare namespace charge {
357
379
  * is explicitly provided; otherwise proofs remain reusable until the
358
380
  * challenge expires.
359
381
  *
382
+ * Replay protection requires a {@link Store.AtomicStore} so replay markers
383
+ * can be written atomically.
384
+ *
360
385
  * Use a shared store in multi-instance deployments so consumed hashes and
361
386
  * proofs are visible across all server instances.
362
387
  */
363
- store?: Store.Store | undefined
388
+ store?: Store.AtomicStore | undefined
364
389
  /**
365
390
  * Whether to wait for the charge transaction to confirm on-chain before
366
391
  * responding. @default true
@@ -597,40 +622,33 @@ function getProofStoreKey(challengeId: string): `mppx:charge:${string}` {
597
622
  return `mppx:charge:proof:${challengeId}`
598
623
  }
599
624
 
600
- /** @internal */
601
- async function assertHashUnused(
602
- store: Store.Store<charge.StoreItemMap>,
603
- hash: `0x${string}`,
604
- ): Promise<void> {
605
- const seen = await store.get(getHashStoreKey(hash))
606
- if (seen !== null)
607
- throw new VerificationFailedError({ reason: 'Transaction hash has already been used' })
608
- }
609
-
610
- /** @internal */
611
625
  async function markHashUsed(
612
- store: Store.Store<charge.StoreItemMap>,
626
+ store: Store.AtomicStore<charge.StoreItemMap>,
613
627
  hash: `0x${string}`,
614
- ): Promise<void> {
615
- await store.put(getHashStoreKey(hash), Date.now())
628
+ ): Promise<boolean> {
629
+ return store.update(getHashStoreKey(hash), (current) => {
630
+ if (current !== null) return { op: 'noop', result: false }
631
+ return { op: 'set', value: Date.now(), result: true }
632
+ })
616
633
  }
617
634
 
618
635
  /** @internal */
619
- async function assertProofUnused(
620
- store: Store.Store<charge.StoreItemMap>,
621
- challengeId: string,
636
+ async function releaseHashUse(
637
+ store: Store.AtomicStore<charge.StoreItemMap>,
638
+ hash: `0x${string}`,
622
639
  ): Promise<void> {
623
- const seen = await store.get(getProofStoreKey(challengeId))
624
- if (seen !== null)
625
- throw new VerificationFailedError({ reason: 'Proof credential has already been used' })
640
+ await store.delete(getHashStoreKey(hash))
626
641
  }
627
642
 
628
643
  /** @internal */
629
644
  async function markProofUsed(
630
- store: Store.Store<charge.StoreItemMap>,
645
+ store: Store.AtomicStore<charge.StoreItemMap>,
631
646
  challengeId: string,
632
- ): Promise<void> {
633
- await store.put(getProofStoreKey(challengeId), Date.now())
647
+ ): Promise<boolean> {
648
+ return store.update(getProofStoreKey(challengeId), (current) => {
649
+ if (current !== null) return { op: 'noop', result: false }
650
+ return { op: 'set', value: Date.now(), result: true }
651
+ })
634
652
  }
635
653
 
636
654
  /** @internal */
@@ -676,6 +694,13 @@ class MismatchError extends PaymentError {
676
694
  readonly type = 'https://paymentauth.org/problems/verification-failed'
677
695
 
678
696
  constructor(reason: string, details: Record<string, string>) {
679
- super([reason, ...Object.entries(details).map(([k, v]) => ` - ${k}: ${v}`)].join('\n'))
697
+ super(
698
+ [
699
+ reason.startsWith('Payment verification failed')
700
+ ? reason
701
+ : `Payment verification failed: ${reason}`,
702
+ ...Object.entries(details).map(([k, v]) => ` - ${k}: ${v}`),
703
+ ].join('\n'),
704
+ )
680
705
  }
681
706
  }
@@ -1,3 +1,4 @@
1
+ import * as Ws_ from '../session/Ws.js'
1
2
  import { charge as charge_ } from './Charge.js'
2
3
  import { session as session_, settle as settle_ } from './Session.js'
3
4
 
@@ -29,4 +30,6 @@ export namespace tempo {
29
30
  export const session = session_
30
31
  /** One-shot settle: reads highest voucher from storage and submits on-chain. */
31
32
  export const settle = settle_
33
+ /** Experimental websocket helpers for Tempo sessions. */
34
+ export const Ws = Ws_
32
35
  }