@tangle-network/agent-gateway 0.7.1 → 0.8.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 (64) hide show
  1. package/README.md +83 -2
  2. package/dist/chunk-GITV7CPT.js +84 -0
  3. package/dist/chunk-GITV7CPT.js.map +1 -0
  4. package/dist/chunk-J5SDVHOL.js +104 -0
  5. package/dist/chunk-J5SDVHOL.js.map +1 -0
  6. package/dist/chunk-MP6IIAIA.js +5651 -0
  7. package/dist/chunk-MP6IIAIA.js.map +1 -0
  8. package/dist/index.d.ts +70 -10
  9. package/dist/index.js +303 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-DEsMmS-X.d.ts → types-BHISsm7D.d.ts} +414 -170
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/execution-fence.ts +162 -0
  23. package/src/a2a/handler.ts +506 -560
  24. package/src/a2a/message-send-execution.ts +241 -0
  25. package/src/a2a/message-stream-execution.ts +392 -0
  26. package/src/a2a/payment-recovery.ts +431 -0
  27. package/src/a2a/push-config-methods.ts +158 -0
  28. package/src/a2a/push-notifications.ts +172 -22
  29. package/src/a2a/task-cancellation.ts +50 -0
  30. package/src/a2a/task-finalization.ts +451 -0
  31. package/src/a2a/task-lifecycle.ts +54 -0
  32. package/src/a2a/task-methods.ts +163 -0
  33. package/src/a2a/task-push-delivery.ts +119 -0
  34. package/src/a2a/task-recovery.ts +11 -0
  35. package/src/a2a/task-state.ts +99 -0
  36. package/src/a2a/task-store-sql.ts +222 -24
  37. package/src/a2a/task-store.ts +58 -1
  38. package/src/a2a/task-submission-recovery.ts +178 -0
  39. package/src/a2a/types.ts +1 -0
  40. package/src/dispatch-authorization.ts +437 -0
  41. package/src/dispatch-payment-recovery.ts +248 -0
  42. package/src/dispatch-payment.ts +425 -0
  43. package/src/dispatch-pricing.ts +108 -0
  44. package/src/dispatch-sandbox.ts +422 -0
  45. package/src/dispatch-settlement.ts +139 -0
  46. package/src/dispatch-types.ts +81 -0
  47. package/src/dispatch.ts +35 -483
  48. package/src/index.ts +57 -1
  49. package/src/middleware.ts +307 -26
  50. package/src/mpp-payment.ts +117 -0
  51. package/src/nonce-store.ts +122 -20
  52. package/src/observer-types.ts +63 -0
  53. package/src/observer.ts +3 -63
  54. package/src/payment-operations.ts +485 -0
  55. package/src/payment-recovery-sql.ts +108 -0
  56. package/src/payment-recovery-worker.ts +488 -0
  57. package/src/payment-recovery.ts +331 -0
  58. package/src/payment-types.ts +48 -0
  59. package/src/types.ts +144 -46
  60. package/src/verify.ts +233 -71
  61. package/dist/chunk-M7ZJAK4K.js +0 -53
  62. package/dist/chunk-M7ZJAK4K.js.map +0 -1
  63. package/dist/chunk-Q4YAIEZY.js +0 -1763
  64. package/dist/chunk-Q4YAIEZY.js.map +0 -1
package/src/middleware.ts CHANGED
@@ -6,12 +6,22 @@ import {
6
6
  type AuthorizedRequest,
7
7
  type GatewayState,
8
8
  authenticateAndGuard,
9
- dispatchSandboxStream,
10
- estimateTokens,
9
+ beginPaymentExecution,
10
+ markPaymentExecutionStarted,
11
+ renewPaymentExecution,
12
+ claimPayment,
13
+ dispatchSandboxStreamRich,
14
+ releasePayment,
15
+ releasePaymentAfterFailure,
11
16
  settleAndRecord,
12
17
  } from './dispatch'
13
- import { MemoryNonceStore } from './nonce-store'
18
+ import { isAtomicNonceStore, MemoryNonceStore } from './nonce-store'
14
19
  import { type GatewayObserver, type RequestContext, generateRequestId } from './observer'
20
+ import {
21
+ MemoryPaymentRecoveryStore,
22
+ PaymentRecoveryReplayError,
23
+ assertPaymentRecoveryConfig,
24
+ } from './payment-recovery'
15
25
  import { MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
16
26
  import type { ChatCompletionChunk, ChatCompletionRequest, GatewayConfig } from './types'
17
27
  import { isApiKeyAuthEnabled, isMppAuthEnabled } from './verify'
@@ -26,7 +36,8 @@ import { isApiKeyAuthEnabled, isMppAuthEnabled } from './verify'
26
36
  * GET /:slug/chat/completions — agent discovery metadata (Tangle-native shape)
27
37
  * POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)
28
38
  */
29
- export function createAgentGateway(config: GatewayConfig) {
39
+ export function createAgentGateway(inputConfig: GatewayConfig) {
40
+ let config = inputConfig
30
41
  // Production gateways must verify x402 signatures. Tests and local
31
42
  // dev can opt into the explicit demo path.
32
43
  if (!config.x402.verifySigner && !config.x402.demoMode) {
@@ -35,6 +46,120 @@ export function createAgentGateway(config: GatewayConfig) {
35
46
  'For tests, set x402.demoMode: true explicitly.',
36
47
  )
37
48
  }
49
+ const maxOutputTokens = config.maxOutputTokens ?? 4096
50
+ const defaultOutputTokens = config.defaultOutputTokens ?? 1024
51
+ if (!Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0) {
52
+ throw new Error('createAgentGateway: maxOutputTokens must be a positive integer')
53
+ }
54
+ if (
55
+ !Number.isSafeInteger(defaultOutputTokens) ||
56
+ defaultOutputTokens <= 0 ||
57
+ defaultOutputTokens > maxOutputTokens
58
+ ) {
59
+ throw new Error(
60
+ 'createAgentGateway: defaultOutputTokens must be a positive integer no greater than maxOutputTokens',
61
+ )
62
+ }
63
+ if (
64
+ config.x402.currencyDecimals !== undefined &&
65
+ (!Number.isInteger(config.x402.currencyDecimals) ||
66
+ config.x402.currencyDecimals < 0 ||
67
+ config.x402.currencyDecimals > 18)
68
+ ) {
69
+ throw new Error('createAgentGateway: x402.currencyDecimals must be an integer between 0 and 18')
70
+ }
71
+ const executionBudget = config.executionBudget
72
+ for (const [name, value] of [
73
+ ['maxReasoningTokens', executionBudget?.maxReasoningTokens ?? maxOutputTokens],
74
+ ['maxToolTokens', executionBudget?.maxToolTokens ?? maxOutputTokens],
75
+ ['maxToolCalls', executionBudget?.maxToolCalls ?? 8],
76
+ ] as const) {
77
+ if (!Number.isSafeInteger(value) || value < 0) {
78
+ throw new Error(`createAgentGateway: executionBudget.${name} must be a non-negative safe integer`)
79
+ }
80
+ }
81
+ if (
82
+ executionBudget?.maxProviderCostUsd !== undefined &&
83
+ (!Number.isFinite(executionBudget.maxProviderCostUsd) || executionBudget.maxProviderCostUsd < 0)
84
+ ) {
85
+ throw new Error('createAgentGateway: executionBudget.maxProviderCostUsd must be finite and non-negative')
86
+ }
87
+ if (config.x402.paymentOperations && config.x402.paymentProtocolVersion === undefined) {
88
+ throw new Error('createAgentGateway: paymentProtocolVersion must be explicit when durable payment operations are configured')
89
+ }
90
+ if (
91
+ config.x402.authorizePayment &&
92
+ config.x402.paymentProtocolVersion !== 2 &&
93
+ !config.x402.demoMode
94
+ ) {
95
+ throw new Error(
96
+ 'createAgentGateway: production x402 version 1 cannot use authorizePayment; ' +
97
+ 'use paymentProtocolVersion: 2 with paymentOperations for durable payment ownership',
98
+ )
99
+ }
100
+ if (config.x402.paymentProtocolVersion === 2 &&
101
+ (!config.x402.paymentOperations || config.x402.paymentOperations.protocolVersion !== 2)) {
102
+ throw new Error('createAgentGateway: payment protocol version 2 requires durable payment operations')
103
+ }
104
+ if (config.x402.paymentProtocolVersion === 1 && config.x402.paymentOperations) {
105
+ throw new Error('createAgentGateway: version 1 cannot be combined with version 2 payment operations')
106
+ }
107
+ if (
108
+ config.a2a?.pushStore &&
109
+ !config.x402.demoMode &&
110
+ (!config.a2a.webhookSecret || config.a2a.webhookSecret.trim().length === 0)
111
+ ) {
112
+ throw new Error('createAgentGateway: production A2A push requires a webhookSecret')
113
+ }
114
+ const mppMethod = (config.mpp?.method ?? 'blueprintevm').toLowerCase()
115
+ if (config.mpp?.authenticateCredential !== undefined &&
116
+ typeof config.mpp.authenticateCredential !== 'function') {
117
+ throw new Error('createAgentGateway: mpp.authenticateCredential must be a function')
118
+ }
119
+ if (config.mpp?.verifySigner !== undefined && typeof config.mpp.verifySigner !== 'function') {
120
+ throw new Error('createAgentGateway: mpp.verifySigner must be a function')
121
+ }
122
+ const mppAuthenticator = typeof config.mpp?.authenticateCredential === 'function'
123
+ ? config.mpp.authenticateCredential
124
+ : typeof config.mpp?.verifySigner === 'function'
125
+ ? config.mpp.verifySigner
126
+ : undefined
127
+ if (config.mpp?.charge && config.mpp.charge.protocolVersion !== 1) {
128
+ throw new Error('createAgentGateway: unsupported MPP charge lifecycle version')
129
+ }
130
+ if (
131
+ config.mpp?.charge &&
132
+ mppMethod !== 'blueprintevm' &&
133
+ !mppAuthenticator
134
+ ) {
135
+ throw new Error('createAgentGateway: generic MPP methods require credential authentication')
136
+ }
137
+ if (
138
+ config.mpp &&
139
+ mppMethod !== 'blueprintevm' &&
140
+ mppAuthenticator &&
141
+ !config.mpp.charge
142
+ ) {
143
+ throw new Error('createAgentGateway: generic MPP methods require a charge lifecycle')
144
+ }
145
+ if (config.mpp && mppMethod !== 'blueprintevm' && !mppAuthenticator) {
146
+ throw new Error('createAgentGateway: generic MPP methods require credential authentication')
147
+ }
148
+ if (config.nonceStore && !isAtomicNonceStore(config.nonceStore)) {
149
+ throw new Error('createAgentGateway: durable payment ownership requires an atomic nonce store')
150
+ }
151
+ const needsRecovery = config.x402.paymentProtocolVersion === 2 ||
152
+ (mppMethod !== 'blueprintevm' && config.mpp?.charge !== undefined)
153
+ if (needsRecovery && !config.paymentRecovery) {
154
+ if (!config.x402.demoMode) {
155
+ throw new Error('createAgentGateway: durable payment recovery is required in production')
156
+ }
157
+ config = {
158
+ ...config,
159
+ paymentRecovery: { store: new MemoryPaymentRecoveryStore() },
160
+ }
161
+ }
162
+ if (config.paymentRecovery) assertPaymentRecoveryConfig(config.paymentRecovery)
38
163
  const gw = new Hono()
39
164
  const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()
40
165
  const state: GatewayState = {
@@ -43,6 +168,12 @@ export function createAgentGateway(config: GatewayConfig) {
43
168
  globalRateLimit: config.rateLimit ?? { limit: 60, windowSeconds: 60 },
44
169
  requiredScope: config.requiredScope ?? 'chat',
45
170
  maxLen: config.maxMessageLength ?? 8000,
171
+ maxOutputTokens,
172
+ defaultOutputTokens,
173
+ maxReasoningTokens: config.executionBudget?.maxReasoningTokens ?? maxOutputTokens,
174
+ maxToolTokens: config.executionBudget?.maxToolTokens ?? maxOutputTokens,
175
+ maxToolCalls: config.executionBudget?.maxToolCalls ?? 8,
176
+ maxProviderCostUsd: config.executionBudget?.maxProviderCostUsd,
46
177
  obs: config.observer,
47
178
  }
48
179
  const obs: GatewayObserver | undefined = state.obs
@@ -125,9 +256,62 @@ export function createAgentGateway(config: GatewayConfig) {
125
256
  )
126
257
  }
127
258
 
128
- const guard = await authenticateAndGuard(c, slug, body.messages, config, state)
259
+ const guard = await authenticateAndGuard(
260
+ c,
261
+ slug,
262
+ body.messages,
263
+ config,
264
+ state,
265
+ body.max_tokens,
266
+ )
129
267
  if (guard instanceof Response) return guard
130
268
  const authz = guard
269
+ try {
270
+ await claimPayment(authz, config, state)
271
+ } catch (error) {
272
+ const replayedGenericMpp = error instanceof PaymentRecoveryReplayError &&
273
+ authz.paymentMethod === 'mpp' &&
274
+ authz.mppMethod !== 'blueprintevm'
275
+ try {
276
+ await releasePayment(authz, config, 'payment authorization failed')
277
+ } catch (releaseError) {
278
+ console.error(
279
+ `[agent-gateway] payment release failed for ${authz.requestId}:`,
280
+ releaseError instanceof Error ? releaseError.message : String(releaseError),
281
+ )
282
+ }
283
+ await obs?.onAuthFailure?.(
284
+ {
285
+ requestId: authz.requestId,
286
+ agentSlug: authz.agent.slug,
287
+ startMs: authz.startMs,
288
+ },
289
+ {
290
+ method: authz.paymentMethod,
291
+ code: replayedGenericMpp ? 'invalid_mpp_credential' : 'payment_authorization_failed',
292
+ httpStatus: replayedGenericMpp ? 401 : 402,
293
+ },
294
+ )
295
+ const status = replayedGenericMpp ? 401 : 402
296
+ return c.json(
297
+ {
298
+ error: {
299
+ message: replayedGenericMpp ? 'Invalid Payment credential' : 'Payment authorization failed',
300
+ type: replayedGenericMpp ? 'authentication_error' : 'payment_required',
301
+ code: replayedGenericMpp ? 'invalid_mpp_credential' : 'payment_authorization_failed',
302
+ },
303
+ },
304
+ replayedGenericMpp
305
+ ? {
306
+ status,
307
+ headers: {
308
+ 'WWW-Authenticate': `Payment realm="${config.mpp!.realm}", method="${config.mpp!.method ?? 'blueprintevm'}"`,
309
+ 'X-Request-Id': authz.requestId,
310
+ },
311
+ }
312
+ : { status, headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': authz.requestId } },
313
+ )
314
+ }
131
315
 
132
316
  return streamChatCompletions(c, authz, config, obs)
133
317
  })
@@ -139,13 +323,37 @@ export function createAgentGateway(config: GatewayConfig) {
139
323
  // regardless of which protocol the caller used.
140
324
  const taskStore = config.a2a?.taskStore ?? new InMemoryTaskStore()
141
325
  const pushStore = config.a2a?.pushStore
142
- const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
143
- gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
144
- gw.post('/:slug', a2a.handleJsonRpc)
326
+ try {
327
+ const a2a = createA2AHandlers({ config, state, taskStore, pushStore })
328
+ gw.get('/:slug/.well-known/agent.json', a2a.handleAgentCard)
329
+ gw.post('/:slug', a2a.handleJsonRpc)
330
+ } catch (error) {
331
+ // An older custom store must not take down the OpenAI surface. Keep the
332
+ // A2A surface unavailable until its owner supplies atomic methods.
333
+ console.error(
334
+ '[agent-gateway] A2A is unavailable until its task store is upgraded:',
335
+ error instanceof Error ? error.message : String(error),
336
+ )
337
+ const unavailable = (c: import('hono').Context) => c.json(
338
+ { error: 'A2A task persistence is not configured for concurrent workers' },
339
+ 503,
340
+ )
341
+ gw.get('/:slug/.well-known/agent.json', unavailable)
342
+ gw.post('/:slug', unavailable)
343
+ }
145
344
 
146
345
  return gw
147
346
  }
148
347
 
348
+ // Consumers that bind the gateway through a package boundary can fail closed
349
+ // when an old binary ignores the version 2 operation contract.
350
+ Object.assign(createAgentGateway, { paymentProtocolVersion: 2 as const })
351
+
352
+ /** Public package-boundary version marker for durable payment operations. */
353
+ export namespace createAgentGateway {
354
+ export const paymentProtocolVersion = 2 as const
355
+ }
356
+
149
357
  /**
150
358
  * Drain the sandbox stream into an OpenAI-shaped SSE response, settle the
151
359
  * payment, fire observer hooks. Identical pre-refactor behavior, just lifted
@@ -158,9 +366,23 @@ function streamChatCompletions(
158
366
  config: GatewayConfig,
159
367
  obs: GatewayObserver | undefined,
160
368
  ): Response {
161
- const { agent, consumerId, paymentMethod, requestId, userMessage, rateLimitRemaining } = authz
162
- const inputTokens = estimateTokens(userMessage)
163
- let outputTokens = 0
369
+ const {
370
+ agent,
371
+ consumerId,
372
+ paymentMethod,
373
+ requestId,
374
+ userMessage,
375
+ rateLimitRemaining,
376
+ maxOutputTokens,
377
+ } = authz
378
+ let outputText = ''
379
+ let usage: import('./types').SandboxUsageReceipt | undefined
380
+ let workObserved = false
381
+ const requestSignal = c.req.raw.signal
382
+ const abortController = new AbortController()
383
+ const abortFromRequest = () => abortController.abort()
384
+ if (requestSignal.aborted) abortFromRequest()
385
+ else requestSignal.addEventListener('abort', abortFromRequest, { once: true })
164
386
  const ctx: RequestContext = {
165
387
  requestId,
166
388
  agentSlug: agent.slug,
@@ -171,7 +393,8 @@ function streamChatCompletions(
171
393
  async start(controller) {
172
394
  const encoder = new TextEncoder()
173
395
  const sendChunk = (delta: string) => {
174
- outputTokens += estimateTokens(delta)
396
+ if (controller.desiredSize === null) return
397
+ outputText += delta
175
398
  const chunk: ChatCompletionChunk = {
176
399
  id: `chatcmpl-${Date.now()}`,
177
400
  object: 'chat.completion.chunk',
@@ -183,10 +406,41 @@ function streamChatCompletions(
183
406
  }
184
407
 
185
408
  try {
186
- for await (const delta of dispatchSandboxStream(agent, userMessage, consumerId, config)) {
187
- sendChunk(delta)
409
+ for await (const event of dispatchSandboxStreamRich(
410
+ agent,
411
+ userMessage,
412
+ consumerId,
413
+ config,
414
+ abortController.signal,
415
+ undefined,
416
+ maxOutputTokens,
417
+ () => beginPaymentExecution(authz, config),
418
+ authz.paymentOperation !== undefined || authz.mppChargeOperation !== undefined,
419
+ async () => {
420
+ if (authz.paymentRecoveryId) workObserved = true
421
+ await markPaymentExecutionStarted(authz, config)
422
+ },
423
+ authz.executionBudget.maxInputTokens,
424
+ () => renewPaymentExecution(authz, config),
425
+ )) {
426
+ if (event.kind === 'text') {
427
+ sendChunk(event.delta)
428
+ workObserved = true
429
+ }
430
+ if (event.kind === 'activity') workObserved = true
431
+ if (event.kind === 'usage') usage = event.usage
188
432
  }
189
433
 
434
+ if (!usage) throw new Error('sandbox did not provide a usage receipt')
435
+
436
+ await settleAndRecord(
437
+ agent,
438
+ authz,
439
+ usage,
440
+ config,
441
+ obs,
442
+ )
443
+
190
444
  const done: ChatCompletionChunk = {
191
445
  id: `chatcmpl-${Date.now()}`,
192
446
  object: 'chat.completion.chunk',
@@ -194,10 +448,10 @@ function streamChatCompletions(
194
448
  model: agent.slug,
195
449
  choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
196
450
  }
197
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
198
- controller.enqueue(encoder.encode('data: [DONE]\n\n'))
199
-
200
- await settleAndRecord(agent, authz, inputTokens, outputTokens, config, obs)
451
+ if (controller.desiredSize !== null) {
452
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\n\n`))
453
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
454
+ }
201
455
  } catch (err) {
202
456
  const rawMessage = err instanceof Error ? err.message : String(err)
203
457
  // Never expose stack traces / absolute paths from sandbox internals.
@@ -205,16 +459,37 @@ function streamChatCompletions(
205
459
  rawMessage.includes('/') || rawMessage.includes('\\')
206
460
  ? 'Internal agent error'
207
461
  : rawMessage
208
- await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
209
- controller.enqueue(
210
- encoder.encode(
211
- `data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
212
- ),
213
- )
462
+ try {
463
+ await obs?.onStreamError?.(ctx, { consumerId, errorMessage: rawMessage })
464
+ } catch (observerError) {
465
+ console.error(
466
+ `[agent-gateway] stream observer failed for ${requestId}:`,
467
+ observerError instanceof Error ? observerError.message : String(observerError),
468
+ )
469
+ }
470
+ try {
471
+ await releasePaymentAfterFailure(authz, config, rawMessage, workObserved || usage !== undefined)
472
+ } catch (releaseError) {
473
+ console.error(
474
+ `[agent-gateway] payment release failed for ${authz.requestId}:`,
475
+ releaseError instanceof Error ? releaseError.message : String(releaseError),
476
+ )
477
+ }
478
+ if (!abortController.signal.aborted && controller.desiredSize !== null) {
479
+ controller.enqueue(
480
+ encoder.encode(
481
+ `data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`,
482
+ ),
483
+ )
484
+ }
214
485
  } finally {
215
- controller.close()
486
+ requestSignal.removeEventListener('abort', abortFromRequest)
487
+ if (controller.desiredSize !== null) controller.close()
216
488
  }
217
489
  },
490
+ cancel() {
491
+ abortController.abort()
492
+ },
218
493
  })
219
494
 
220
495
  return new Response(stream, {
@@ -225,7 +500,13 @@ function streamChatCompletions(
225
500
  'X-Agent-Slug': agent.slug,
226
501
  'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
227
502
  'X-Payment-Method': paymentMethod,
228
- 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',
503
+ 'X-Payment-Settled': paymentMethod === 'x402' || authz.paymentOperation ? 'pending' : 'true',
504
+ ...(authz.mppChargeOperation
505
+ ? { 'Payment-Receipt': authz.mppChargeOperation.receipt }
506
+ : {}),
507
+ ...(authz.paymentRecoveryId
508
+ ? { 'X-Payment-Operation-Id': authz.paymentRecoveryId }
509
+ : {}),
229
510
  ...(rateLimitRemaining !== undefined
230
511
  ? { 'X-RateLimit-Remaining': String(rateLimitRemaining) }
231
512
  : {}),
@@ -0,0 +1,117 @@
1
+ /** Version of the gateway's method-specific MPP charge contract. */
2
+ export const MPP_CHARGE_PROTOCOL_VERSION = 1 as const
3
+
4
+ export type MppChargeOperationState = 'confirmed' | 'releasing' | 'released'
5
+
6
+ /** Pure authentication result for one method credential. */
7
+ export interface MppAuthenticatedCredential {
8
+ consumerId: string
9
+ /**
10
+ * Stable, non-secret processor identity for this payment credential.
11
+ * Return the same value for equivalent encodings of one credential.
12
+ * The gateway hashes this value before it persists or claims it.
13
+ */
14
+ paymentIdentity: string
15
+ }
16
+
17
+ /** Durable result of one immediate MPP charge. */
18
+ export interface MppChargeOperation {
19
+ protocolVersion: typeof MPP_CHARGE_PROTOCOL_VERSION
20
+ operationId: string
21
+ acquiredByRequestId: string
22
+ method: string
23
+ /** A complete Payment-Receipt header value. */
24
+ receipt: string
25
+ state: MppChargeOperationState
26
+ }
27
+
28
+ export interface MppChargeRequest {
29
+ /**
30
+ * Stable provider idempotency key. The adapter must bind every processor
31
+ * operation to this value before it attempts confirmation.
32
+ */
33
+ operationId: string
34
+ requestId: string
35
+ agentId: string
36
+ consumerId: string
37
+ method: string
38
+ /** Original decoded credential. It is available only on the live request. */
39
+ credential: string
40
+ amount: bigint
41
+ currencyDecimals: number
42
+ }
43
+
44
+ export type MppChargeRecoveryResult =
45
+ | MppChargeOperation
46
+ /** `not-found` is final and must fence this operation ID against a later charge. */
47
+ | { operationId: string; state: 'not-found' | 'pending' }
48
+
49
+ /**
50
+ * Immediate-charge lifecycle for a non-BlueprinTEVM MPP method.
51
+ *
52
+ * `confirmPayment` runs after every request denial and before a response or
53
+ * sandbox call. It must use `operationId` as its processor idempotency key,
54
+ * confirm payment, verify final success, and only then return `confirmed`.
55
+ *
56
+ * Every method must also support id-only recovery and an idempotent release.
57
+ * Recovery must inspect the existing processor operation. It must never
58
+ * create a second charge when an acknowledgement is ambiguous.
59
+ */
60
+ export interface MppChargeLifecycle {
61
+ readonly protocolVersion: typeof MPP_CHARGE_PROTOCOL_VERSION
62
+ confirmPayment(request: MppChargeRequest): Promise<MppChargeOperation>
63
+ releasePayment(operation: MppChargeOperation, reason: string): Promise<MppChargeOperation>
64
+ recoverPayment(operationId: string): Promise<MppChargeRecoveryResult>
65
+ }
66
+
67
+ /** Stable gateway identity. Neither the credential nor adapter identity is persisted. */
68
+ export async function mppPaymentOperationId(
69
+ method: string,
70
+ paymentIdentity: string,
71
+ ): Promise<string> {
72
+ const normalizedMethod = method.trim().toLowerCase()
73
+ if (!normalizedMethod || normalizedMethod.length > 128) {
74
+ throw new Error('MPP payment method is invalid')
75
+ }
76
+ if (!paymentIdentity || paymentIdentity.length > 8192) {
77
+ throw new Error('MPP payment identity is invalid')
78
+ }
79
+ const digest = await globalThis.crypto.subtle.digest(
80
+ 'SHA-256',
81
+ new TextEncoder().encode(`${normalizedMethod}\0${paymentIdentity}`),
82
+ )
83
+ const fingerprint = [...new Uint8Array(digest)]
84
+ .map((byte) => byte.toString(16).padStart(2, '0'))
85
+ .join('')
86
+ return `mpp:${normalizedMethod}:${fingerprint}`
87
+ }
88
+
89
+ export function assertMppChargeOperation(
90
+ operation: MppChargeOperation,
91
+ expected: { operationId: string; requestId: string; method: string },
92
+ allowedStates: readonly MppChargeOperationState[],
93
+ requireReceipt = true,
94
+ ): void {
95
+ if (operation.protocolVersion !== MPP_CHARGE_PROTOCOL_VERSION) {
96
+ throw new Error('MPP charge operation protocol version mismatch')
97
+ }
98
+ if (operation.operationId !== expected.operationId) {
99
+ throw new Error('MPP charge operation id mismatch')
100
+ }
101
+ if (operation.acquiredByRequestId !== expected.requestId) {
102
+ throw new Error('MPP charge operation request owner mismatch')
103
+ }
104
+ if (operation.method.toLowerCase() !== expected.method.toLowerCase()) {
105
+ throw new Error('MPP charge operation method mismatch')
106
+ }
107
+ if (!allowedStates.includes(operation.state)) {
108
+ throw new Error(`MPP charge operation is in invalid state ${operation.state}`)
109
+ }
110
+ if (requireReceipt && (
111
+ !operation.receipt ||
112
+ operation.receipt.length > 8192 ||
113
+ /[^\x20-\x7e]/.test(operation.receipt)
114
+ )) {
115
+ throw new Error('MPP charge operation has an invalid payment receipt')
116
+ }
117
+ }