@tangle-network/agent-gateway 0.7.0 → 0.7.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.
package/src/dispatch.ts CHANGED
@@ -21,7 +21,13 @@ import type {
21
21
  GatewayConfig,
22
22
  PaymentMethod,
23
23
  } from './types'
24
- import { defaultVerifyApiKey, verifyMpp, verifyX402 } from './verify'
24
+ import {
25
+ defaultVerifyApiKey,
26
+ isApiKeyAuthEnabled,
27
+ isMppAuthEnabled,
28
+ verifyMpp,
29
+ verifyX402,
30
+ } from './verify'
25
31
 
26
32
  /** Single bundle of long-lived gateway state shared across all handlers in one createAgentGateway call. */
27
33
  export interface GatewayState {
@@ -68,7 +74,7 @@ export async function authenticateAndGuard(
68
74
  await state.obs?.onRequestStart?.(ctx)
69
75
 
70
76
  const agent = await config.resolveAgent(slug)
71
- if (!agent) {
77
+ if (!agent || !agent.enabled) {
72
78
  return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)
73
79
  }
74
80
  if (!messages?.length) {
@@ -109,11 +115,11 @@ export async function authenticateAndGuard(
109
115
  }
110
116
  consumerId = signer
111
117
  paymentMethod = 'x402'
112
- } else if (config.mpp && authHeader.toLowerCase().startsWith('payment ')) {
113
- const signer = await verifyMpp(authHeader, config.mpp, config.x402)
118
+ } else if (isMppAuthEnabled(config) && authHeader.toLowerCase().startsWith('payment ')) {
119
+ const signer = await verifyMpp(authHeader, config.mpp!, config.x402, state.nonceStore)
114
120
  if (!signer) {
115
- const realm = config.mpp.realm
116
- const method = config.mpp.method ?? 'blueprintevm'
121
+ const realm = config.mpp!.realm
122
+ const method = config.mpp!.method ?? 'blueprintevm'
117
123
  await state.obs?.onAuthFailure?.(ctx, {
118
124
  method: 'mpp',
119
125
  code: 'invalid_mpp_credential',
@@ -139,7 +145,18 @@ export async function authenticateAndGuard(
139
145
  consumerId = signer
140
146
  paymentMethod = 'mpp'
141
147
  } else if (authHeader.startsWith('Bearer ')) {
142
- const verify = config.verifyApiKey ?? defaultVerifyApiKey
148
+ const verify = config.verifyApiKey ?? (config.x402.demoMode ? defaultVerifyApiKey : null)
149
+ if (!verify || !isApiKeyAuthEnabled(config)) {
150
+ await state.obs?.onAuthFailure?.(ctx, {
151
+ method: 'apikey',
152
+ code: 'api_keys_not_configured',
153
+ httpStatus: 401,
154
+ })
155
+ return c.json(
156
+ { error: { message: 'API key authentication is not configured', type: 'authentication_error' } },
157
+ { status: 401, headers: { 'X-Request-Id': requestId } },
158
+ )
159
+ }
143
160
  const key = await verify(authHeader)
144
161
  if (!key) {
145
162
  await state.obs?.onAuthFailure?.(ctx, {
@@ -179,13 +196,13 @@ export async function authenticateAndGuard(
179
196
  httpStatus: 402,
180
197
  })
181
198
  const methods: string[] = ['x402']
182
- if (config.mpp) methods.push('mpp')
183
- methods.push('api_key')
199
+ if (isMppAuthEnabled(config)) methods.push('mpp')
200
+ if (isApiKeyAuthEnabled(config)) methods.push('api_key')
184
201
  const headers: Record<string, string> = {
185
202
  'X-Payment-Required': methods.join(', '),
186
203
  'X-Request-Id': requestId,
187
204
  }
188
- if (config.mpp) {
205
+ if (isMppAuthEnabled(config) && config.mpp) {
189
206
  headers['WWW-Authenticate'] =
190
207
  `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
191
208
  }
@@ -201,14 +218,18 @@ export async function authenticateAndGuard(
201
218
  credits_address: config.x402.creditsAddress,
202
219
  estimated_amount_per_request: '20000',
203
220
  },
204
- ...(config.mpp
221
+ ...(isMppAuthEnabled(config) && config.mpp
205
222
  ? { mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' } }
206
223
  : {}),
207
- api_key: {
208
- purchase_url: config.baseUrl
209
- ? `${config.baseUrl}/agents/${slug}/api-keys`
210
- : undefined,
211
- },
224
+ ...(isApiKeyAuthEnabled(config)
225
+ ? {
226
+ api_key: {
227
+ purchase_url: config.baseUrl
228
+ ? `${config.baseUrl}/agents/${slug}/api-keys`
229
+ : undefined,
230
+ },
231
+ }
232
+ : {}),
212
233
  },
213
234
  },
214
235
  { status: 402, headers },
package/src/index.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export { createAgentGateway } from './middleware'
2
- export { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
2
+ export {
3
+ verifyX402,
4
+ verifyMpp,
5
+ defaultVerifyApiKey,
6
+ isApiKeyAuthEnabled,
7
+ isMppAuthEnabled,
8
+ } from './verify'
3
9
  export {
4
10
  filterConsumerMessages,
5
11
  filterConsumerMessagesStrict,
package/src/middleware.ts CHANGED
@@ -14,6 +14,7 @@ import { MemoryNonceStore } from './nonce-store'
14
14
  import { type GatewayObserver, type RequestContext, generateRequestId } from './observer'
15
15
  import { MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
16
16
  import type { ChatCompletionChunk, ChatCompletionRequest, GatewayConfig } from './types'
17
+ import { isApiKeyAuthEnabled, isMppAuthEnabled } from './verify'
17
18
 
18
19
  /**
19
20
  * Create a Hono router that serves the agent gateway.
@@ -51,7 +52,7 @@ export function createAgentGateway(config: GatewayConfig) {
51
52
  gw.get('/:slug/chat/completions', async (c) => {
52
53
  const slug = c.req.param('slug')
53
54
  const agent = await config.resolveAgent(slug)
54
- if (!agent) return c.json({ error: 'Agent not found or not published' }, 404)
55
+ if (!agent || !agent.enabled) return c.json({ error: 'Agent not found or not published' }, 404)
55
56
 
56
57
  const paymentMethods: Array<Record<string, unknown>> = [
57
58
  {
@@ -61,14 +62,14 @@ export function createAgentGateway(config: GatewayConfig) {
61
62
  credits_contract: config.x402.creditsAddress,
62
63
  },
63
64
  ]
64
- if (config.mpp) {
65
+ if (isMppAuthEnabled(config)) {
65
66
  paymentMethods.push({
66
67
  type: 'mpp',
67
- realm: config.mpp.realm,
68
- method: config.mpp.method ?? 'blueprintevm',
68
+ realm: config.mpp!.realm,
69
+ method: config.mpp!.method ?? 'blueprintevm',
69
70
  })
70
71
  }
71
- paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })
72
+ if (isApiKeyAuthEnabled(config)) paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })
72
73
 
73
74
  return c.json({
74
75
  slug: agent.slug,
@@ -231,4 +232,3 @@ function streamChatCompletions(
231
232
  },
232
233
  })
233
234
  }
234
-
package/src/types.ts CHANGED
@@ -93,6 +93,18 @@ export interface MppConfig {
93
93
  realm: string
94
94
  /** MPP method name (default: "blueprintevm") */
95
95
  method?: string
96
+ /**
97
+ * Production verifier for the method-specific credential. Return the
98
+ * authenticated consumer id, or null when the credential is invalid.
99
+ * The callback receives the decoded JSON payload when one exists plus the
100
+ * original decoded credential so non-JSON methods can verify their own form.
101
+ * Omit only when x402.demoMode is explicitly enabled for local testing, or
102
+ * when x402.verifySigner handles an x402-compatible MPP credential.
103
+ */
104
+ verifySigner?: (
105
+ payload: Record<string, unknown>,
106
+ context: { method: string; credential: string },
107
+ ) => Promise<string | null>
96
108
  }
97
109
 
98
110
  export interface PaymentResult {
@@ -194,12 +206,13 @@ export interface GatewayConfig {
194
206
  /** x402 payment configuration */
195
207
  x402: X402Config
196
208
 
197
- /** MPP (Machine Payments Protocol) configuration. If provided, gateway accepts Authorization: Payment headers. */
209
+ /** MPP (Machine Payments Protocol) configuration. It is advertised only when a production verifier or explicit demo mode is available. */
198
210
  mpp?: MppConfig
199
211
 
200
212
  /**
201
213
  * Verify an API key. Return key info if valid, null if invalid.
202
- * Default: accepts any `sk_agent_*` key (demo mode).
214
+ * In explicit x402 demo mode, the built-in verifier accepts `sk_agent_*` keys.
215
+ * Production gateways must provide this callback.
203
216
  */
204
217
  verifyApiKey?: (authHeader: string) => Promise<ApiKeyInfo | null>
205
218
 
package/src/verify.ts CHANGED
@@ -1,6 +1,26 @@
1
- import type { X402Config, MppConfig, ApiKeyInfo } from './types'
1
+ import type { X402Config, MppConfig, ApiKeyInfo, GatewayConfig } from './types'
2
2
  import type { NonceStore } from './nonce-store'
3
3
 
4
+ /** Pure capability checks shared by discovery and every request protocol. */
5
+ export function isApiKeyAuthEnabled(
6
+ config: Pick<GatewayConfig, 'verifyApiKey' | 'x402'>,
7
+ ): boolean {
8
+ return config.verifyApiKey !== undefined || config.x402.demoMode === true
9
+ }
10
+
11
+ /** MPP is enabled only when a real verifier or explicit demo mode exists. */
12
+ export function isMppAuthEnabled(
13
+ config: Pick<GatewayConfig, 'mpp' | 'x402'>,
14
+ ): boolean {
15
+ const method = config.mpp?.method ?? 'blueprintevm'
16
+ return Boolean(
17
+ config.mpp &&
18
+ (config.mpp.verifySigner !== undefined ||
19
+ (method === 'blueprintevm' && config.x402.verifySigner !== undefined) ||
20
+ config.x402.demoMode === true),
21
+ )
22
+ }
23
+
4
24
  /**
5
25
  * Verify x402 SpendAuth signature (EIP-712).
6
26
  * Returns the signer address (commitment) if valid, null otherwise.
@@ -32,14 +52,8 @@ export async function verifyX402(
32
52
  // Reject zero-amount payments
33
53
  if (amount <= 0n) return null
34
54
 
35
- // Reject replayed nonces
36
55
  const nonceKey = `${raw.commitment}:${nonce.toString()}`
37
- if (nonceStore) {
38
- if (await nonceStore.hasSeen(nonceKey)) return null
39
- // Mark seen with TTL matching the expiry window (max 1 hour)
40
- const ttl = Math.min(Number(expiry) - Math.floor(Date.now() / 1000), 3600)
41
- await nonceStore.markSeen(nonceKey, Math.max(ttl, 60))
42
- }
56
+ if (nonceStore && await nonceStore.hasSeen(nonceKey)) return null
43
57
 
44
58
  if (config.verifySigner) {
45
59
  const verified = await config.verifySigner(raw)
@@ -48,6 +62,14 @@ export async function verifyX402(
48
62
  return null
49
63
  }
50
64
 
65
+ // Check and mark only after the signature is accepted. Otherwise an
66
+ // invalid request can burn a valid payer nonce and deny the real request.
67
+ if (nonceStore) {
68
+ // Mark seen with TTL matching the expiry window (max 1 hour)
69
+ const ttl = Math.min(Number(expiry) - Math.floor(Date.now() / 1000), 3600)
70
+ await nonceStore.markSeen(nonceKey, Math.max(ttl, 60))
71
+ }
72
+
51
73
  return raw.commitment
52
74
  } catch {
53
75
  return null
@@ -57,43 +79,88 @@ export async function verifyX402(
57
79
  /**
58
80
  * Verify MPP (Machine Payments Protocol) Authorization: Payment header.
59
81
  *
60
- * MPP uses `Authorization: Payment <method> <credential>` format where
61
- * the credential is a base64url-encoded JSON wrapping the same EIP-3009
62
- * payment payload that x402 uses. This means existing x402 wallets work
63
- * unchanged over the MPP wire format.
82
+ * MPP uses `Authorization: Payment <method> <credential>` format. The
83
+ * credential is method-specific; `MppConfig.verifySigner` owns verification
84
+ * and returns the consumer identity. The built-in `blueprintevm` path can
85
+ * reuse the x402 verifier for credentials with the compatible payload shape.
64
86
  *
65
87
  * Returns the signer address if valid, null otherwise.
66
- * In demo mode, accepts any well-formed Payment header.
88
+ * In demo mode, accepts any well-formed Payment header with an identity.
67
89
  */
68
90
  export async function verifyMpp(
69
91
  authHeader: string,
70
- _config: MppConfig,
92
+ config: MppConfig,
71
93
  x402Config: X402Config,
94
+ nonceStore?: NonceStore,
72
95
  ): Promise<string | null> {
73
96
  // MPP format: "Payment <method> <base64url-credential>"
74
97
  const match = authHeader.match(/^Payment\s+(\S+)\s+(\S+)$/i)
75
98
  if (!match) return null
76
99
 
77
- const [, , credentialB64] = match
100
+ const [, method, credentialB64] = match
101
+ if (config.method && method !== config.method) return null
78
102
 
79
103
  try {
80
- // Decode base64url credential → JSON with the same EIP-3009 payload
104
+ if (!/^[A-Za-z0-9_-]+$/.test(credentialB64)) return null
81
105
  const decoded = Buffer.from(credentialB64, 'base64url').toString('utf-8')
82
- const credential = JSON.parse(decoded)
106
+ let payload: Record<string, unknown> = {}
107
+ try {
108
+ const credential = JSON.parse(decoded) as unknown
109
+ if (credential && typeof credential === 'object' && !Array.isArray(credential)) {
110
+ const nested = (credential as Record<string, unknown>).payload
111
+ payload =
112
+ nested && typeof nested === 'object' && !Array.isArray(nested)
113
+ ? (nested as Record<string, unknown>)
114
+ : (credential as Record<string, unknown>)
115
+ }
116
+ } catch {
117
+ // Method-specific verifiers may accept a non-JSON credential format.
118
+ }
119
+
120
+ const nonceKey =
121
+ nonceStore && payload.nonce !== undefined
122
+ ? `mpp:${method}:${String(payload.commitment ?? payload.from ?? 'unknown')}:${String(payload.nonce)}`
123
+ : null
124
+ if (nonceKey && await nonceStore!.hasSeen(nonceKey)) return null
83
125
 
84
- // The credential payload wraps the same fields x402 uses
85
- const payload = credential.payload ?? credential
86
- if (!payload.commitment && !payload.from) return null
126
+ let consumerId: string | null = null
127
+ if (config.verifySigner) {
128
+ consumerId = await config.verifySigner(payload, { method, credential: decoded })
129
+ } else if (method === 'blueprintevm' && x402Config.verifySigner && payload.commitment) {
130
+ const verified = await x402Config.verifySigner(payload)
131
+ consumerId = verified ? String(payload.commitment) : null
132
+ } else if (x402Config.demoMode) {
133
+ const identity = payload.commitment ?? payload.from
134
+ if (typeof identity !== 'string' || identity.length === 0) return null
135
+ consumerId = identity
136
+ } else {
137
+ return null
138
+ }
139
+ if (!consumerId) return null
87
140
 
88
- // Validate operator match (same as x402)
141
+ // Validate common EVM fields when present. Method-specific verifiers own
142
+ // the complete credential contract for non-EVM methods.
89
143
  const operator = payload.operator ?? payload.to
90
- if (operator && operator.toLowerCase() !== x402Config.operatorAddress.toLowerCase()) return null
144
+ if (operator !== undefined) {
145
+ if (typeof operator !== 'string' || operator.toLowerCase() !== x402Config.operatorAddress.toLowerCase()) {
146
+ return null
147
+ }
148
+ }
149
+ if (payload.amount !== undefined && BigInt(String(payload.amount)) <= 0n) return null
150
+ if (payload.nonce !== undefined) BigInt(String(payload.nonce))
151
+ if (payload.expiry !== undefined && BigInt(String(payload.expiry)) < BigInt(Math.floor(Date.now() / 1000))) {
152
+ return null
153
+ }
91
154
 
92
- // Validate bigint fields if present
93
- if (payload.amount) BigInt(payload.amount)
94
- if (payload.nonce) BigInt(payload.nonce)
155
+ if (nonceStore && payload.nonce !== undefined) {
156
+ const expiry = payload.expiry === undefined
157
+ ? Math.floor(Date.now() / 1000) + 3600
158
+ : Number(payload.expiry)
159
+ const ttl = Math.min(expiry - Math.floor(Date.now() / 1000), 3600)
160
+ await nonceStore.markSeen(nonceKey!, Math.max(ttl, 60))
161
+ }
95
162
 
96
- return payload.commitment ?? payload.from ?? null
163
+ return consumerId
97
164
  } catch {
98
165
  return null
99
166
  }