@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
|
@@ -90,6 +90,84 @@ export interface PushNotificationStore {
|
|
|
90
90
|
delete(taskId: string, configId: string): Promise<void>
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Validate a push destination before the gateway sends task data to it.
|
|
95
|
+
*
|
|
96
|
+
* The default policy rejects URL credentials, non-HTTPS schemes, IP literals
|
|
97
|
+
* in reserved ranges, and common private hostnames. Production deployments
|
|
98
|
+
* should also provide `GatewayConfig.a2a.pushUrlValidator` for DNS policy.
|
|
99
|
+
*/
|
|
100
|
+
export function validatePushNotificationUrl(value: string): URL | undefined {
|
|
101
|
+
let url: URL
|
|
102
|
+
try {
|
|
103
|
+
url = new URL(value)
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
url.protocol !== 'https:' ||
|
|
109
|
+
url.username !== '' ||
|
|
110
|
+
url.password !== '' ||
|
|
111
|
+
isPrivatePushHostname(url.hostname)
|
|
112
|
+
) {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
return url
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isPrivatePushHostname(value: string): boolean {
|
|
119
|
+
const hostname = value.toLowerCase().replace(/\.$/, '')
|
|
120
|
+
const ipv4 = parseIpv4(hostname)
|
|
121
|
+
if (ipv4) {
|
|
122
|
+
const [first, second] = ipv4
|
|
123
|
+
return first === 0 ||
|
|
124
|
+
first === 10 ||
|
|
125
|
+
first === 127 ||
|
|
126
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
127
|
+
(first === 169 && second === 254) ||
|
|
128
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
129
|
+
(first === 192 && second === 0) ||
|
|
130
|
+
(first === 192 && second === 168) ||
|
|
131
|
+
(first === 198 && (second === 18 || second === 19)) ||
|
|
132
|
+
(first === 203 && second === 0) ||
|
|
133
|
+
first >= 224
|
|
134
|
+
}
|
|
135
|
+
const ipv6 = hostname.replace(/^\[|\]$/g, '')
|
|
136
|
+
if (
|
|
137
|
+
ipv6 === '::' ||
|
|
138
|
+
ipv6 === '::1' ||
|
|
139
|
+
ipv6.startsWith('fc') ||
|
|
140
|
+
ipv6.startsWith('fd') ||
|
|
141
|
+
ipv6.startsWith('fe8') ||
|
|
142
|
+
ipv6.startsWith('fe9') ||
|
|
143
|
+
ipv6.startsWith('fea') ||
|
|
144
|
+
ipv6.startsWith('feb') ||
|
|
145
|
+
// WHATWG URL normalizes dotted IPv4-mapped IPv6 literals to hexadecimal.
|
|
146
|
+
// Reject the whole mapped range instead of matching only dotted forms.
|
|
147
|
+
ipv6.startsWith('::ffff:') ||
|
|
148
|
+
// IPv4-compatible IPv6 literals can also embed loopback/private IPv4.
|
|
149
|
+
(ipv6.startsWith('::') && ipv6 !== '::1')
|
|
150
|
+
) return true
|
|
151
|
+
return hostname === 'localhost' ||
|
|
152
|
+
hostname === 'localhost.localdomain' ||
|
|
153
|
+
hostname === 'metadata' ||
|
|
154
|
+
hostname === 'metadata.google.internal' ||
|
|
155
|
+
hostname.endsWith('.localhost') ||
|
|
156
|
+
hostname.endsWith('.local') ||
|
|
157
|
+
hostname.endsWith('.internal') ||
|
|
158
|
+
hostname.endsWith('.intranet') ||
|
|
159
|
+
hostname.endsWith('.lan') ||
|
|
160
|
+
hostname.endsWith('.home')
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseIpv4(value: string): [number, number, number, number] | undefined {
|
|
164
|
+
const parts = value.split('.')
|
|
165
|
+
if (parts.length !== 4 || parts.some((part) => !/^\d{1,3}$/.test(part))) return undefined
|
|
166
|
+
const numbers = parts.map(Number)
|
|
167
|
+
if (numbers.some((part) => part > 255)) return undefined
|
|
168
|
+
return numbers as [number, number, number, number]
|
|
169
|
+
}
|
|
170
|
+
|
|
93
171
|
export class InMemoryPushNotificationStore implements PushNotificationStore {
|
|
94
172
|
private readonly byTask = new Map<string, Map<string, PushNotificationConfig>>()
|
|
95
173
|
|
|
@@ -145,16 +223,14 @@ export class SqlPushNotificationStore implements PushNotificationStore {
|
|
|
145
223
|
|
|
146
224
|
async set(taskId: string, config: PushNotificationConfig): Promise<void> {
|
|
147
225
|
const auth = config.authentication ? JSON.stringify(config.authentication) : null
|
|
148
|
-
|
|
149
|
-
`
|
|
150
|
-
|
|
226
|
+
await this.db.exec(
|
|
227
|
+
`INSERT INTO ${this.table} (task_id, config_id, url, token, authentication) VALUES (?, ?, ?, ?, ?)
|
|
228
|
+
ON CONFLICT (task_id, config_id) DO UPDATE SET
|
|
229
|
+
url = excluded.url,
|
|
230
|
+
token = excluded.token,
|
|
231
|
+
authentication = excluded.authentication`,
|
|
232
|
+
[taskId, config.id, config.url, config.token ?? null, auth],
|
|
151
233
|
)
|
|
152
|
-
if (updated.rowsAffected === 0) {
|
|
153
|
-
await this.db.exec(
|
|
154
|
-
`INSERT INTO ${this.table} (task_id, config_id, url, token, authentication) VALUES (?, ?, ?, ?, ?)`,
|
|
155
|
-
[taskId, config.id, config.url, config.token ?? null, auth],
|
|
156
|
-
)
|
|
157
|
-
}
|
|
158
234
|
}
|
|
159
235
|
|
|
160
236
|
async get(taskId: string, configId: string): Promise<PushNotificationConfig | undefined> {
|
|
@@ -207,24 +283,57 @@ export class SqlPushNotificationStore implements PushNotificationStore {
|
|
|
207
283
|
}
|
|
208
284
|
}
|
|
209
285
|
|
|
210
|
-
|
|
211
|
-
* Send the webhook for each registered config on a task. Signs the body with
|
|
212
|
-
* HMAC-SHA256 against `webhookSecret` so the consumer can verify authenticity.
|
|
213
|
-
* Fire-and-forget per the design note above — the function awaits delivery
|
|
214
|
-
* (so observability hooks see the result) but does not retry on failure.
|
|
215
|
-
*
|
|
216
|
-
* The caller decides *when* to deliver — typically on terminal-state
|
|
217
|
-
* transitions emitted from `message/send` and `message/stream`.
|
|
218
|
-
*/
|
|
219
|
-
export async function deliverPushNotifications(args: {
|
|
286
|
+
interface PushDeliveryOptions {
|
|
220
287
|
task: Task
|
|
221
288
|
store: PushNotificationStore
|
|
222
|
-
webhookSecret
|
|
289
|
+
webhookSecret?: string
|
|
290
|
+
/** Atomically claim one terminal delivery before its external side effect. */
|
|
291
|
+
claimDelivery?: (
|
|
292
|
+
taskId: string,
|
|
293
|
+
configId: string,
|
|
294
|
+
terminalState: Task['status']['state'],
|
|
295
|
+
) => Promise<boolean>
|
|
223
296
|
/** Inject for tests. Defaults to global `fetch`. */
|
|
224
297
|
fetcher?: typeof fetch
|
|
298
|
+
/** Optional DNS-aware host policy for production deployments. */
|
|
299
|
+
urlValidator?: (url: URL) => boolean | Promise<boolean>
|
|
300
|
+
/** Require `urlValidator` before sending from a production gateway. */
|
|
301
|
+
requireUrlValidator?: boolean
|
|
225
302
|
/** Optional callback so the gateway's observer can log delivery outcomes. */
|
|
226
303
|
onDelivery?: (result: PushDeliveryResult) => void
|
|
227
|
-
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export type PushNotificationDeliveryOptions = Omit<PushDeliveryOptions, 'webhookSecret'> & {
|
|
307
|
+
webhookSecret: string
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Send signed webhooks for a terminal task.
|
|
312
|
+
*
|
|
313
|
+
* A non-empty HMAC secret is mandatory. This public production path cannot
|
|
314
|
+
* send an unsigned request.
|
|
315
|
+
*/
|
|
316
|
+
export async function deliverPushNotifications(
|
|
317
|
+
args: PushNotificationDeliveryOptions,
|
|
318
|
+
): Promise<PushDeliveryResult[]> {
|
|
319
|
+
if (typeof args.webhookSecret !== 'string' || args.webhookSecret.trim().length === 0) {
|
|
320
|
+
throw new Error('deliverPushNotifications requires a non-empty webhookSecret')
|
|
321
|
+
}
|
|
322
|
+
return deliverPushNotificationsInternal(args)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Deliver unsigned webhooks only for explicit local demo mode.
|
|
327
|
+
*
|
|
328
|
+
* Production callers must use `deliverPushNotifications`.
|
|
329
|
+
*/
|
|
330
|
+
export async function deliverDemoPushNotifications(
|
|
331
|
+
args: Omit<PushNotificationDeliveryOptions, 'webhookSecret'>,
|
|
332
|
+
): Promise<PushDeliveryResult[]> {
|
|
333
|
+
return deliverPushNotificationsInternal(args)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function deliverPushNotificationsInternal(args: PushDeliveryOptions): Promise<PushDeliveryResult[]> {
|
|
228
337
|
const fetcher = args.fetcher ?? fetch
|
|
229
338
|
const configs = await args.store.list(args.task.id)
|
|
230
339
|
const body = JSON.stringify({
|
|
@@ -238,6 +347,37 @@ export async function deliverPushNotifications(args: {
|
|
|
238
347
|
|
|
239
348
|
const results: PushDeliveryResult[] = []
|
|
240
349
|
for (const config of configs) {
|
|
350
|
+
// Validate before claiming so policy rejection remains retryable.
|
|
351
|
+
try {
|
|
352
|
+
const url = validatePushNotificationUrl(config.url)
|
|
353
|
+
if (!url) {
|
|
354
|
+
throw new Error('push notification URL is not a safe HTTPS destination')
|
|
355
|
+
}
|
|
356
|
+
if (args.requireUrlValidator && !args.urlValidator) {
|
|
357
|
+
throw new Error('push notification URL validation is not configured')
|
|
358
|
+
}
|
|
359
|
+
if (args.urlValidator && !await args.urlValidator(url)) {
|
|
360
|
+
throw new Error('push notification URL was rejected by host policy')
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
const result: PushDeliveryResult = {
|
|
364
|
+
taskId: args.task.id,
|
|
365
|
+
configId: config.id,
|
|
366
|
+
url: config.url,
|
|
367
|
+
ok: false,
|
|
368
|
+
error: err instanceof Error ? err.message : String(err),
|
|
369
|
+
}
|
|
370
|
+
args.onDelivery?.(result)
|
|
371
|
+
results.push(result)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (
|
|
376
|
+
args.claimDelivery &&
|
|
377
|
+
!await args.claimDelivery(args.task.id, config.id, args.task.status.state)
|
|
378
|
+
) {
|
|
379
|
+
continue
|
|
380
|
+
}
|
|
241
381
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
242
382
|
if (config.token) headers['X-A2A-Notification-Token'] = config.token
|
|
243
383
|
if (signature) headers['X-A2A-Signature'] = signature
|
|
@@ -248,13 +388,23 @@ export async function deliverPushNotifications(args: {
|
|
|
248
388
|
|
|
249
389
|
let result: PushDeliveryResult
|
|
250
390
|
try {
|
|
251
|
-
const res = await fetcher(config.url, {
|
|
391
|
+
const res = await fetcher(config.url, {
|
|
392
|
+
method: 'POST',
|
|
393
|
+
headers,
|
|
394
|
+
body,
|
|
395
|
+
// Never follow a user-controlled redirect. The redirected destination
|
|
396
|
+
// could be an internal HTTP service or instance metadata endpoint.
|
|
397
|
+
redirect: 'manual',
|
|
398
|
+
})
|
|
252
399
|
result = {
|
|
253
400
|
taskId: args.task.id,
|
|
254
401
|
configId: config.id,
|
|
255
402
|
url: config.url,
|
|
256
403
|
ok: res.ok,
|
|
257
404
|
status: res.status,
|
|
405
|
+
...(res.status >= 300 && res.status < 400
|
|
406
|
+
? { error: 'push notification redirect rejected' }
|
|
407
|
+
: {}),
|
|
258
408
|
}
|
|
259
409
|
} catch (err) {
|
|
260
410
|
result = {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class TaskCancellationRegistry {
|
|
2
|
+
private readonly controllers = new Map<string, AbortController>()
|
|
3
|
+
private readonly finalizing = new Set<string>()
|
|
4
|
+
|
|
5
|
+
register(taskId: string): AbortController {
|
|
6
|
+
const controller = new AbortController()
|
|
7
|
+
this.controllers.set(taskId, controller)
|
|
8
|
+
return controller
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
clear(taskId: string): void {
|
|
12
|
+
this.controllers.delete(taskId)
|
|
13
|
+
this.finalizing.delete(taskId)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
beginFinalization(taskId: string): boolean {
|
|
17
|
+
const controller = this.controllers.get(taskId)
|
|
18
|
+
if (!controller || controller.signal.aborted || this.finalizing.has(taskId)) return false
|
|
19
|
+
this.finalizing.add(taskId)
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
isFinalizing(taskId: string): boolean {
|
|
24
|
+
return this.finalizing.has(taskId)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
has(taskId: string): boolean {
|
|
28
|
+
const controller = this.controllers.get(taskId)
|
|
29
|
+
return controller !== undefined && !controller.signal.aborted
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
cancel(taskId: string): boolean {
|
|
33
|
+
if (this.finalizing.has(taskId)) return false
|
|
34
|
+
const controller = this.controllers.get(taskId)
|
|
35
|
+
if (!controller) return false
|
|
36
|
+
controller.abort()
|
|
37
|
+
this.controllers.delete(taskId)
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function bindRequestAbort(
|
|
43
|
+
requestSignal: AbortSignal,
|
|
44
|
+
controller: AbortController,
|
|
45
|
+
): () => void {
|
|
46
|
+
const abort = () => controller.abort()
|
|
47
|
+
if (requestSignal.aborted) abort()
|
|
48
|
+
else requestSignal.addEventListener('abort', abort, { once: true })
|
|
49
|
+
return () => requestSignal.removeEventListener('abort', abort)
|
|
50
|
+
}
|