@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.
Files changed (64) hide show
  1. package/README.md +90 -3
  2. package/dist/chunk-C7Z2BRYV.js +5693 -0
  3. package/dist/chunk-C7Z2BRYV.js.map +1 -0
  4. package/dist/chunk-GITV7CPT.js +84 -0
  5. package/dist/chunk-GITV7CPT.js.map +1 -0
  6. package/dist/chunk-J5SDVHOL.js +104 -0
  7. package/dist/chunk-J5SDVHOL.js.map +1 -0
  8. package/dist/index.d.ts +70 -10
  9. package/dist/index.js +303 -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-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/execution-fence.ts +162 -0
  23. package/src/a2a/handler.ts +506 -560
  24. package/src/a2a/message-send-execution.ts +241 -0
  25. package/src/a2a/message-stream-execution.ts +392 -0
  26. package/src/a2a/payment-recovery.ts +431 -0
  27. package/src/a2a/push-config-methods.ts +158 -0
  28. package/src/a2a/push-notifications.ts +172 -22
  29. package/src/a2a/task-cancellation.ts +50 -0
  30. package/src/a2a/task-finalization.ts +451 -0
  31. package/src/a2a/task-lifecycle.ts +54 -0
  32. package/src/a2a/task-methods.ts +163 -0
  33. package/src/a2a/task-push-delivery.ts +119 -0
  34. package/src/a2a/task-recovery.ts +11 -0
  35. package/src/a2a/task-state.ts +99 -0
  36. package/src/a2a/task-store-sql.ts +222 -24
  37. package/src/a2a/task-store.ts +58 -1
  38. package/src/a2a/task-submission-recovery.ts +178 -0
  39. package/src/a2a/types.ts +1 -0
  40. package/src/dispatch-authorization.ts +468 -0
  41. package/src/dispatch-payment-recovery.ts +248 -0
  42. package/src/dispatch-payment.ts +425 -0
  43. package/src/dispatch-pricing.ts +108 -0
  44. package/src/dispatch-sandbox.ts +424 -0
  45. package/src/dispatch-settlement.ts +139 -0
  46. package/src/dispatch-types.ts +84 -0
  47. package/src/dispatch.ts +35 -483
  48. package/src/index.ts +59 -1
  49. package/src/middleware.ts +339 -35
  50. package/src/mpp-payment.ts +117 -0
  51. package/src/nonce-store.ts +122 -20
  52. package/src/observer-types.ts +63 -0
  53. package/src/observer.ts +3 -63
  54. package/src/payment-operations.ts +485 -0
  55. package/src/payment-recovery-sql.ts +108 -0
  56. package/src/payment-recovery-worker.ts +488 -0
  57. package/src/payment-recovery.ts +331 -0
  58. package/src/payment-types.ts +48 -0
  59. package/src/types.ts +188 -49
  60. package/src/verify.ts +240 -71
  61. package/dist/chunk-M7ZJAK4K.js +0 -53
  62. package/dist/chunk-M7ZJAK4K.js.map +0 -1
  63. package/dist/chunk-Q4YAIEZY.js +0 -1763
  64. 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
- const updated = await this.db.exec(
149
- `UPDATE ${this.table} SET url = ?, token = ?, authentication = ? WHERE task_id = ? AND config_id = ?`,
150
- [config.url, config.token ?? null, auth, taskId, config.id],
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: string | undefined
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
- }): Promise<PushDeliveryResult[]> {
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, { method: 'POST', headers, body })
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
+ }