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