@tangle-network/agent-gateway 0.1.0 → 0.3.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.
package/src/middleware.ts CHANGED
@@ -10,6 +10,7 @@ import { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'
10
10
  import { filterConsumerMessagesStrict, redactSystemPromptFromOutput } from './filter'
11
11
  import { checkRateLimit, MemoryRateLimitStore, type RateLimitStore } from './rate-limit'
12
12
  import { MemoryNonceStore } from './nonce-store'
13
+ import { generateRequestId, type GatewayObserver, type RequestContext } from './observer'
13
14
 
14
15
  /**
15
16
  * Create a Hono router that serves the agent gateway.
@@ -28,6 +29,7 @@ export function createAgentGateway(config: GatewayConfig) {
28
29
  const globalRateLimit = config.rateLimit ?? { limit: 60, windowSeconds: 60 }
29
30
  const nonceStore = config.nonceStore ?? new MemoryNonceStore()
30
31
  const requiredScope = config.requiredScope ?? 'chat'
32
+ const obs: GatewayObserver | undefined = config.observer
31
33
 
32
34
  // --- Discovery endpoint (no auth) ---
33
35
 
@@ -75,6 +77,10 @@ export function createAgentGateway(config: GatewayConfig) {
75
77
  gw.post('/:slug/chat/completions', async (c) => {
76
78
  const slug = c.req.param('slug')
77
79
  const startMs = Date.now()
80
+ const requestId = generateRequestId()
81
+ const ctx: RequestContext = { requestId, agentSlug: slug, startMs }
82
+
83
+ await obs?.onRequestStart?.(ctx)
78
84
 
79
85
  // 1. Resolve agent
80
86
  const agent = await config.resolveAgent(slug)
@@ -85,6 +91,7 @@ export function createAgentGateway(config: GatewayConfig) {
85
91
  // 2. Body size limit (before parsing — DoS prevention)
86
92
  const contentLength = parseInt(c.req.header('Content-Length') ?? '0', 10)
87
93
  if (contentLength > 65536) {
94
+ await obs?.onBodyTooLarge?.(ctx, contentLength)
88
95
  return c.json(
89
96
  { error: { message: 'Request body too large (max 64KB)', type: 'invalid_request' } },
90
97
  413,
@@ -111,9 +118,10 @@ export function createAgentGateway(config: GatewayConfig) {
111
118
  if (spendAuthHeader) {
112
119
  const signer = await verifyX402(spendAuthHeader, config.x402, nonceStore)
113
120
  if (!signer) {
121
+ await obs?.onAuthFailure?.(ctx, { method: 'x402', code: 'invalid_spend_auth', httpStatus: 402 })
114
122
  return c.json(
115
123
  { error: { message: 'Invalid X-Payment-Signature', type: 'authentication_error', code: 'invalid_spend_auth' } },
116
- { status: 402, headers: { 'X-Payment-Required': 'spendauth' } },
124
+ { status: 402, headers: { 'X-Payment-Required': 'spendauth', 'X-Request-Id': requestId } },
117
125
  )
118
126
  }
119
127
  consumerId = signer
@@ -123,9 +131,10 @@ export function createAgentGateway(config: GatewayConfig) {
123
131
  if (!signer) {
124
132
  const realm = config.mpp.realm
125
133
  const method = config.mpp.method ?? 'blueprintevm'
134
+ await obs?.onAuthFailure?.(ctx, { method: 'mpp', code: 'invalid_mpp_credential', httpStatus: 401 })
126
135
  return c.json(
127
136
  { error: { message: 'Invalid Payment credential', type: 'authentication_error', code: 'invalid_mpp_credential' } },
128
- { status: 401, headers: { 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"` } },
137
+ { status: 401, headers: { 'WWW-Authenticate': `Payment realm="${realm}", method="${method}"`, 'X-Request-Id': requestId } },
129
138
  )
130
139
  }
131
140
  consumerId = signer
@@ -134,14 +143,19 @@ export function createAgentGateway(config: GatewayConfig) {
134
143
  const verify = config.verifyApiKey ?? defaultVerifyApiKey
135
144
  const key = await verify(authHeader)
136
145
  if (!key) {
137
- return c.json({ error: { message: 'Invalid API key', type: 'authentication_error' } }, 401)
146
+ await obs?.onAuthFailure?.(ctx, { method: 'apikey', code: 'invalid_api_key', httpStatus: 401 })
147
+ return c.json(
148
+ { error: { message: 'Invalid API key', type: 'authentication_error' } },
149
+ { status: 401, headers: { 'X-Request-Id': requestId } },
150
+ )
138
151
  }
139
152
 
140
153
  // Scope enforcement — API key must include the required scope
141
154
  if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(requiredScope)) {
155
+ await obs?.onAuthFailure?.(ctx, { method: 'apikey', code: 'insufficient_scope', httpStatus: 403 })
142
156
  return c.json(
143
157
  { error: { message: `API key missing required scope: ${requiredScope}`, type: 'forbidden', code: 'insufficient_scope' } },
144
- 403,
158
+ { status: 403, headers: { 'X-Request-Id': requestId } },
145
159
  )
146
160
  }
147
161
 
@@ -150,11 +164,15 @@ export function createAgentGateway(config: GatewayConfig) {
150
164
  keyInfo = key
151
165
  } else {
152
166
  // No payment — return 402 with instructions
167
+ await obs?.onAuthFailure?.(ctx, { method: 'none', code: 'payment_required', httpStatus: 402 })
153
168
  const methods: string[] = ['x402']
154
169
  if (config.mpp) methods.push('mpp')
155
170
  methods.push('api_key')
156
171
 
157
- const headers: Record<string, string> = { 'X-Payment-Required': methods.join(', ') }
172
+ const headers: Record<string, string> = {
173
+ 'X-Payment-Required': methods.join(', '),
174
+ 'X-Request-Id': requestId,
175
+ }
158
176
  if (config.mpp) {
159
177
  headers['WWW-Authenticate'] = `Payment realm="${config.mpp.realm}", method="${config.mpp.method ?? 'blueprintevm'}"`
160
178
  }
@@ -180,6 +198,8 @@ export function createAgentGateway(config: GatewayConfig) {
180
198
  }, { status: 402, headers })
181
199
  }
182
200
 
201
+ await obs?.onPaymentVerified?.(ctx, { method: paymentMethod, consumerId: consumerId!, keyId: keyInfo?.keyId })
202
+
183
203
  // 4. Rate limit — per-key override or global
184
204
  const effectiveRateLimit = keyInfo?.rateLimitPerMinute
185
205
  ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }
@@ -187,9 +207,10 @@ export function createAgentGateway(config: GatewayConfig) {
187
207
 
188
208
  const rl = await checkRateLimit(consumerId!, effectiveRateLimit, rateLimitStore)
189
209
  if (!rl.allowed) {
210
+ await obs?.onRateLimited?.(ctx, { consumerId: consumerId!, retryAfterSeconds: rl.retryAfterSeconds ?? 60 })
190
211
  return c.json(
191
212
  { error: { message: 'Rate limit exceeded', type: 'rate_limit_error', retry_after: rl.retryAfterSeconds } },
192
- { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds ?? 60) } },
213
+ { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds ?? 60), 'X-Request-Id': requestId } },
193
214
  )
194
215
  }
195
216
 
@@ -197,13 +218,16 @@ export function createAgentGateway(config: GatewayConfig) {
197
218
  const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(body.messages, maxLen)
198
219
 
199
220
  if (injectionWarnings.length > 0) {
200
- // Log injection attempt
201
- console.warn(`[agent-gateway] injection detected from ${consumerId}: ${injectionWarnings.join(', ')}`)
221
+ await obs?.onInjectionDetected?.(ctx, {
222
+ consumerId: consumerId!,
223
+ patterns: injectionWarnings,
224
+ blocked: !!config.blockInjection,
225
+ })
202
226
 
203
227
  if (config.blockInjection) {
204
228
  return c.json(
205
229
  { error: { message: 'Request rejected: potential prompt injection detected', type: 'content_policy_violation' } },
206
- 400,
230
+ { status: 400, headers: { 'X-Request-Id': requestId } },
207
231
  )
208
232
  }
209
233
  // In non-blocking mode, continue but the warning is logged for auditing
@@ -219,7 +243,7 @@ export function createAgentGateway(config: GatewayConfig) {
219
243
  }
220
244
 
221
245
  // 6. Get sandbox and stream response with output filtering
222
- let inputTokens = Math.ceil(userMessage.length / 4)
246
+ const inputTokens = Math.ceil(userMessage.length / 4)
223
247
  let outputTokens = 0
224
248
 
225
249
  const stream = new ReadableStream({
@@ -272,7 +296,7 @@ export function createAgentGateway(config: GatewayConfig) {
272
296
  const ownerEarned = totalCost * (1 - agent.platformFeePercent)
273
297
  const platformFee = totalCost * agent.platformFeePercent
274
298
 
275
- await config.recordUsage({
299
+ const usageEvent = {
276
300
  agentId: agent.id,
277
301
  agentSlug: agent.slug,
278
302
  consumerId: consumerId!,
@@ -283,18 +307,26 @@ export function createAgentGateway(config: GatewayConfig) {
283
307
  ownerEarnedUsd: ownerEarned,
284
308
  platformFeeUsd: platformFee,
285
309
  durationMs: Date.now() - startMs,
286
- })
310
+ }
311
+
312
+ await config.recordUsage(usageEvent)
313
+ await obs?.onRequestComplete?.(ctx, usageEvent)
287
314
 
288
315
  if (config.settlePayment) {
289
- await config.settlePayment({ method: paymentMethod, consumerId: consumerId! }, totalCost).catch(err => {
290
- console.error(`[agent-gateway] settlement failed for ${consumerId}: ${err instanceof Error ? err.message : err}`)
316
+ await config.settlePayment({ method: paymentMethod, consumerId: consumerId! }, totalCost).catch(async err => {
317
+ const msg = err instanceof Error ? err.message : String(err)
318
+ console.error(`[agent-gateway] settlement failed for ${consumerId}: ${msg}`)
319
+ await obs?.onSettlementError?.(ctx, { consumerId: consumerId!, method: paymentMethod, errorMessage: msg })
291
320
  })
292
321
  }
293
322
  } catch (err) {
294
323
  // Sanitize error — never expose stack traces or internal paths
295
- const safeMessage = err instanceof Error
296
- ? (err.message.includes('/') || err.message.includes('\\') ? 'Internal agent error' : err.message)
297
- : 'Internal agent error'
324
+ const rawMessage = err instanceof Error ? err.message : String(err)
325
+ const safeMessage =
326
+ rawMessage.includes('/') || rawMessage.includes('\\')
327
+ ? 'Internal agent error'
328
+ : rawMessage
329
+ await obs?.onStreamError?.(ctx, { consumerId: consumerId!, errorMessage: rawMessage })
298
330
  controller.enqueue(
299
331
  encoder.encode(`data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\n\n`),
300
332
  )
@@ -308,6 +340,7 @@ export function createAgentGateway(config: GatewayConfig) {
308
340
  headers: {
309
341
  'Content-Type': 'text/event-stream',
310
342
  'Cache-Control': 'no-cache',
343
+ 'X-Request-Id': requestId,
311
344
  'X-Agent-Slug': agent.slug,
312
345
  'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',
313
346
  'X-Payment-Method': paymentMethod,
@@ -10,7 +10,11 @@ export interface NonceStore {
10
10
  markSeen(nonce: string, ttlSeconds: number): Promise<void>
11
11
  }
12
12
 
13
- /** In-memory nonce store with automatic eviction */
13
+ // ---------------------------------------------------------------------------
14
+ // In-memory implementation — single-worker, ephemeral
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** In-memory nonce store with automatic eviction. Use in tests or single-worker deploys. */
14
18
  export class MemoryNonceStore implements NonceStore {
15
19
  private seen = new Map<string, number>() // nonce → expiresAt
16
20
  private lastEviction = Date.now()
@@ -41,3 +45,58 @@ export class MemoryNonceStore implements NonceStore {
41
45
  }
42
46
  }
43
47
  }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Cloudflare KV implementation — multi-worker, distributed
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /**
54
+ * Minimal KVNamespace shape — matches Cloudflare Workers' @cloudflare/workers-types
55
+ * without pulling that package as a dep. Production consumers cast their KV
56
+ * binding to this interface at the construction site.
57
+ */
58
+ export interface KVNamespace {
59
+ get(key: string, options?: { type?: 'text' | 'json' }): Promise<string | null>
60
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
61
+ delete(key: string): Promise<void>
62
+ }
63
+
64
+ /**
65
+ * KV-backed NonceStore for distributed Cloudflare Workers deployments.
66
+ *
67
+ * Why this exists: MemoryNonceStore works on a single worker instance, but
68
+ * Cloudflare routes requests across multiple isolates. Without shared state,
69
+ * an attacker could retry a replayed nonce against a different isolate and
70
+ * have it accepted. This implementation uses Workers KV with native TTL so
71
+ * the nonce automatically expires at payment-expiry time.
72
+ *
73
+ * TTL precision: KV is eventually consistent (propagation ~60s). For x402
74
+ * with 10-minute expiry windows this is fine — by the time KV propagates,
75
+ * the payment itself would be expired anyway.
76
+ *
77
+ * Usage:
78
+ * const nonceStore = new KvNonceStore(env.NONCE_KV, 'x402')
79
+ * createAgentGateway({ ...config, nonceStore })
80
+ */
81
+ export class KvNonceStore implements NonceStore {
82
+ constructor(
83
+ private readonly kv: KVNamespace,
84
+ /** Key prefix to namespace within a shared KV (default: "nonce"). */
85
+ private readonly prefix: string = 'nonce',
86
+ ) {}
87
+
88
+ async hasSeen(nonce: string): Promise<boolean> {
89
+ const value = await this.kv.get(this.key(nonce))
90
+ return value !== null
91
+ }
92
+
93
+ async markSeen(nonce: string, ttlSeconds: number): Promise<void> {
94
+ // KV minimum TTL is 60 seconds
95
+ const ttl = Math.max(ttlSeconds, 60)
96
+ await this.kv.put(this.key(nonce), '1', { expirationTtl: ttl })
97
+ }
98
+
99
+ private key(nonce: string): string {
100
+ return `${this.prefix}:${nonce}`
101
+ }
102
+ }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Observability hook surface.
3
+ *
4
+ * Consumers implement GatewayObserver to wire the gateway into their existing
5
+ * telemetry stack (Langfuse, OTEL, structured logs, Prometheus, etc.) without
6
+ * the gateway itself depending on any of those libraries.
7
+ *
8
+ * Every event carries a requestId so downstream metrics can correlate the
9
+ * payment verification, sandbox execution, and settlement for one request.
10
+ * When no observer is configured, the gateway stays silent.
11
+ */
12
+
13
+ import type { PaymentMethod, GatewayUsageEvent } from './types'
14
+
15
+ export interface RequestContext {
16
+ requestId: string
17
+ agentSlug: string
18
+ startMs: number
19
+ }
20
+
21
+ export interface AuthFailureReason {
22
+ method: 'x402' | 'mpp' | 'apikey' | 'none'
23
+ code: string
24
+ httpStatus: number
25
+ }
26
+
27
+ export interface GatewayObserver {
28
+ /** Called at the start of every chat completions POST. */
29
+ onRequestStart?: (ctx: RequestContext) => void | Promise<void>
30
+
31
+ /** Called when a payment method has been successfully verified. */
32
+ onPaymentVerified?: (ctx: RequestContext, info: {
33
+ method: PaymentMethod
34
+ consumerId: string
35
+ keyId?: string
36
+ }) => void | Promise<void>
37
+
38
+ /** Called when auth fails — every branch. */
39
+ onAuthFailure?: (ctx: RequestContext, reason: AuthFailureReason) => void | Promise<void>
40
+
41
+ /** Called when a consumer hits the rate limit. */
42
+ onRateLimited?: (ctx: RequestContext, info: {
43
+ consumerId: string
44
+ retryAfterSeconds: number
45
+ }) => void | Promise<void>
46
+
47
+ /** Called when the request body exceeds the 64KB limit. */
48
+ onBodyTooLarge?: (ctx: RequestContext, contentLength: number) => void | Promise<void>
49
+
50
+ /**
51
+ * Called when prompt-injection patterns are detected.
52
+ * `blocked` is true when blockInjection config is on and the request was
53
+ * rejected; false when the patterns were logged but the request proceeded.
54
+ */
55
+ onInjectionDetected?: (ctx: RequestContext, info: {
56
+ consumerId: string
57
+ patterns: string[]
58
+ blocked: boolean
59
+ }) => void | Promise<void>
60
+
61
+ /** Called after a successful stream completes and recordUsage has fired. */
62
+ onRequestComplete?: (ctx: RequestContext, usage: GatewayUsageEvent) => void | Promise<void>
63
+
64
+ /** Called when the sandbox throws. The error message is pre-scrubbed. */
65
+ onStreamError?: (ctx: RequestContext, info: {
66
+ consumerId: string
67
+ errorMessage: string
68
+ }) => void | Promise<void>
69
+
70
+ /** Called when settlement fails. Payment already occurred; this is async bookkeeping. */
71
+ onSettlementError?: (ctx: RequestContext, info: {
72
+ consumerId: string
73
+ method: PaymentMethod
74
+ errorMessage: string
75
+ }) => void | Promise<void>
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Convenience implementations
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Structured-log observer. Emits one JSON line per event on the `log` function.
84
+ * Default sink: console.log. Production consumers usually pipe their own
85
+ * structured logger (pino, winston, the cf Logs binding).
86
+ *
87
+ * Usage:
88
+ * new ConsoleObserver(({ level, event, ...rest }) => logger.info({ event, ...rest }))
89
+ */
90
+ export class ConsoleObserver implements GatewayObserver {
91
+ constructor(
92
+ private readonly log: (entry: Record<string, unknown>) => void = (e) => console.log(JSON.stringify(e)),
93
+ ) {}
94
+
95
+ private emit(level: 'info' | 'warn' | 'error', event: string, ctx: RequestContext, rest: Record<string, unknown> = {}) {
96
+ this.log({
97
+ level,
98
+ event,
99
+ time: new Date().toISOString(),
100
+ requestId: ctx.requestId,
101
+ agentSlug: ctx.agentSlug,
102
+ durationMs: Date.now() - ctx.startMs,
103
+ ...rest,
104
+ })
105
+ }
106
+
107
+ onRequestStart(ctx: RequestContext) { this.emit('info', 'gateway.request.start', ctx) }
108
+ onPaymentVerified(ctx: RequestContext, info: { method: PaymentMethod; consumerId: string; keyId?: string }) {
109
+ this.emit('info', 'gateway.payment.verified', ctx, info)
110
+ }
111
+ onAuthFailure(ctx: RequestContext, reason: AuthFailureReason) {
112
+ this.emit('warn', 'gateway.auth.failure', ctx, reason as unknown as Record<string, unknown>)
113
+ }
114
+ onRateLimited(ctx: RequestContext, info: { consumerId: string; retryAfterSeconds: number }) {
115
+ this.emit('warn', 'gateway.rate_limit', ctx, info)
116
+ }
117
+ onBodyTooLarge(ctx: RequestContext, contentLength: number) {
118
+ this.emit('warn', 'gateway.body_too_large', ctx, { contentLength })
119
+ }
120
+ onInjectionDetected(ctx: RequestContext, info: { consumerId: string; patterns: string[]; blocked: boolean }) {
121
+ this.emit('warn', 'gateway.injection', ctx, info)
122
+ }
123
+ onRequestComplete(ctx: RequestContext, usage: GatewayUsageEvent) {
124
+ this.emit('info', 'gateway.request.complete', ctx, usage as unknown as Record<string, unknown>)
125
+ }
126
+ onStreamError(ctx: RequestContext, info: { consumerId: string; errorMessage: string }) {
127
+ this.emit('error', 'gateway.stream.error', ctx, info)
128
+ }
129
+ onSettlementError(ctx: RequestContext, info: { consumerId: string; method: PaymentMethod; errorMessage: string }) {
130
+ this.emit('error', 'gateway.settlement.error', ctx, info)
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Compose multiple observers into one. Errors in any individual observer
136
+ * don't break the others (fire-and-forget telemetry).
137
+ */
138
+ export class CompositeObserver implements GatewayObserver {
139
+ constructor(private readonly observers: GatewayObserver[]) {}
140
+
141
+ private async fanOut<K extends keyof GatewayObserver>(event: K, ...args: unknown[]): Promise<void> {
142
+ for (const obs of this.observers) {
143
+ const fn = obs[event] as ((...a: unknown[]) => void | Promise<void>) | undefined
144
+ if (!fn) continue
145
+ try {
146
+ await fn.apply(obs, args)
147
+ } catch (err) {
148
+ console.warn(`[agent-gateway] observer ${event} threw:`, err instanceof Error ? err.message : err)
149
+ }
150
+ }
151
+ }
152
+
153
+ onRequestStart = (ctx: RequestContext) => this.fanOut('onRequestStart', ctx)
154
+ onPaymentVerified = (ctx: RequestContext, info: Parameters<Required<GatewayObserver>['onPaymentVerified']>[1]) =>
155
+ this.fanOut('onPaymentVerified', ctx, info)
156
+ onAuthFailure = (ctx: RequestContext, reason: AuthFailureReason) =>
157
+ this.fanOut('onAuthFailure', ctx, reason)
158
+ onRateLimited = (ctx: RequestContext, info: Parameters<Required<GatewayObserver>['onRateLimited']>[1]) =>
159
+ this.fanOut('onRateLimited', ctx, info)
160
+ onBodyTooLarge = (ctx: RequestContext, contentLength: number) =>
161
+ this.fanOut('onBodyTooLarge', ctx, contentLength)
162
+ onInjectionDetected = (ctx: RequestContext, info: Parameters<Required<GatewayObserver>['onInjectionDetected']>[1]) =>
163
+ this.fanOut('onInjectionDetected', ctx, info)
164
+ onRequestComplete = (ctx: RequestContext, usage: GatewayUsageEvent) =>
165
+ this.fanOut('onRequestComplete', ctx, usage)
166
+ onStreamError = (ctx: RequestContext, info: Parameters<Required<GatewayObserver>['onStreamError']>[1]) =>
167
+ this.fanOut('onStreamError', ctx, info)
168
+ onSettlementError = (ctx: RequestContext, info: Parameters<Required<GatewayObserver>['onSettlementError']>[1]) =>
169
+ this.fanOut('onSettlementError', ctx, info)
170
+ }
171
+
172
+ /**
173
+ * Generate a request-id. Crypto-random 16 bytes, hex-encoded with an `req_` prefix.
174
+ * Works in Workers, Node, and browsers — all have globalThis.crypto.
175
+ */
176
+ export function generateRequestId(): string {
177
+ const bytes = new Uint8Array(16)
178
+ globalThis.crypto.getRandomValues(bytes)
179
+ const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('')
180
+ return `req_${hex}`
181
+ }
package/src/rate-limit.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  /**
2
- * Sliding window rate limiter.
3
- * In-memory by default. Override with KV-backed store for Workers.
2
+ * Sliding-window rate limiter.
3
+ *
4
+ * Two implementations:
5
+ * - MemoryRateLimitStore — single-worker, ephemeral, good for tests
6
+ * - KvRateLimitStore — Cloudflare Workers KV, distributed
4
7
  */
5
8
 
6
9
  export interface RateLimitConfig {
@@ -24,6 +27,10 @@ export interface RateLimitStore {
24
27
  set(key: string, timestamps: number[], ttlSeconds: number): Promise<void>
25
28
  }
26
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // In-memory implementation
32
+ // ---------------------------------------------------------------------------
33
+
27
34
  /** In-memory rate limit store with periodic eviction */
28
35
  export class MemoryRateLimitStore implements RateLimitStore {
29
36
  private store = new Map<string, { timestamps: number[]; expiresAt: number }>()
@@ -53,6 +60,60 @@ export class MemoryRateLimitStore implements RateLimitStore {
53
60
  }
54
61
  }
55
62
 
63
+ // ---------------------------------------------------------------------------
64
+ // Cloudflare KV implementation
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /** Minimal KV shape — see nonce-store.ts for rationale. */
68
+ export interface KVNamespace {
69
+ get(key: string, options?: { type?: 'text' | 'json' }): Promise<string | null>
70
+ put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
71
+ delete(key: string): Promise<void>
72
+ }
73
+
74
+ /**
75
+ * KV-backed RateLimitStore for distributed Cloudflare Workers deployments.
76
+ *
77
+ * Stores timestamp arrays per consumer. Reads are O(1); writes replace the
78
+ * full array (cap is already filtered by checkRateLimit before write).
79
+ *
80
+ * Consistency note: Workers KV is eventually consistent within ~60s. An
81
+ * attacker sitting on two isolates could technically exceed the limit by
82
+ * ~2x for that window. For payment rate limits this is acceptable; for
83
+ * abuse prevention on free endpoints consider Durable Objects instead.
84
+ */
85
+ export class KvRateLimitStore implements RateLimitStore {
86
+ constructor(
87
+ private readonly kv: KVNamespace,
88
+ private readonly prefix: string = 'rl',
89
+ ) {}
90
+
91
+ async get(key: string): Promise<number[]> {
92
+ const raw = await this.kv.get(this.key(key))
93
+ if (!raw) return []
94
+ try {
95
+ const arr = JSON.parse(raw) as unknown
96
+ return Array.isArray(arr) ? (arr as number[]).filter((t) => typeof t === 'number') : []
97
+ } catch {
98
+ return []
99
+ }
100
+ }
101
+
102
+ async set(key: string, timestamps: number[], ttlSeconds: number): Promise<void> {
103
+ // KV minimum TTL is 60 seconds
104
+ const ttl = Math.max(ttlSeconds, 60)
105
+ await this.kv.put(this.key(key), JSON.stringify(timestamps), { expirationTtl: ttl })
106
+ }
107
+
108
+ private key(key: string): string {
109
+ return `${this.prefix}:${key}`
110
+ }
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Core limiter
115
+ // ---------------------------------------------------------------------------
116
+
56
117
  export async function checkRateLimit(
57
118
  consumerId: string,
58
119
  config: RateLimitConfig,
package/src/types.ts CHANGED
@@ -147,6 +147,14 @@ export interface GatewayConfig {
147
147
 
148
148
  /** Nonce replay protection store (default: in-memory). Rejects reused x402 nonces. */
149
149
  nonceStore?: import('./nonce-store').NonceStore
150
+
151
+ /**
152
+ * Observability hook. When set, the gateway emits typed events for request
153
+ * lifecycle, auth outcomes, rate limits, injection detection, usage, errors,
154
+ * and settlement failures. See ./observer.ts for the interface and
155
+ * ConsoleObserver / CompositeObserver implementations.
156
+ */
157
+ observer?: import('./observer').GatewayObserver
150
158
  }
151
159
 
152
160
  // --- Chat completion types (OpenAI-compatible) ---
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/rate-limit.ts"],"sourcesContent":["/**\n * Sliding window rate limiter.\n * In-memory by default. Override with KV-backed store for Workers.\n */\n\nexport interface RateLimitConfig {\n /** Max requests per window (default: 60) */\n limit: number\n /** Window size in seconds (default: 60) */\n windowSeconds: number\n}\n\nexport interface RateLimitResult {\n allowed: boolean\n remaining: number\n resetAt: number\n retryAfterSeconds?: number\n}\n\nexport interface RateLimitStore {\n /** Get timestamps of recent requests for this key */\n get(key: string): Promise<number[]>\n /** Set timestamps for this key (with TTL) */\n set(key: string, timestamps: number[], ttlSeconds: number): Promise<void>\n}\n\n/** In-memory rate limit store with periodic eviction */\nexport class MemoryRateLimitStore implements RateLimitStore {\n private store = new Map<string, { timestamps: number[]; expiresAt: number }>()\n private lastEviction = Date.now()\n\n async get(key: string): Promise<number[]> {\n this.evictExpired()\n const entry = this.store.get(key)\n if (!entry || entry.expiresAt < Date.now()) {\n this.store.delete(key)\n return []\n }\n return entry.timestamps\n }\n\n async set(key: string, timestamps: number[], ttlSeconds: number): Promise<void> {\n this.store.set(key, { timestamps, expiresAt: Date.now() + ttlSeconds * 1000 })\n }\n\n private evictExpired() {\n const now = Date.now()\n if (now - this.lastEviction < 30_000) return\n this.lastEviction = now\n for (const [key, entry] of this.store) {\n if (entry.expiresAt < now) this.store.delete(key)\n }\n }\n}\n\nexport async function checkRateLimit(\n consumerId: string,\n config: RateLimitConfig,\n store: RateLimitStore,\n): Promise<RateLimitResult> {\n const now = Date.now()\n const windowMs = config.windowSeconds * 1000\n const cutoff = now - windowMs\n\n const key = `rl:${consumerId}`\n const timestamps = (await store.get(key)).filter(t => t > cutoff)\n\n if (timestamps.length >= config.limit) {\n const oldestInWindow = Math.min(...timestamps)\n const resetAt = oldestInWindow + windowMs\n return {\n allowed: false,\n remaining: 0,\n resetAt,\n retryAfterSeconds: Math.ceil((resetAt - now) / 1000),\n }\n }\n\n timestamps.push(now)\n await store.set(key, timestamps, config.windowSeconds * 2)\n\n return {\n allowed: true,\n remaining: config.limit - timestamps.length,\n resetAt: now + windowMs,\n }\n}\n"],"mappings":";AA2BO,IAAM,uBAAN,MAAqD;AAAA,EAClD,QAAQ,oBAAI,IAAyD;AAAA,EACrE,eAAe,KAAK,IAAI;AAAA,EAEhC,MAAM,IAAI,KAAgC;AACxC,SAAK,aAAa;AAClB,UAAM,QAAQ,KAAK,MAAM,IAAI,GAAG;AAChC,QAAI,CAAC,SAAS,MAAM,YAAY,KAAK,IAAI,GAAG;AAC1C,WAAK,MAAM,OAAO,GAAG;AACrB,aAAO,CAAC;AAAA,IACV;AACA,WAAO,MAAM;AAAA,EACf;AAAA,EAEA,MAAM,IAAI,KAAa,YAAsB,YAAmC;AAC9E,SAAK,MAAM,IAAI,KAAK,EAAE,YAAY,WAAW,KAAK,IAAI,IAAI,aAAa,IAAK,CAAC;AAAA,EAC/E;AAAA,EAEQ,eAAe;AACrB,UAAM,MAAM,KAAK,IAAI;AACrB,QAAI,MAAM,KAAK,eAAe,IAAQ;AACtC,SAAK,eAAe;AACpB,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,OAAO;AACrC,UAAI,MAAM,YAAY,IAAK,MAAK,MAAM,OAAO,GAAG;AAAA,IAClD;AAAA,EACF;AACF;AAEA,eAAsB,eACpB,YACA,QACA,OAC0B;AAC1B,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,WAAW,OAAO,gBAAgB;AACxC,QAAM,SAAS,MAAM;AAErB,QAAM,MAAM,MAAM,UAAU;AAC5B,QAAM,cAAc,MAAM,MAAM,IAAI,GAAG,GAAG,OAAO,OAAK,IAAI,MAAM;AAEhE,MAAI,WAAW,UAAU,OAAO,OAAO;AACrC,UAAM,iBAAiB,KAAK,IAAI,GAAG,UAAU;AAC7C,UAAM,UAAU,iBAAiB;AACjC,WAAO;AAAA,MACL,SAAS;AAAA,MACT,WAAW;AAAA,MACX;AAAA,MACA,mBAAmB,KAAK,MAAM,UAAU,OAAO,GAAI;AAAA,IACrD;AAAA,EACF;AAEA,aAAW,KAAK,GAAG;AACnB,QAAM,MAAM,IAAI,KAAK,YAAY,OAAO,gBAAgB,CAAC;AAEzD,SAAO;AAAA,IACL,SAAS;AAAA,IACT,WAAW,OAAO,QAAQ,WAAW;AAAA,IACrC,SAAS,MAAM;AAAA,EACjB;AACF;","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/middleware.ts","../src/verify.ts","../src/filter.ts"],"sourcesContent":["import { Hono } from 'hono'\nimport type {\n GatewayConfig,\n ChatCompletionRequest,\n ChatCompletionChunk,\n PaymentMethod,\n ApiKeyInfo,\n} from './types'\nimport { verifyX402, verifyMpp, defaultVerifyApiKey } from './verify'\nimport { filterConsumerMessagesStrict, redactSystemPromptFromOutput } from './filter'\nimport { checkRateLimit, MemoryRateLimitStore, type RateLimitStore } from './rate-limit'\nimport { MemoryNonceStore } from './nonce-store'\n\n/**\n * Create a Hono router that serves the agent gateway.\n *\n * Mount at any path:\n * app.route('/v1/agents', createAgentGateway(config))\n *\n * Exposes:\n * GET /:slug/chat/completions — agent discovery metadata\n * POST /:slug/chat/completions — OpenAI-compatible chat endpoint (paid)\n */\nexport function createAgentGateway(config: GatewayConfig) {\n const gw = new Hono()\n const maxLen = config.maxMessageLength ?? 8000\n const rateLimitStore: RateLimitStore = config.rateLimitStore ?? new MemoryRateLimitStore()\n const globalRateLimit = config.rateLimit ?? { limit: 60, windowSeconds: 60 }\n const nonceStore = config.nonceStore ?? new MemoryNonceStore()\n const requiredScope = config.requiredScope ?? 'chat'\n\n // --- Discovery endpoint (no auth) ---\n\n gw.get('/:slug/chat/completions', async (c) => {\n const slug = c.req.param('slug')\n const agent = await config.resolveAgent(slug)\n if (!agent) return c.json({ error: 'Agent not found or not published' }, 404)\n\n const paymentMethods: Array<Record<string, unknown>> = [\n {\n type: 'x402',\n operator: config.x402.operatorAddress,\n chain_id: config.x402.chainId,\n credits_contract: config.x402.creditsAddress,\n },\n ]\n if (config.mpp) {\n paymentMethods.push({\n type: 'mpp',\n realm: config.mpp.realm,\n method: config.mpp.method ?? 'blueprintevm',\n })\n }\n paymentMethods.push({ type: 'api_key', prefix: 'sk_agent_' })\n\n return c.json({\n slug: agent.slug,\n pricing: {\n per_token_usd: agent.pricePerTokenUsd,\n currency: 'USD',\n platform_fee_percent: agent.platformFeePercent,\n },\n hosting: {\n mode: agent.sandboxEndpoint ? 'sovereign' : 'centralized',\n endpoint: agent.sandboxEndpoint ?? config.baseUrl ?? 'tangle.tools',\n },\n payment_methods: paymentMethods,\n capabilities: ['chat.completions', 'streaming'],\n openai_compatible: true,\n })\n })\n\n // --- Chat completions endpoint (paid) ---\n\n gw.post('/:slug/chat/completions', async (c) => {\n const slug = c.req.param('slug')\n const startMs = Date.now()\n\n // 1. Resolve agent\n const agent = await config.resolveAgent(slug)\n if (!agent) {\n return c.json({ error: { message: 'Agent not found', type: 'not_found' } }, 404)\n }\n\n // 2. Body size limit (before parsing — DoS prevention)\n const contentLength = parseInt(c.req.header('Content-Length') ?? '0', 10)\n if (contentLength > 65536) {\n return c.json(\n { error: { message: 'Request body too large (max 64KB)', type: 'invalid_request' } },\n 413,\n )\n }\n\n let body: ChatCompletionRequest\n try {\n body = await c.req.json()\n } catch {\n return c.json({ error: { message: 'Invalid JSON', type: 'invalid_request' } }, 400)\n }\n if (!body.messages?.length) {\n return c.json({ error: { message: 'messages array required', type: 'invalid_request' } }, 400)\n }\n\n // 3. Authenticate — x402 SpendAuth, MPP, or API key\n const spendAuthHeader = c.req.header('X-Payment-Signature')\n const authHeader = c.req.header('Authorization') ?? ''\n let consumerId: string | null = null\n let paymentMethod: PaymentMethod = 'none'\n let keyInfo: ApiKeyInfo | null = null\n\n if (spendAuthHeader) {\n const signer = await verifyX402(spendAuthHeader, config.x402, nonceStore)\n if (!signer) {\n return c.json(\n { error: { message: 'Invalid X-Payment-Signature', type: 'authentication_error', code: 'invalid_spend_auth' } },\n { status: 402, headers: { 'X-Payment-Required': 'spendauth' } },\n )\n }\n consumerId = signer\n paymentMethod = 'x402'\n } else if (config.mpp && authHeader.toLowerCase().startsWith('payment ')) {\n const signer = await verifyMpp(authHeader, config.mpp, config.x402)\n if (!signer) {\n const realm = config.mpp.realm\n const method = config.mpp.method ?? 'blueprintevm'\n return c.json(\n { error: { message: 'Invalid Payment credential', type: 'authentication_error', code: 'invalid_mpp_credential' } },\n { status: 401, headers: { 'WWW-Authenticate': `Payment realm=\"${realm}\", method=\"${method}\"` } },\n )\n }\n consumerId = signer\n paymentMethod = 'mpp'\n } else if (authHeader.startsWith('Bearer ')) {\n const verify = config.verifyApiKey ?? defaultVerifyApiKey\n const key = await verify(authHeader)\n if (!key) {\n return c.json({ error: { message: 'Invalid API key', type: 'authentication_error' } }, 401)\n }\n\n // Scope enforcement — API key must include the required scope\n if (key.scopes && key.scopes.length > 0 && !key.scopes.includes(requiredScope)) {\n return c.json(\n { error: { message: `API key missing required scope: ${requiredScope}`, type: 'forbidden', code: 'insufficient_scope' } },\n 403,\n )\n }\n\n consumerId = key.consumerId\n paymentMethod = 'apikey'\n keyInfo = key\n } else {\n // No payment — return 402 with instructions\n const methods: string[] = ['x402']\n if (config.mpp) methods.push('mpp')\n methods.push('api_key')\n\n const headers: Record<string, string> = { 'X-Payment-Required': methods.join(', ') }\n if (config.mpp) {\n headers['WWW-Authenticate'] = `Payment realm=\"${config.mpp.realm}\", method=\"${config.mpp.method ?? 'blueprintevm'}\"`\n }\n\n return c.json({\n error: {\n message: 'Payment required',\n type: 'payment_required',\n payment_methods: methods,\n x402: {\n operator: config.x402.operatorAddress,\n chain_id: config.x402.chainId,\n credits_address: config.x402.creditsAddress,\n estimated_amount_per_request: '20000',\n },\n ...(config.mpp ? {\n mpp: { realm: config.mpp.realm, method: config.mpp.method ?? 'blueprintevm' },\n } : {}),\n api_key: {\n purchase_url: config.baseUrl ? `${config.baseUrl}/agents/${slug}/api-keys` : undefined,\n },\n },\n }, { status: 402, headers })\n }\n\n // 4. Rate limit — per-key override or global\n const effectiveRateLimit = keyInfo?.rateLimitPerMinute\n ? { limit: keyInfo.rateLimitPerMinute, windowSeconds: 60 }\n : globalRateLimit\n\n const rl = await checkRateLimit(consumerId!, effectiveRateLimit, rateLimitStore)\n if (!rl.allowed) {\n return c.json(\n { error: { message: 'Rate limit exceeded', type: 'rate_limit_error', retry_after: rl.retryAfterSeconds } },\n { status: 429, headers: { 'Retry-After': String(rl.retryAfterSeconds ?? 60) } },\n )\n }\n\n // 5. Filter messages — injection detection + sanitization\n const { messages: filtered, injectionWarnings } = filterConsumerMessagesStrict(body.messages, maxLen)\n\n if (injectionWarnings.length > 0) {\n // Log injection attempt\n console.warn(`[agent-gateway] injection detected from ${consumerId}: ${injectionWarnings.join(', ')}`)\n\n if (config.blockInjection) {\n return c.json(\n { error: { message: 'Request rejected: potential prompt injection detected', type: 'content_policy_violation' } },\n 400,\n )\n }\n // In non-blocking mode, continue but the warning is logged for auditing\n }\n\n const userMessage = filtered\n .filter((m) => m.role === 'user')\n .map((m) => m.content)\n .join('\\n\\n')\n\n if (!userMessage) {\n return c.json({ error: { message: 'No user message provided', type: 'invalid_request' } }, 400)\n }\n\n // 6. Get sandbox and stream response with output filtering\n let inputTokens = Math.ceil(userMessage.length / 4)\n let outputTokens = 0\n\n const stream = new ReadableStream({\n async start(controller) {\n const encoder = new TextEncoder()\n const sendChunk = (rawDelta: string) => {\n // Redact system prompt leakage from output\n const delta = redactSystemPromptFromOutput(rawDelta, agent.systemPrompt)\n outputTokens += Math.ceil(delta.length / 4)\n const chunk: ChatCompletionChunk = {\n id: `chatcmpl-${Date.now()}`,\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: agent.slug,\n choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],\n }\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\\n\\n`))\n }\n\n try {\n const box = await config.getSandbox(agent)\n const promptStream = box.streamPrompt(userMessage, {\n sessionId: `consumer:${consumerId}`,\n systemPrompt: agent.systemPrompt,\n })\n\n for await (const event of promptStream) {\n if (\n event.type === 'message.part.updated' &&\n event.data?.part?.type === 'text' &&\n event.data.delta\n ) {\n sendChunk(event.data.delta)\n }\n }\n\n // Final chunk\n const done: ChatCompletionChunk = {\n id: `chatcmpl-${Date.now()}`,\n object: 'chat.completion.chunk',\n created: Math.floor(Date.now() / 1000),\n model: agent.slug,\n choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],\n }\n controller.enqueue(encoder.encode(`data: ${JSON.stringify(done)}\\n\\n`))\n controller.enqueue(encoder.encode('data: [DONE]\\n\\n'))\n\n // 7. Record usage + settle payment\n const totalCost = (inputTokens + outputTokens) * agent.pricePerTokenUsd\n const ownerEarned = totalCost * (1 - agent.platformFeePercent)\n const platformFee = totalCost * agent.platformFeePercent\n\n await config.recordUsage({\n agentId: agent.id,\n agentSlug: agent.slug,\n consumerId: consumerId!,\n paymentMethod,\n inputTokens,\n outputTokens,\n totalCostUsd: totalCost,\n ownerEarnedUsd: ownerEarned,\n platformFeeUsd: platformFee,\n durationMs: Date.now() - startMs,\n })\n\n if (config.settlePayment) {\n await config.settlePayment({ method: paymentMethod, consumerId: consumerId! }, totalCost).catch(err => {\n console.error(`[agent-gateway] settlement failed for ${consumerId}: ${err instanceof Error ? err.message : err}`)\n })\n }\n } catch (err) {\n // Sanitize error — never expose stack traces or internal paths\n const safeMessage = err instanceof Error\n ? (err.message.includes('/') || err.message.includes('\\\\') ? 'Internal agent error' : err.message)\n : 'Internal agent error'\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ error: { message: safeMessage, type: 'server_error' } })}\\n\\n`),\n )\n } finally {\n controller.close()\n }\n },\n })\n\n return new Response(stream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n 'X-Agent-Slug': agent.slug,\n 'X-Agent-Hosting': agent.sandboxEndpoint ? 'sovereign' : 'centralized',\n 'X-Payment-Method': paymentMethod,\n 'X-Payment-Settled': paymentMethod === 'x402' ? 'pending' : 'true',\n ...(rl.remaining !== undefined ? { 'X-RateLimit-Remaining': String(rl.remaining) } : {}),\n },\n })\n })\n\n return gw\n}\n","import type { X402Config, MppConfig, ApiKeyInfo } from './types'\nimport type { NonceStore } from './nonce-store'\n\n/**\n * Verify x402 SpendAuth signature (EIP-712).\n * Returns the signer address (commitment) if valid, null otherwise.\n *\n * DEMO MODE (demoMode: true): accepts any well-formed header structure.\n * PRODUCTION: requires config.verifySigner callback for on-chain verification.\n */\nexport async function verifyX402(\n spendAuthHeader: string,\n config: X402Config,\n nonceStore?: NonceStore,\n): Promise<string | null> {\n try {\n const raw = JSON.parse(spendAuthHeader)\n if (!raw.commitment || !raw.signature || !raw.amount) return null\n if (raw.operator?.toLowerCase() !== config.operatorAddress.toLowerCase()) return null\n\n const amount = BigInt(raw.amount)\n const nonce = BigInt(raw.nonce)\n const expiry = BigInt(raw.expiry)\n\n // Reject expired payments\n if (expiry < BigInt(Math.floor(Date.now() / 1000))) return null\n\n // Reject zero-amount payments\n if (amount <= 0n) return null\n\n // Reject replayed nonces\n const nonceKey = `${raw.commitment}:${nonce.toString()}`\n if (nonceStore) {\n if (await nonceStore.hasSeen(nonceKey)) return null\n // Mark seen with TTL matching the expiry window (max 1 hour)\n const ttl = Math.min(Number(expiry) - Math.floor(Date.now() / 1000), 3600)\n await nonceStore.markSeen(nonceKey, Math.max(ttl, 60))\n }\n\n // Production: delegate to on-chain verification\n if (config.verifySigner) {\n const verified = await config.verifySigner(raw)\n if (!verified) return null\n } else if (!config.demoMode) {\n console.warn('[agent-gateway] x402 verification running without verifySigner — set demoMode: true to suppress this warning')\n }\n\n return raw.commitment\n } catch {\n return null\n }\n}\n\n/**\n * Verify MPP (Machine Payments Protocol) Authorization: Payment header.\n *\n * MPP uses `Authorization: Payment <method> <credential>` format where\n * the credential is a base64url-encoded JSON wrapping the same EIP-3009\n * payment payload that x402 uses. This means existing x402 wallets work\n * unchanged over the MPP wire format.\n *\n * Returns the signer address if valid, null otherwise.\n * In demo mode, accepts any well-formed Payment header.\n */\nexport async function verifyMpp(\n authHeader: string,\n _config: MppConfig,\n x402Config: X402Config,\n): Promise<string | null> {\n // MPP format: \"Payment <method> <base64url-credential>\"\n const match = authHeader.match(/^Payment\\s+(\\S+)\\s+(\\S+)$/i)\n if (!match) return null\n\n const [, , credentialB64] = match\n\n try {\n // Decode base64url credential → JSON with the same EIP-3009 payload\n const decoded = Buffer.from(credentialB64, 'base64url').toString('utf-8')\n const credential = JSON.parse(decoded)\n\n // The credential payload wraps the same fields x402 uses\n const payload = credential.payload ?? credential\n if (!payload.commitment && !payload.from) return null\n\n // Validate operator match (same as x402)\n const operator = payload.operator ?? payload.to\n if (operator && operator.toLowerCase() !== x402Config.operatorAddress.toLowerCase()) return null\n\n // Validate bigint fields if present\n if (payload.amount) BigInt(payload.amount)\n if (payload.nonce) BigInt(payload.nonce)\n\n return payload.commitment ?? payload.from ?? null\n } catch {\n return null\n }\n}\n\n/**\n * Default API key verifier — accepts any `sk_agent_*` key (demo mode).\n * Override in GatewayConfig.verifyApiKey for production.\n */\nexport async function defaultVerifyApiKey(\n authHeader: string,\n): Promise<ApiKeyInfo | null> {\n if (!authHeader.startsWith('Bearer sk_agent_')) return null\n const key = authHeader.slice(7)\n return {\n keyId: key.slice(0, 16),\n consumerId: `apikey:${key.slice(0, 16)}`,\n }\n}\n","import type { ChatMessage } from './types'\n\n// --- Injection detection patterns ---\n\nconst INJECTION_PATTERNS = [\n // Direct instruction override\n /ignore\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|prompts?|rules?|directives?)/i,\n /disregard\\s+(all\\s+)?(previous|prior|system)/i,\n /forget\\s+(everything|all|your)\\s+(previous|instructions?|training)/i,\n // Role assumption\n /you\\s+are\\s+now\\s+(a|an|the)\\s+/i,\n /pretend\\s+(you\\s+are|to\\s+be)\\s+/i,\n /act\\s+as\\s+(if\\s+you\\s+are|a|an|the)\\s+/i,\n /new\\s+instructions?:/i,\n /\\[system\\]/i,\n /\\[INST\\]/i,\n // Prompt extraction\n /what\\s+(is|are)\\s+your\\s+(system\\s+)?(prompt|instructions?|rules?|directives?)/i,\n /repeat\\s+(your|the)\\s+(system\\s+)?(prompt|instructions?)/i,\n /output\\s+(your|the)\\s+(system\\s+)?(prompt|instructions?)/i,\n /show\\s+me\\s+(your|the)\\s+(system|hidden|secret)\\s+(prompt|instructions?|message)/i,\n // Data exfiltration\n /read\\s+(the\\s+)?(vault|workspace|config|secret|\\.env)/i,\n /cat\\s+\\/home\\/agent\\/(vault|config|\\.env|secrets?)/i,\n /list\\s+(all\\s+)?(vault|workspace|secret)\\s+(files?|contents?|data)/i,\n]\n\n// Unicode normalization — collapse homoglyphs and zero-width chars\nfunction normalizeUnicode(text: string): string {\n return text\n // Remove zero-width chars (ZWJ, ZWNJ, ZWS, ZWSP)\n .replace(/[\\u200B-\\u200F\\u2028-\\u202F\\u2060\\uFEFF]/g, '')\n // Normalize to NFKC (collapses homoglyphs like а→a, е→e)\n .normalize('NFKC')\n}\n\n/**\n * Detect prompt injection attempts.\n * Returns array of matched pattern descriptions, empty if clean.\n */\nexport function detectInjection(content: string): string[] {\n const normalized = normalizeUnicode(content)\n const matches: string[] = []\n\n for (const pattern of INJECTION_PATTERNS) {\n if (pattern.test(normalized)) {\n matches.push(pattern.source.slice(0, 60))\n }\n }\n\n // Check for base64-encoded injection attempts\n const b64Matches = normalized.match(/[A-Za-z0-9+/]{40,}={0,2}/g)\n if (b64Matches) {\n for (const b64 of b64Matches) {\n try {\n const decoded = atob(b64)\n if (INJECTION_PATTERNS.some(p => p.test(decoded))) {\n matches.push('base64-encoded injection')\n }\n } catch { /* not valid b64 */ }\n }\n }\n\n return matches\n}\n\n/**\n * Security boundary — filter consumer messages before forwarding to agent.\n *\n * Defense in depth:\n * 1. Strip system messages (consumers cannot set system prompt)\n * 2. Normalize Unicode (collapse homoglyphs, remove zero-width chars)\n * 3. Detect injection patterns (instruction override, prompt extraction, data exfil)\n * 4. Redact sensitive keywords\n * 5. Cap message length\n *\n * Returns filtered messages and any injection warnings detected.\n */\nexport function filterConsumerMessages(\n messages: ChatMessage[],\n maxLength = 8000,\n): ChatMessage[] {\n return messages\n .filter((m) => m.role !== 'system')\n .map((m) => {\n const normalized = normalizeUnicode(m.content)\n const redacted = normalized\n .replace(/\\b(vault|workspace|owner|admin|secret|\\.env|config\\.json)[\\s/:][^\\s]*/gi, '[REDACTED]')\n .slice(0, maxLength)\n return { role: m.role, content: redacted }\n })\n}\n\n/**\n * Filter consumer messages with injection detection.\n * Returns { messages, injectionWarnings }.\n * If injectionWarnings is non-empty, the gateway should log and optionally reject.\n */\nexport function filterConsumerMessagesStrict(\n messages: ChatMessage[],\n maxLength = 8000,\n): { messages: ChatMessage[]; injectionWarnings: string[] } {\n const filtered = filterConsumerMessages(messages, maxLength)\n const allContent = filtered.map(m => m.content).join(' ')\n const injectionWarnings = detectInjection(allContent)\n return { messages: filtered, injectionWarnings }\n}\n\n/**\n * Redact system prompt content from agent output.\n * Prevents the agent from leaking its own instructions in responses.\n *\n * Strategy: if any chunk of the system prompt appears verbatim (>40 chars)\n * in the output, replace it with [REDACTED].\n */\nexport function redactSystemPromptFromOutput(\n output: string,\n systemPrompt: string | undefined,\n): string {\n if (!systemPrompt || systemPrompt.length < 40) return output\n\n // Split system prompt into meaningful chunks (sentences or lines)\n const chunks = systemPrompt\n .split(/[.\\n]/)\n .map(s => s.trim())\n .filter(s => s.length >= 40)\n\n let redacted = output\n for (const chunk of chunks) {\n // Case-insensitive substring match\n const idx = redacted.toLowerCase().indexOf(chunk.toLowerCase())\n if (idx >= 0) {\n redacted = redacted.slice(0, idx) + '[REDACTED — system instructions]' + redacted.slice(idx + chunk.length)\n }\n }\n\n return redacted\n}\n"],"mappings":"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","names":[]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/nonce-store.ts"],"sourcesContent":["/**\n * Nonce replay protection for x402/MPP payments.\n * Tracks seen nonces to prevent the same payment from being used twice.\n */\n\nexport interface NonceStore {\n /** Check if nonce has been seen. Returns true if already used (reject). */\n hasSeen(nonce: string): Promise<boolean>\n /** Mark nonce as used. TTL = how long to remember it (seconds). */\n markSeen(nonce: string, ttlSeconds: number): Promise<void>\n}\n\n/** In-memory nonce store with automatic eviction */\nexport class MemoryNonceStore implements NonceStore {\n private seen = new Map<string, number>() // nonce → expiresAt\n private lastEviction = Date.now()\n\n async hasSeen(nonce: string): Promise<boolean> {\n this.evictExpired()\n const expiresAt = this.seen.get(nonce)\n if (!expiresAt) return false\n if (expiresAt < Date.now()) {\n this.seen.delete(nonce)\n return false\n }\n return true\n }\n\n async markSeen(nonce: string, ttlSeconds: number): Promise<void> {\n this.seen.set(nonce, Date.now() + ttlSeconds * 1000)\n this.evictExpired()\n }\n\n private evictExpired() {\n const now = Date.now()\n // Evict at most every 60 seconds to avoid O(n) on every request\n if (now - this.lastEviction < 60_000) return\n this.lastEviction = now\n for (const [nonce, expiresAt] of this.seen) {\n if (expiresAt < now) this.seen.delete(nonce)\n }\n }\n}\n"],"mappings":";AAaO,IAAM,mBAAN,MAA6C;AAAA,EAC1C,OAAO,oBAAI,IAAoB;AAAA;AAAA,EAC/B,eAAe,KAAK,IAAI;AAAA,EAEhC,MAAM,QAAQ,OAAiC;AAC7C,SAAK,aAAa;AAClB,UAAM,YAAY,KAAK,KAAK,IAAI,KAAK;AACrC,QAAI,CAAC,UAAW,QAAO;AACvB,QAAI,YAAY,KAAK,IAAI,GAAG;AAC1B,WAAK,KAAK,OAAO,KAAK;AACtB,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAS,OAAe,YAAmC;AAC/D,SAAK,KAAK,IAAI,OAAO,KAAK,IAAI,IAAI,aAAa,GAAI;AACnD,SAAK,aAAa;AAAA,EACpB;AAAA,EAEQ,eAAe;AACrB,UAAM,MAAM,KAAK,IAAI;AAErB,QAAI,MAAM,KAAK,eAAe,IAAQ;AACtC,SAAK,eAAe;AACpB,eAAW,CAAC,OAAO,SAAS,KAAK,KAAK,MAAM;AAC1C,UAAI,YAAY,IAAK,MAAK,KAAK,OAAO,KAAK;AAAA,IAC7C;AAAA,EACF;AACF;","names":[]}