@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
@@ -4,10 +4,35 @@
4
4
  */
5
5
 
6
6
  export interface NonceStore {
7
- /** Check if nonce has been seen. Returns true if already used (reject). */
7
+ /** Check if nonce has been seen. This method never grants ownership. */
8
8
  hasSeen(nonce: string): Promise<boolean>
9
- /** Mark nonce as used. TTL = how long to remember it (seconds). */
10
- markSeen(nonce: string, ttlSeconds: number): Promise<void>
9
+ /**
10
+ * Atomically claim a nonce. An owner id makes a retry by the same payment
11
+ * operation idempotent. This is optional only for the 0.7.1 check-and-mark
12
+ * compatibility contract; durable owner claims require this method.
13
+ */
14
+ claim?(nonce: string, ttlSeconds: number, ownerId?: string): Promise<boolean>
15
+ /** @deprecated Use claim() for atomic ownership in new stores. */
16
+ markSeen?(nonce: string, ttlSeconds: number): Promise<void>
17
+ }
18
+
19
+ export interface AtomicNonceStore extends NonceStore {
20
+ claim(nonce: string, ttlSeconds: number, ownerId?: string): Promise<boolean>
21
+ }
22
+
23
+ /**
24
+ * Return the seconds for which a signed nonce must remain stored.
25
+ *
26
+ * The signed expiry is the replay boundary. A fixed one-hour cap would allow
27
+ * a still-valid authorization to replay after the nonce entry expires.
28
+ */
29
+ export function nonceTtlSeconds(
30
+ expiry: bigint,
31
+ nowSeconds = Math.floor(Date.now() / 1000),
32
+ ): number | undefined {
33
+ const remaining = expiry - BigInt(nowSeconds)
34
+ if (remaining <= 0n || remaining > BigInt(Number.MAX_SAFE_INTEGER)) return undefined
35
+ return Math.max(Number(remaining), 60)
11
36
  }
12
37
 
13
38
  // ---------------------------------------------------------------------------
@@ -16,23 +41,34 @@ export interface NonceStore {
16
41
 
17
42
  /** In-memory nonce store with automatic eviction. Use in tests or single-worker deploys. */
18
43
  export class MemoryNonceStore implements NonceStore {
19
- private seen = new Map<string, number>() // nonce → expiresAt
44
+ private seen = new Map<string, { expiresAt: number; ownerId?: string }>()
20
45
  private lastEviction = Date.now()
21
46
 
22
47
  async hasSeen(nonce: string): Promise<boolean> {
23
48
  this.evictExpired()
24
- const expiresAt = this.seen.get(nonce)
25
- if (!expiresAt) return false
26
- if (expiresAt < Date.now()) {
49
+ const entry = this.seen.get(nonce)
50
+ if (!entry) return false
51
+ if (entry.expiresAt < Date.now()) {
27
52
  this.seen.delete(nonce)
28
53
  return false
29
54
  }
30
55
  return true
31
56
  }
32
57
 
58
+ async claim(nonce: string, ttlSeconds: number, ownerId?: string): Promise<boolean> {
59
+ this.evictExpired()
60
+ const now = Date.now()
61
+ const entry = this.seen.get(nonce)
62
+ if (entry !== undefined && entry.expiresAt >= now) {
63
+ return ownerId !== undefined && entry.ownerId === ownerId
64
+ }
65
+ this.seen.set(nonce, { expiresAt: now + ttlSeconds * 1000, ownerId })
66
+ return true
67
+ }
68
+
33
69
  async markSeen(nonce: string, ttlSeconds: number): Promise<void> {
34
- this.seen.set(nonce, Date.now() + ttlSeconds * 1000)
35
70
  this.evictExpired()
71
+ this.seen.set(nonce, { expiresAt: Date.now() + ttlSeconds * 1000 })
36
72
  }
37
73
 
38
74
  private evictExpired() {
@@ -40,8 +76,8 @@ export class MemoryNonceStore implements NonceStore {
40
76
  // Evict at most every 60 seconds to avoid O(n) on every request
41
77
  if (now - this.lastEviction < 60_000) return
42
78
  this.lastEviction = now
43
- for (const [nonce, expiresAt] of this.seen) {
44
- if (expiresAt < now) this.seen.delete(nonce)
79
+ for (const [nonce, entry] of this.seen) {
80
+ if (entry.expiresAt < now) this.seen.delete(nonce)
45
81
  }
46
82
  }
47
83
  }
@@ -58,45 +94,111 @@ export class MemoryNonceStore implements NonceStore {
58
94
  export interface KVNamespace {
59
95
  get(key: string, options?: { type?: 'text' | 'json' }): Promise<string | null>
60
96
  put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
97
+ /** Optional linearizable create-if-absent extension. Cloudflare KV does not provide it. */
98
+ putIfAbsent?(key: string, value: string, options?: { expirationTtl?: number }): Promise<boolean>
61
99
  delete(key: string): Promise<void>
62
100
  }
63
101
 
102
+ /** Atomic claim supplied by D1, a Durable Object, or another linearizable store. */
103
+ export type AtomicKvNonceClaim = (
104
+ key: string,
105
+ ttlSeconds: number,
106
+ ownerId?: string,
107
+ ) => Promise<boolean>
108
+
109
+ export interface KvNonceStoreOptions {
110
+ /**
111
+ * Claim the fully namespaced key atomically.
112
+ * The callback must make same-owner retries idempotent.
113
+ */
114
+ atomicClaim?: AtomicKvNonceClaim
115
+ }
116
+
64
117
  /**
65
118
  * KV-backed NonceStore for distributed Cloudflare Workers deployments.
66
119
  *
67
120
  * Why this exists: MemoryNonceStore works on a single worker instance, but
68
121
  * Cloudflare routes requests across multiple isolates. Without shared state,
69
122
  * 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.
123
+ * have it accepted. Cloudflare KV has no conditional write, so a plain KV
124
+ * binding is not an atomic payment store. Supply `atomicClaim` from D1,
125
+ * Durable Objects, or another linearizable service before using this store
126
+ * for paid requests.
76
127
  *
77
128
  * Usage:
78
129
  * const nonceStore = new KvNonceStore(env.NONCE_KV, 'x402')
79
130
  * createAgentGateway({ ...config, nonceStore })
80
131
  */
81
132
  export class KvNonceStore implements NonceStore {
133
+ private readonly atomicClaim?: AtomicKvNonceClaim
134
+
82
135
  constructor(
83
136
  private readonly kv: KVNamespace,
84
137
  /** Key prefix to namespace within a shared KV (default: "nonce"). */
85
138
  private readonly prefix: string = 'nonce',
86
- ) {}
139
+ options: KvNonceStoreOptions = {},
140
+ ) {
141
+ this.atomicClaim = options.atomicClaim ?? (
142
+ kv.putIfAbsent
143
+ ? async (key, ttlSeconds, ownerId) => {
144
+ const value = ownerId ?? '1'
145
+ if (ownerId !== undefined) {
146
+ const existing = await kv.get(key)
147
+ if (existing !== null) return existing === ownerId
148
+ }
149
+ const inserted = await kv.putIfAbsent!(key, value, { expirationTtl: ttlSeconds })
150
+ if (inserted || ownerId === undefined) return inserted
151
+ return (await kv.get(key)) === ownerId
152
+ }
153
+ : undefined
154
+ )
155
+ }
87
156
 
88
157
  async hasSeen(nonce: string): Promise<boolean> {
89
- const value = await this.kv.get(this.key(nonce))
90
- return value !== null
158
+ return (await this.kv.get(this.key(nonce))) !== null
91
159
  }
92
160
 
93
161
  async markSeen(nonce: string, ttlSeconds: number): Promise<void> {
94
- // KV minimum TTL is 60 seconds
95
162
  const ttl = Math.max(ttlSeconds, 60)
96
163
  await this.kv.put(this.key(nonce), '1', { expirationTtl: ttl })
97
164
  }
98
165
 
166
+ async claim(nonce: string, ttlSeconds: number, ownerId?: string): Promise<boolean> {
167
+ if (!this.atomicClaim) {
168
+ throw new Error(
169
+ 'KvNonceStore requires an atomicClaim backed by D1, Durable Objects, or an atomic KV extension',
170
+ )
171
+ }
172
+ const ttl = Math.max(ttlSeconds, 60)
173
+ return this.atomicClaim(this.key(nonce), ttl, ownerId)
174
+ }
175
+
176
+ /** Used by gateway validation to reject plain, non-atomic KV bindings. */
177
+ hasAtomicClaim(): boolean {
178
+ return this.atomicClaim !== undefined
179
+ }
180
+
99
181
  private key(nonce: string): string {
100
182
  return `${this.prefix}:${nonce}`
101
183
  }
102
184
  }
185
+
186
+ /** Claim through the one atomic contract used by every payment path. */
187
+ export async function claimStoredNonce(
188
+ store: NonceStore,
189
+ nonce: string,
190
+ ttlSeconds: number,
191
+ ownerId?: string,
192
+ ): Promise<boolean> {
193
+ if (typeof store.claim !== 'function') {
194
+ throw new Error('NonceStore.claim is required for atomic payment replay protection')
195
+ }
196
+ return store.claim(nonce, ttlSeconds, ownerId)
197
+ }
198
+
199
+ /** Durable payment paths must use a store with a single atomic claim operation. */
200
+ export function isAtomicNonceStore(store: NonceStore): store is AtomicNonceStore {
201
+ const kvStore = store as NonceStore & { hasAtomicClaim?: () => boolean }
202
+ if (typeof kvStore.hasAtomicClaim === 'function' && !kvStore.hasAtomicClaim()) return false
203
+ return typeof store.claim === 'function'
204
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ GatewayUsageEvent,
3
+ PaymentMethod,
4
+ } from './payment-types'
5
+
6
+ export interface RequestContext {
7
+ requestId: string
8
+ agentSlug: string
9
+ startMs: number
10
+ }
11
+
12
+ export interface AuthFailureReason {
13
+ method: PaymentMethod
14
+ code: string
15
+ httpStatus: number
16
+ }
17
+
18
+ export interface GatewayObserver {
19
+ /** Called at the start of every chat completions POST. */
20
+ onRequestStart?: (ctx: RequestContext) => void | Promise<void>
21
+
22
+ /** Called when a payment method has been successfully verified. */
23
+ onPaymentVerified?: (ctx: RequestContext, info: {
24
+ method: PaymentMethod
25
+ consumerId: string
26
+ keyId?: string
27
+ }) => void | Promise<void>
28
+
29
+ /** Called when auth fails — every branch. */
30
+ onAuthFailure?: (ctx: RequestContext, reason: AuthFailureReason) => void | Promise<void>
31
+
32
+ /** Called when a consumer hits the rate limit. */
33
+ onRateLimited?: (ctx: RequestContext, info: {
34
+ consumerId: string
35
+ retryAfterSeconds: number
36
+ }) => void | Promise<void>
37
+
38
+ /** Called when the request body exceeds the 64KB limit. */
39
+ onBodyTooLarge?: (ctx: RequestContext, contentLength: number) => void | Promise<void>
40
+
41
+ /** Called when prompt-injection patterns are detected. */
42
+ onInjectionDetected?: (ctx: RequestContext, info: {
43
+ consumerId: string
44
+ patterns: string[]
45
+ blocked: boolean
46
+ }) => void | Promise<void>
47
+
48
+ /** Called after a successful stream completes and recordUsage has fired. */
49
+ onRequestComplete?: (ctx: RequestContext, usage: GatewayUsageEvent) => void | Promise<void>
50
+
51
+ /** Called when the sandbox throws. The error message is pre-scrubbed. */
52
+ onStreamError?: (ctx: RequestContext, info: {
53
+ consumerId: string
54
+ errorMessage: string
55
+ }) => void | Promise<void>
56
+
57
+ /** Called when settlement fails. Payment already occurred; this is async bookkeeping. */
58
+ onSettlementError?: (ctx: RequestContext, info: {
59
+ consumerId: string
60
+ method: PaymentMethod
61
+ errorMessage: string
62
+ }) => void | Promise<void>
63
+ }
package/src/observer.ts CHANGED
@@ -10,70 +10,10 @@
10
10
  * When no observer is configured, the gateway stays silent.
11
11
  */
12
12
 
13
- import type { PaymentMethod, GatewayUsageEvent } from './types'
13
+ import type { GatewayUsageEvent, PaymentMethod } from './payment-types'
14
+ import type { AuthFailureReason, GatewayObserver, RequestContext } from './observer-types'
14
15
 
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
- }
16
+ export type { AuthFailureReason, GatewayObserver, RequestContext } from './observer-types'
77
17
 
78
18
  // ---------------------------------------------------------------------------
79
19
  // Convenience implementations