@tangle-network/agent-gateway 0.7.1 → 0.8.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.
Files changed (64) hide show
  1. package/README.md +90 -3
  2. package/dist/chunk-C7Z2BRYV.js +5693 -0
  3. package/dist/chunk-C7Z2BRYV.js.map +1 -0
  4. package/dist/chunk-GITV7CPT.js +84 -0
  5. package/dist/chunk-GITV7CPT.js.map +1 -0
  6. package/dist/chunk-J5SDVHOL.js +104 -0
  7. package/dist/chunk-J5SDVHOL.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-oQ58UakD.d.ts} +447 -172
  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 +468 -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 +424 -0
  45. package/src/dispatch-settlement.ts +139 -0
  46. package/src/dispatch-types.ts +84 -0
  47. package/src/dispatch.ts +35 -483
  48. package/src/index.ts +59 -1
  49. package/src/middleware.ts +339 -35
  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 +188 -49
  60. package/src/verify.ts +240 -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
@@ -0,0 +1,178 @@
1
+ import type { Task } from './types'
2
+ import {
3
+ compareAndSetTask,
4
+ cryptoRandomId,
5
+ type TaskStateStore,
6
+ withStatus,
7
+ } from './task-state'
8
+
9
+ const TASK_ORIGIN_METADATA_KEY = 'gatewayOrigin'
10
+ const TASK_SUBMISSION_METADATA_KEY = 'gatewaySubmission'
11
+ const TASK_SUBMISSION_RECOVERY_METADATA_KEY = 'gatewaySubmissionRecovery'
12
+ const TASK_SUBMISSION_LEASE_MS = 5 * 60 * 1000
13
+
14
+ export interface TaskOriginAgent {
15
+ id: string
16
+ slug: string
17
+ }
18
+
19
+ export interface TaskSubmissionIdentity {
20
+ agent: TaskOriginAgent
21
+ requestId: string
22
+ consumerId: string
23
+ }
24
+
25
+ interface TaskOriginBinding {
26
+ version: 1
27
+ agentId: string
28
+ agentSlug: string
29
+ }
30
+
31
+ export interface TaskSubmissionRecord {
32
+ version: 1
33
+ lease: { id: string; expiresAt: number }
34
+ agentId: string
35
+ agentSlug: string
36
+ requestId: string
37
+ consumerId: string
38
+ }
39
+
40
+ export interface SubmissionRecoveryDependencies {
41
+ taskStore: TaskStateStore
42
+ deliverPush: (task: Task) => Promise<void>
43
+ }
44
+
45
+ export function withTaskOrigin(
46
+ metadata: Record<string, unknown> | undefined,
47
+ agent: TaskOriginAgent,
48
+ ): Record<string, unknown> {
49
+ return {
50
+ ...(metadata ?? {}),
51
+ [TASK_ORIGIN_METADATA_KEY]: {
52
+ version: 1,
53
+ agentId: agent.id,
54
+ agentSlug: agent.slug,
55
+ } satisfies TaskOriginBinding,
56
+ }
57
+ }
58
+
59
+ export function withTaskSubmission(
60
+ metadata: Record<string, unknown> | undefined,
61
+ identity: TaskSubmissionIdentity,
62
+ ): Record<string, unknown> {
63
+ const origin = metadata?.[TASK_ORIGIN_METADATA_KEY]
64
+ return {
65
+ ...(metadata ?? {}),
66
+ ...(origin === undefined
67
+ ? {
68
+ [TASK_ORIGIN_METADATA_KEY]: {
69
+ version: 1,
70
+ agentId: identity.agent.id,
71
+ agentSlug: identity.agent.slug,
72
+ } satisfies TaskOriginBinding,
73
+ }
74
+ : {}),
75
+ [TASK_SUBMISSION_METADATA_KEY]: {
76
+ version: 1,
77
+ lease: { id: cryptoRandomId(), expiresAt: Date.now() + TASK_SUBMISSION_LEASE_MS },
78
+ agentId: identity.agent.id,
79
+ agentSlug: identity.agent.slug,
80
+ requestId: identity.requestId,
81
+ consumerId: identity.consumerId,
82
+ } satisfies TaskSubmissionRecord,
83
+ }
84
+ }
85
+
86
+ export function readTaskOrigin(task: Task): TaskOriginBinding | undefined {
87
+ const raw = task.metadata?.[TASK_ORIGIN_METADATA_KEY]
88
+ if (!raw || typeof raw !== 'object') return undefined
89
+ const origin = raw as Partial<TaskOriginBinding>
90
+ if (
91
+ origin.version !== 1 ||
92
+ typeof origin.agentId !== 'string' ||
93
+ origin.agentId.length === 0 ||
94
+ typeof origin.agentSlug !== 'string' ||
95
+ origin.agentSlug.length === 0
96
+ ) {
97
+ return undefined
98
+ }
99
+ return origin as TaskOriginBinding
100
+ }
101
+
102
+ export function readTaskSubmission(task: Task): TaskSubmissionRecord | undefined {
103
+ const raw = task.metadata?.[TASK_SUBMISSION_METADATA_KEY]
104
+ if (!raw || typeof raw !== 'object') return undefined
105
+ const submission = raw as Partial<TaskSubmissionRecord>
106
+ if (
107
+ submission.version !== 1 ||
108
+ !submission.lease ||
109
+ typeof submission.lease.id !== 'string' ||
110
+ submission.lease.id.length === 0 ||
111
+ typeof submission.lease.expiresAt !== 'number' ||
112
+ !Number.isFinite(submission.lease.expiresAt) ||
113
+ typeof submission.agentId !== 'string' ||
114
+ submission.agentId.length === 0 ||
115
+ typeof submission.agentSlug !== 'string' ||
116
+ submission.agentSlug.length === 0 ||
117
+ typeof submission.requestId !== 'string' ||
118
+ submission.requestId.length === 0 ||
119
+ typeof submission.consumerId !== 'string'
120
+ ) {
121
+ return undefined
122
+ }
123
+ return submission as TaskSubmissionRecord
124
+ }
125
+
126
+ export function clearTaskSubmission(task: Task): Task {
127
+ if (!task.metadata || !(TASK_SUBMISSION_METADATA_KEY in task.metadata)) return task
128
+ const metadata = { ...task.metadata }
129
+ delete metadata[TASK_SUBMISSION_METADATA_KEY]
130
+ if (Object.keys(metadata).length > 0) return { ...task, metadata }
131
+ const { metadata: _metadata, ...withoutMetadata } = task
132
+ return withoutMetadata
133
+ }
134
+
135
+ export async function recoverSubmissionIfNeeded(
136
+ task: Task,
137
+ deps: SubmissionRecoveryDependencies,
138
+ ): Promise<Task> {
139
+ const raw = task.metadata?.[TASK_SUBMISSION_METADATA_KEY]
140
+ if (raw === undefined) return task
141
+ const submission = readTaskSubmission(task)
142
+ if (submission && submission.lease.expiresAt > Date.now()) return task
143
+ if (task.status.state !== 'submitted') {
144
+ return (await clearTaskSubmissionMarker(deps.taskStore, task)).task
145
+ }
146
+
147
+ const cleanTask = clearTaskSubmission(task)
148
+ const failed: Task = {
149
+ ...withStatus(cleanTask, 'failed'),
150
+ metadata: {
151
+ ...(cleanTask.metadata ?? {}),
152
+ [TASK_SUBMISSION_RECOVERY_METADATA_KEY]: {
153
+ error: submission
154
+ ? 'A2A task submission lease expired before payment authorization completed'
155
+ : 'A2A task submission lease is invalid',
156
+ },
157
+ },
158
+ }
159
+ if (await compareAndSetTask(deps.taskStore, task, failed)) {
160
+ await deps.deliverPush(failed)
161
+ return failed
162
+ }
163
+ return await deps.taskStore.get(task.id) ?? task
164
+ }
165
+
166
+ async function clearTaskSubmissionMarker(
167
+ taskStore: TaskStateStore,
168
+ expected: Task,
169
+ ): Promise<{ task: Task; applied: boolean }> {
170
+ const current = await taskStore.get(expected.id)
171
+ if (!current || JSON.stringify(current) !== JSON.stringify(expected)) {
172
+ return { task: current ?? expected, applied: false }
173
+ }
174
+ const cleared = clearTaskSubmission(current)
175
+ if (cleared === current) return { task: current, applied: true }
176
+ if (await compareAndSetTask(taskStore, current, cleared)) return { task: cleared, applied: true }
177
+ return { task: await taskStore.get(expected.id) ?? expected, applied: false }
178
+ }
package/src/a2a/types.ts CHANGED
@@ -51,6 +51,7 @@ export const A2A_ERROR_CODES = {
51
51
  CONTENT_TYPE_NOT_SUPPORTED: -32005,
52
52
  INVALID_AGENT_RESPONSE: -32006,
53
53
  AUTHENTICATED_EXTENDED_CARD_NOT_CONFIGURED: -32007,
54
+ TASK_ACCESS_DENIED: -32008,
54
55
  } as const
55
56
 
56
57
  // ── Message parts ────────────────────────────────────────────────────────
@@ -0,0 +1,468 @@
1
+ import type { Context } from 'hono'
2
+
3
+ import { filterConsumerMessagesStrict } from './filter'
4
+ import { type RequestContext, generateRequestId } from './observer'
5
+ import { type GatewayState, type AuthorizedRequest } from './dispatch-types'
6
+ import { checkRateLimit } from './rate-limit'
7
+ import {
8
+ maximumBillableInputTokens,
9
+ requiredX402Amount,
10
+ } from './dispatch-pricing'
11
+ import type {
12
+ ApiKeyInfo,
13
+ ChatMessage,
14
+ GatewayConfig,
15
+ PaymentMethod,
16
+ SandboxExecutionBudget,
17
+ } from './types'
18
+ import {
19
+ defaultVerifyApiKey,
20
+ isApiKeyAuthEnabled,
21
+ isMppAuthEnabled,
22
+ isX402AuthEnabled,
23
+ mppPaymentPayload,
24
+ mppPaymentCredential,
25
+ verifyMppCredential,
26
+ verifyX402,
27
+ } from './verify'
28
+
29
+ /**
30
+ * Resolve the agent, then run the full pre-dispatch pipeline: payment +
31
+ * rate-limit + injection filter + user-message extraction + optional
32
+ * `authorizeConsumer` hook. Returns the success record on the happy path
33
+ * or a fully-formed `Response` (402/404/429/400/403) on any short-circuit.
34
+ *
35
+ * Body parsing is the caller's responsibility — different wire formats
36
+ * (OpenAI chat completions vs A2A JSON-RPC) have different envelopes; both
37
+ * still ultimately produce a `ChatMessage[]`.
38
+ */
39
+ export async function authenticateAndGuard(
40
+ c: Context,
41
+ slug: string,
42
+ messages: ChatMessage[],
43
+ config: GatewayConfig,
44
+ state: GatewayState,
45
+ requestedMaxOutputTokens?: number,
46
+ ): Promise<AuthorizedRequest | Response> {
47
+ const startMs = Date.now()
48
+ const requestId = generateRequestId()
49
+ const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
50
+ await state.obs?.onRequestStart?.(ctx)
51
+
52
+ let threadId: string | undefined
53
+ if (config.conversationMode === 'thread') {
54
+ const requestedThreadId = c.req.header('X-Tangle-Thread-Id')?.trim()
55
+ if (requestedThreadId && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestedThreadId)) {
56
+ return c.json(
57
+ { error: { message: 'Invalid X-Tangle-Thread-Id', type: 'invalid_request' } },
58
+ { status: 400, headers: { 'X-Request-Id': requestId } },
59
+ )
60
+ }
61
+ threadId = requestedThreadId || requestId
62
+ }
63
+
64
+ const agent = await config.resolveAgent(slug)
65
+ if (!agent || !agent.enabled) {
66
+ return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
67
+ }
68
+ if (!messages?.length) {
69
+ return c.json(
70
+ { error: { message: 'messages array required', type: 'invalid_request' } },
71
+ 400,
72
+ )
73
+ }
74
+
75
+ const maxOutputTokens = requestedMaxOutputTokens ?? state.defaultOutputTokens
76
+ if (
77
+ !Number.isInteger(maxOutputTokens) ||
78
+ maxOutputTokens <= 0 ||
79
+ maxOutputTokens > state.maxOutputTokens
80
+ ) {
81
+ return c.json(
82
+ {
83
+ error: {
84
+ message: `max_tokens must be an integer between 1 and ${state.maxOutputTokens}`,
85
+ type: 'invalid_request',
86
+ code: 'invalid_max_tokens',
87
+ },
88
+ },
89
+ 400,
90
+ )
91
+ }
92
+
93
+ // Quote the maximum UTF-8 input plus every hidden provider cost before
94
+ // verification. The verifier must remain read-only at this point.
95
+ const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(
96
+ messages,
97
+ state.maxLen,
98
+ )
99
+ const userMessages = filtered
100
+ .filter((m) => m.role === 'user')
101
+ // A thread-backed host already owns the persisted history. Send only the
102
+ // new turn to avoid storing the caller's full history as one new user row.
103
+ // Keep the historical concatenation for the default consumer session.
104
+ const userMessage = config.conversationMode === 'thread'
105
+ ? userMessages[userMessages.length - 1]?.content ?? ''
106
+ : userMessages.map((m) => m.content).join('\n\n')
107
+ if (!userMessage) {
108
+ return c.json(
109
+ { error: { message: 'No user message provided', type: 'invalid_request' } },
110
+ 400,
111
+ )
112
+ }
113
+
114
+ let requiredPaymentAmount: bigint
115
+ const messageInputBound = maximumBillableInputTokens(agent, filtered)
116
+ let maxInputTokens = messageInputBound
117
+ if (config.inputTokenBound) {
118
+ let configuredBound: number
119
+ try {
120
+ configuredBound = config.inputTokenBound({ agent, messages: filtered })
121
+ } catch {
122
+ return c.json(
123
+ {
124
+ error: {
125
+ message: 'Agent input token bound is unavailable',
126
+ type: 'server_error',
127
+ code: 'input_token_bound_unavailable',
128
+ },
129
+ },
130
+ 503,
131
+ )
132
+ }
133
+ if (!Number.isSafeInteger(configuredBound) || configuredBound < messageInputBound) {
134
+ return c.json(
135
+ {
136
+ error: {
137
+ message: 'Agent input token bound is invalid',
138
+ type: 'server_error',
139
+ code: 'invalid_input_token_bound',
140
+ },
141
+ },
142
+ 503,
143
+ )
144
+ }
145
+ maxInputTokens = configuredBound
146
+ }
147
+ const maxReasoningTokens = state.maxReasoningTokens
148
+ const maxToolTokens = state.maxToolTokens
149
+ const maxToolCalls = state.maxToolCalls
150
+ const maxProviderCostUsd = state.maxProviderCostUsd ??
151
+ (maxInputTokens + maxOutputTokens + maxReasoningTokens + maxToolTokens) * agent.pricePerTokenUsd
152
+ const executionBudget: SandboxExecutionBudget = {
153
+ maxInputTokens,
154
+ maxOutputTokens,
155
+ maxReasoningTokens,
156
+ maxToolTokens,
157
+ maxToolCalls,
158
+ maxProviderCostUsd,
159
+ }
160
+ try {
161
+ requiredPaymentAmount = requiredX402Amount(
162
+ agent.pricePerTokenUsd,
163
+ maxInputTokens,
164
+ maxOutputTokens,
165
+ config.x402.currencyDecimals,
166
+ maxReasoningTokens,
167
+ maxToolTokens,
168
+ maxProviderCostUsd,
169
+ )
170
+ } catch {
171
+ return c.json(
172
+ {
173
+ error: {
174
+ message: 'Agent payment configuration is invalid',
175
+ type: 'server_error',
176
+ code: 'invalid_payment_configuration',
177
+ },
178
+ },
179
+ 503,
180
+ )
181
+ }
182
+
183
+ // Payment / auth.
184
+ const spendAuthHeader = c.req.header('X-Payment-Signature')
185
+ const authHeader = c.req.header('Authorization') ?? ''
186
+ let consumerId: string | null = null
187
+ let paymentMethod: PaymentMethod = 'none'
188
+ let keyInfo: ApiKeyInfo | null = null
189
+ let x402Payload: Record<string, unknown> | null = null
190
+ let paymentNonceKey: string | undefined
191
+ let mppMethod: string | undefined
192
+ let mppCredential: string | undefined
193
+ let mppPaymentIdentity: string | undefined
194
+
195
+ if (spendAuthHeader) {
196
+ if (!isX402AuthEnabled(config)) {
197
+ return c.json(
198
+ { error: { message: 'x402 authentication is not configured', type: 'authentication_error' } },
199
+ { status: 401, headers: { 'X-Request-Id': requestId } },
200
+ )
201
+ }
202
+ const signer = await verifyX402(
203
+ spendAuthHeader,
204
+ config.x402,
205
+ state.nonceStore,
206
+ requiredPaymentAmount,
207
+ false,
208
+ )
209
+ if (!signer) {
210
+ await state.obs?.onAuthFailure?.(ctx, {
211
+ method: 'x402',
212
+ code: 'invalid_spend_auth',
213
+ httpStatus: 402,
214
+ })
215
+ return c.json(
216
+ {
217
+ error: {
218
+ message: 'Invalid X-Payment-Signature',
219
+ type: 'authentication_error',
220
+ code: 'invalid_spend_auth',
221
+ required_amount: requiredPaymentAmount.toString(),
222
+ currency_decimals: config.x402.currencyDecimals ?? 6,
223
+ },
224
+ },
225
+ {
226
+ status: 402,
227
+ headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId },
228
+ },
229
+ )
230
+ }
231
+ x402Payload = JSON.parse(spendAuthHeader) as Record<string, unknown>
232
+ paymentNonceKey = `${String(x402Payload.commitment).toLowerCase()}:${BigInt(String(x402Payload.nonce)).toString()}`
233
+ consumerId = signer
234
+ paymentMethod = 'x402'
235
+ } else if (isMppAuthEnabled(config) && authHeader.toLowerCase().startsWith('payment ')) {
236
+ const authenticated = await verifyMppCredential(
237
+ authHeader,
238
+ config.mpp!,
239
+ config.x402,
240
+ state.nonceStore,
241
+ requiredPaymentAmount,
242
+ false,
243
+ )
244
+ if (!authenticated) {
245
+ const realm = config.mpp!.realm
246
+ const method = config.mpp!.method ?? 'blueprintevm'
247
+ await state.obs?.onAuthFailure?.(ctx, {
248
+ method: 'mpp',
249
+ code: 'invalid_mpp_credential',
250
+ httpStatus: 401,
251
+ })
252
+ return c.json(
253
+ {
254
+ error: {
255
+ message: 'Invalid Payment credential',
256
+ type: 'authentication_error',
257
+ code: 'invalid_mpp_credential',
258
+ },
259
+ },
260
+ {
261
+ status: 401,
262
+ headers: {
263
+ 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`,
264
+ 'X-Request-Id': requestId,
265
+ },
266
+ },
267
+ )
268
+ }
269
+ consumerId = authenticated.consumerId
270
+ paymentMethod = 'mpp'
271
+ mppMethod = authHeader.match(/^Payment\s+(\S+)\s+/i)?.[1]?.toLowerCase()
272
+ mppCredential = mppPaymentCredential(authHeader)
273
+ mppPaymentIdentity = authenticated.paymentIdentity
274
+ x402Payload = mppPaymentPayload(authHeader) ?? null
275
+ paymentNonceKey = authenticated.replayKey
276
+ } else if (authHeader.startsWith('Bearer ')) {
277
+ const verify = config.verifyApiKey ?? (config.x402.demoMode ? defaultVerifyApiKey : null)
278
+ if (!verify || !isApiKeyAuthEnabled(config)) {
279
+ await state.obs?.onAuthFailure?.(ctx, {
280
+ method: 'apikey',
281
+ code: 'api_keys_not_configured',
282
+ httpStatus: 401,
283
+ })
284
+ return c.json(
285
+ { error: { message: 'API key authentication is not configured', type: 'authentication_error' } },
286
+ { status: 401, headers: { 'X-Request-Id': requestId } },
287
+ )
288
+ }
289
+ const key = await verify(authHeader)
290
+ if (!key) {
291
+ await state.obs?.onAuthFailure?.(ctx, {
292
+ method: 'apikey',
293
+ code: 'invalid_api_key',
294
+ httpStatus: 401,
295
+ })
296
+ return c.json(
297
+ { error: { message: 'Invalid API key', type: 'authentication_error' } },
298
+ { status: 401, headers: { 'X-Request-Id': requestId } },
299
+ )
300
+ }
301
+ if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(state.requiredScope)) {
302
+ await state.obs?.onAuthFailure?.(ctx, {
303
+ method: 'apikey',
304
+ code: 'insufficient_scope',
305
+ httpStatus: 403,
306
+ })
307
+ return c.json(
308
+ {
309
+ error: {
310
+ message: `API key missing required scope: ${state.requiredScope}`,
311
+ type: 'forbidden',
312
+ code: 'insufficient_scope',
313
+ },
314
+ },
315
+ { status: 403, headers: { 'X-Request-Id': requestId } },
316
+ )
317
+ }
318
+ consumerId = key.consumerId
319
+ paymentMethod = 'apikey'
320
+ keyInfo = key
321
+ } else {
322
+ await state.obs?.onAuthFailure?.(ctx, {
323
+ method: 'none',
324
+ code: 'payment_required',
325
+ httpStatus: 402,
326
+ })
327
+ const methods: string[] = []
328
+ if (isX402AuthEnabled(config)) methods.push('x402')
329
+ if (isMppAuthEnabled(config)) methods.push('mpp')
330
+ if (isApiKeyAuthEnabled(config)) methods.push('api_key')
331
+ const headers: Record<string, string> = {
332
+ 'X-Payment-Required': methods.join(', '),
333
+ 'X-Request-Id': requestId,
334
+ }
335
+ if (isMppAuthEnabled(config) && config.mpp) {
336
+ headers['WWW-Authenticate'] =
337
+ `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
338
+ }
339
+ return c.json(
340
+ {
341
+ error: {
342
+ message: 'Payment required',
343
+ type: 'payment_required',
344
+ payment_methods: methods,
345
+ ...(isX402AuthEnabled(config)
346
+ ? {
347
+ x402: {
348
+ operator: config.x402.operatorAddress,
349
+ chain_id: config.x402.chainId,
350
+ credits_address: config.x402.creditsAddress,
351
+ required_amount: requiredPaymentAmount.toString(),
352
+ currency_decimals: config.x402.currencyDecimals ?? 6,
353
+ max_output_tokens: maxOutputTokens,
354
+ },
355
+ }
356
+ : {}),
357
+ ...(isMppAuthEnabled(config) && config.mpp
358
+ ? { mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' } }
359
+ : {}),
360
+ ...(isApiKeyAuthEnabled(config)
361
+ ? {
362
+ api_key: {
363
+ purchase_url: config.baseUrl
364
+ ? `${config.baseUrl}/agents/${slug}/api-keys`
365
+ : undefined,
366
+ },
367
+ }
368
+ : {}),
369
+ },
370
+ },
371
+ { status: 402, headers },
372
+ )
373
+ }
374
+
375
+ // Rate limit.
376
+ const effectiveRateLimit = keyInfo?.rateLimitPerMinute
377
+ ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
378
+ : state.globalRateLimit
379
+ const rl = await checkRateLimit(consumerId, effectiveRateLimit, state.rateLimitStore)
380
+ if (!rl.allowed) {
381
+ await state.obs?.onRateLimited?.(ctx, {
382
+ consumerId: consumerId,
383
+ retryAfterSeconds: rl.retryAfterSeconds ?? 60,
384
+ })
385
+ return c.json(
386
+ {
387
+ error: {
388
+ message: 'Rate limit exceeded',
389
+ type: 'rate_limit_error',
390
+ retry_after: rl.retryAfterSeconds,
391
+ },
392
+ },
393
+ {
394
+ status: 429,
395
+ headers: {
396
+ 'Retry-After': String(rl.retryAfterSeconds ?? 60),
397
+ 'X-Request-Id': requestId,
398
+ },
399
+ },
400
+ )
401
+ }
402
+
403
+ // Reject or report injection only after authentication so observer events
404
+ // retain the authenticated consumer identity.
405
+ if (injectionWarnings.length > 0) {
406
+ await state.obs?.onInjectionDetected?.(ctx, {
407
+ consumerId: consumerId,
408
+ patterns: injectionWarnings,
409
+ blocked: !!config.blockInjection,
410
+ })
411
+ if (config.blockInjection) {
412
+ return c.json(
413
+ {
414
+ error: {
415
+ message: 'Request rejected: potential prompt injection detected',
416
+ type: 'content_policy_violation',
417
+ },
418
+ },
419
+ { status: 400, headers: { 'X-Request-Id': requestId } },
420
+ )
421
+ }
422
+ }
423
+
424
+ if (config.authorizeConsumer) {
425
+ const authz = await config.authorizeConsumer(agent, {
426
+ method: paymentMethod,
427
+ consumerId: consumerId,
428
+ keyId: keyInfo?.keyId,
429
+ requestId,
430
+ ...(threadId ? { threadId } : {}),
431
+ })
432
+ if (!authz.allow) {
433
+ return c.json(
434
+ {
435
+ error: {
436
+ message: authz.reason,
437
+ type: 'authorization_denied',
438
+ code: authz.code,
439
+ },
440
+ },
441
+ { status: 403, headers: { 'X-Request-Id': requestId } },
442
+ )
443
+ }
444
+ }
445
+
446
+ return {
447
+ agent,
448
+ consumerId,
449
+ paymentMethod,
450
+ keyInfo,
451
+ userMessage,
452
+ rateLimitRemaining: rl.remaining,
453
+ requestId,
454
+ messages: filtered,
455
+ ...(threadId ? { threadId } : {}),
456
+ startMs,
457
+ maxOutputTokens,
458
+ executionBudget,
459
+ requiredPaymentAmount,
460
+ paymentPayload: x402Payload,
461
+ paymentNonceKey,
462
+ mppMethod,
463
+ mppCredential,
464
+ mppPaymentIdentity,
465
+ }
466
+ }
467
+
468
+ export type { AuthorizedRequest, GatewayState }