@tangle-network/agent-gateway 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -3
- package/dist/chunk-C7Z2BRYV.js +5693 -0
- package/dist/chunk-C7Z2BRYV.js.map +1 -0
- package/dist/chunk-GITV7CPT.js +84 -0
- package/dist/chunk-GITV7CPT.js.map +1 -0
- package/dist/chunk-J5SDVHOL.js +104 -0
- package/dist/chunk-J5SDVHOL.js.map +1 -0
- package/dist/index.d.ts +70 -10
- package/dist/index.js +303 -21
- package/dist/index.js.map +1 -1
- package/dist/middleware.d.ts +7 -2
- package/dist/middleware.js +3 -2
- package/dist/nonce-store.d.ts +47 -11
- package/dist/nonce-store.js +9 -3
- package/dist/observer-types-A0RtA8uL.d.ts +95 -0
- package/dist/observer.d.ts +79 -0
- package/dist/observer.js +11 -0
- package/dist/observer.js.map +1 -0
- package/dist/{types-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +506 -560
- package/src/a2a/message-send-execution.ts +241 -0
- package/src/a2a/message-stream-execution.ts +392 -0
- package/src/a2a/payment-recovery.ts +431 -0
- package/src/a2a/push-config-methods.ts +158 -0
- package/src/a2a/push-notifications.ts +172 -22
- package/src/a2a/task-cancellation.ts +50 -0
- package/src/a2a/task-finalization.ts +451 -0
- package/src/a2a/task-lifecycle.ts +54 -0
- package/src/a2a/task-methods.ts +163 -0
- package/src/a2a/task-push-delivery.ts +119 -0
- package/src/a2a/task-recovery.ts +11 -0
- package/src/a2a/task-state.ts +99 -0
- package/src/a2a/task-store-sql.ts +222 -24
- package/src/a2a/task-store.ts +58 -1
- package/src/a2a/task-submission-recovery.ts +178 -0
- package/src/a2a/types.ts +1 -0
- package/src/dispatch-authorization.ts +468 -0
- package/src/dispatch-payment-recovery.ts +248 -0
- package/src/dispatch-payment.ts +425 -0
- package/src/dispatch-pricing.ts +108 -0
- package/src/dispatch-sandbox.ts +424 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +84 -0
- package/src/dispatch.ts +35 -483
- package/src/index.ts +59 -1
- package/src/middleware.ts +339 -35
- package/src/mpp-payment.ts +117 -0
- package/src/nonce-store.ts +122 -20
- package/src/observer-types.ts +63 -0
- package/src/observer.ts +3 -63
- package/src/payment-operations.ts +485 -0
- package/src/payment-recovery-sql.ts +108 -0
- package/src/payment-recovery-worker.ts +488 -0
- package/src/payment-recovery.ts +331 -0
- package/src/payment-types.ts +48 -0
- package/src/types.ts +188 -49
- package/src/verify.ts +240 -71
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
- package/dist/chunk-Q4YAIEZY.js +0 -1763
- package/dist/chunk-Q4YAIEZY.js.map +0 -1
package/src/nonce-store.ts
CHANGED
|
@@ -4,10 +4,35 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
export interface NonceStore {
|
|
7
|
-
/** Check if nonce has been seen.
|
|
7
|
+
/** Check if nonce has been seen. This method never grants ownership. */
|
|
8
8
|
hasSeen(nonce: string): Promise<boolean>
|
|
9
|
-
/**
|
|
10
|
-
|
|
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>()
|
|
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
|
|
25
|
-
if (!
|
|
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,
|
|
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.
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
-
|
|
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 {
|
|
13
|
+
import type { GatewayUsageEvent, PaymentMethod } from './payment-types'
|
|
14
|
+
import type { AuthFailureReason, GatewayObserver, RequestContext } from './observer-types'
|
|
14
15
|
|
|
15
|
-
export
|
|
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
|