@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.
- package/README.md +108 -6
- 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/chunk-MP6IIAIA.js +5651 -0
- package/dist/chunk-MP6IIAIA.js.map +1 -0
- package/dist/index.d.ts +76 -12
- package/dist/index.js +307 -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-CX2V06cN.d.ts → types-BHISsm7D.d.ts} +423 -166
- package/dist/types.d.ts +2 -1
- package/package.json +1 -1
- package/src/a2a/agent-card.ts +4 -3
- package/src/a2a/execution-fence.ts +162 -0
- package/src/a2a/handler.ts +507 -562
- 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 +437 -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 +422 -0
- package/src/dispatch-settlement.ts +139 -0
- package/src/dispatch-types.ts +81 -0
- package/src/dispatch.ts +35 -462
- package/src/index.ts +64 -2
- package/src/middleware.ts +313 -32
- 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 +153 -42
- package/src/verify.ts +265 -36
- package/dist/chunk-3IKQWFKX.js +0 -1703
- package/dist/chunk-3IKQWFKX.js.map +0 -1
- package/dist/chunk-M7ZJAK4K.js +0 -53
- package/dist/chunk-M7ZJAK4K.js.map +0 -1
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import type { AuthorizedRequest } from '../dispatch'
|
|
2
|
+
import type { PaymentOperations, PaymentOperation } from '../payment-operations'
|
|
3
|
+
import {
|
|
4
|
+
deserializePaymentOperation,
|
|
5
|
+
serializePaymentOperation,
|
|
6
|
+
type PaymentRecoveryConfig,
|
|
7
|
+
type PaymentRecoveryRecord,
|
|
8
|
+
type SerializedPaymentOperation,
|
|
9
|
+
} from '../payment-recovery'
|
|
10
|
+
import type { SandboxUsageReceipt } from '../types'
|
|
11
|
+
import type { Task } from './types'
|
|
12
|
+
import {
|
|
13
|
+
asError,
|
|
14
|
+
compareAndSetTask,
|
|
15
|
+
clearTaskMetadata,
|
|
16
|
+
cryptoRandomId,
|
|
17
|
+
withStatus,
|
|
18
|
+
type TaskStateStore,
|
|
19
|
+
} from './task-state'
|
|
20
|
+
|
|
21
|
+
const PAYMENT_RELEASE_METADATA_KEY = 'gatewayPaymentRelease'
|
|
22
|
+
const PAYMENT_RECOVERY_METADATA_KEY = 'gatewayPaymentRecovery'
|
|
23
|
+
const PAYMENT_RELEASE_LEASE_MS = 5 * 60 * 1000
|
|
24
|
+
|
|
25
|
+
interface TaskPaymentRecoveryMarker {
|
|
26
|
+
version: 1
|
|
27
|
+
id: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PaymentReleaseRecord {
|
|
31
|
+
version: 1
|
|
32
|
+
lease: { id: string; expiresAt: number }
|
|
33
|
+
agentSlug: string
|
|
34
|
+
requestId: string
|
|
35
|
+
operationId: string
|
|
36
|
+
paymentOperation: SerializedPaymentOperation
|
|
37
|
+
reason: string
|
|
38
|
+
recoveryAttempts?: number
|
|
39
|
+
recoveryError?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PaymentRecoveryDependencies {
|
|
43
|
+
taskStore: TaskStateStore
|
|
44
|
+
paymentOperations?: PaymentOperations
|
|
45
|
+
paymentRecovery?: PaymentRecoveryConfig
|
|
46
|
+
releasePayment: (authz: AuthorizedRequest, reason: string) => Promise<void>
|
|
47
|
+
releasePaymentAfterFailure: (
|
|
48
|
+
authz: AuthorizedRequest,
|
|
49
|
+
reason: string,
|
|
50
|
+
workObserved: boolean,
|
|
51
|
+
) => Promise<void>
|
|
52
|
+
recoverDurablePayment: (
|
|
53
|
+
recoveryId: string,
|
|
54
|
+
options?: { force?: boolean; usage?: SandboxUsageReceipt },
|
|
55
|
+
) => Promise<PaymentRecoveryRecord | undefined>
|
|
56
|
+
deliverPush: (task: Task) => Promise<void>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function attachPaymentRecoveryMarker(
|
|
60
|
+
taskStore: TaskStateStore,
|
|
61
|
+
task: Task,
|
|
62
|
+
recoveryId: string | undefined,
|
|
63
|
+
): Promise<Task> {
|
|
64
|
+
if (!recoveryId) return task
|
|
65
|
+
const existing = readPaymentRecoveryMarker(task)
|
|
66
|
+
if (existing) {
|
|
67
|
+
if (existing.id !== recoveryId) {
|
|
68
|
+
throw new Error('A2A task already has a different payment recovery identity')
|
|
69
|
+
}
|
|
70
|
+
return task
|
|
71
|
+
}
|
|
72
|
+
const next = withPaymentRecoveryMarker(task, recoveryId)
|
|
73
|
+
if (await compareAndSetTask(taskStore, task, next)) return next
|
|
74
|
+
throw new Error('A2A task changed while payment recovery was attached')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Attach only as a retention marker. The returned task must never execute. */
|
|
78
|
+
export async function retainPaymentRecoveryMarker(
|
|
79
|
+
taskStore: TaskStateStore,
|
|
80
|
+
task: Task,
|
|
81
|
+
recoveryId: string | undefined,
|
|
82
|
+
): Promise<Task> {
|
|
83
|
+
if (!recoveryId) return task
|
|
84
|
+
let current = await taskStore.get(task.id) ?? task
|
|
85
|
+
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
86
|
+
const existing = readPaymentRecoveryMarker(current)
|
|
87
|
+
if (existing) {
|
|
88
|
+
if (existing.id !== recoveryId) {
|
|
89
|
+
throw new Error('A2A task already has a different payment recovery identity')
|
|
90
|
+
}
|
|
91
|
+
return current
|
|
92
|
+
}
|
|
93
|
+
const next = withPaymentRecoveryMarker(current, recoveryId)
|
|
94
|
+
if (await compareAndSetTask(taskStore, current, next)) return next
|
|
95
|
+
const latest = await taskStore.get(task.id)
|
|
96
|
+
if (!latest) throw new Error('A2A task disappeared while payment recovery was retained')
|
|
97
|
+
current = latest
|
|
98
|
+
}
|
|
99
|
+
throw new Error('A2A task changed too many times while payment recovery was retained')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function preservePaymentRecoveryMarker(base: Task, source: Task): Task {
|
|
103
|
+
const marker = readPaymentRecoveryMarker(source)
|
|
104
|
+
return marker ? withPaymentRecoveryMarker(base, marker.id) : base
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readPaymentRecoveryMarker(task: Task): TaskPaymentRecoveryMarker | undefined {
|
|
108
|
+
const raw = task.metadata?.[PAYMENT_RECOVERY_METADATA_KEY]
|
|
109
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
110
|
+
const marker = raw as Partial<TaskPaymentRecoveryMarker>
|
|
111
|
+
if (marker.version !== 1 || typeof marker.id !== 'string' || marker.id.length === 0) {
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
return marker as TaskPaymentRecoveryMarker
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function clearPaymentRecoveryMarker(task: Task): Task {
|
|
118
|
+
return clearTaskMetadata(task, PAYMENT_RECOVERY_METADATA_KEY)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function hasPaymentReleaseRecovery(task: Task): boolean {
|
|
122
|
+
return task.metadata?.[PAYMENT_RELEASE_METADATA_KEY] !== undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function releaseTaskPayment(
|
|
126
|
+
authz: AuthorizedRequest,
|
|
127
|
+
task: Task,
|
|
128
|
+
deps: PaymentRecoveryDependencies,
|
|
129
|
+
reason: string,
|
|
130
|
+
workObserved: boolean,
|
|
131
|
+
): Promise<Task> {
|
|
132
|
+
if (
|
|
133
|
+
!workObserved &&
|
|
134
|
+
!authz.paymentRecoveryId &&
|
|
135
|
+
authz.paymentOperation &&
|
|
136
|
+
deps.paymentOperations
|
|
137
|
+
) {
|
|
138
|
+
let marked: Task
|
|
139
|
+
try {
|
|
140
|
+
marked = await beginPaymentReleaseRecovery(deps.taskStore, task, authz, reason) ?? task
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(
|
|
143
|
+
'[a2a] failed to persist payment release recovery for ' + authz.requestId + ':',
|
|
144
|
+
error instanceof Error ? error.message : String(error),
|
|
145
|
+
)
|
|
146
|
+
return await deps.taskStore.get(task.id) ?? task
|
|
147
|
+
}
|
|
148
|
+
const record = readPaymentReleaseRecord(marked)
|
|
149
|
+
if (!record) return marked
|
|
150
|
+
try {
|
|
151
|
+
await deps.releasePayment(authz, reason)
|
|
152
|
+
} catch (releaseError) {
|
|
153
|
+
const retained = await retainPaymentReleaseForRecovery(
|
|
154
|
+
deps.taskStore,
|
|
155
|
+
task.id,
|
|
156
|
+
record.lease.id,
|
|
157
|
+
asError(releaseError),
|
|
158
|
+
)
|
|
159
|
+
console.error(
|
|
160
|
+
'[a2a] payment release retained for ' + authz.requestId + ':',
|
|
161
|
+
releaseError instanceof Error ? releaseError.message : String(releaseError),
|
|
162
|
+
)
|
|
163
|
+
return retained ?? marked
|
|
164
|
+
}
|
|
165
|
+
return clearPaymentReleaseRecovery(deps.taskStore, marked, record.lease.id)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
await deps.releasePaymentAfterFailure(authz, reason, workObserved)
|
|
170
|
+
} catch (releaseError) {
|
|
171
|
+
console.error(
|
|
172
|
+
`[a2a] payment release failed for ${authz.requestId}:`,
|
|
173
|
+
releaseError instanceof Error ? releaseError.message : String(releaseError),
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
const current = await deps.taskStore.get(task.id) ?? task
|
|
177
|
+
return workObserved
|
|
178
|
+
? current
|
|
179
|
+
: clearReconciledPaymentRecoveryMarker(current, deps)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function beginPaymentReleaseRecovery(
|
|
183
|
+
taskStore: TaskStateStore,
|
|
184
|
+
task: Task,
|
|
185
|
+
authz: AuthorizedRequest,
|
|
186
|
+
reason: string,
|
|
187
|
+
): Promise<Task | undefined> {
|
|
188
|
+
const record = buildPaymentReleaseRecord(authz, reason)
|
|
189
|
+
if (!record) return undefined
|
|
190
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
191
|
+
const current = await taskStore.get(task.id) ?? task
|
|
192
|
+
const existing = readPaymentReleaseRecord(current)
|
|
193
|
+
if (existing) {
|
|
194
|
+
if (existing.operationId !== record.operationId) {
|
|
195
|
+
throw new Error('A2A task already has a different payment release recovery')
|
|
196
|
+
}
|
|
197
|
+
return current
|
|
198
|
+
}
|
|
199
|
+
const next = withPaymentReleaseRecord(current, record)
|
|
200
|
+
if (await compareAndSetTask(taskStore, current, next)) return next
|
|
201
|
+
}
|
|
202
|
+
throw new Error('A2A task changed before payment release recovery was stored')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function retainPaymentReleaseForRecovery(
|
|
206
|
+
taskStore: TaskStateStore,
|
|
207
|
+
taskId: string,
|
|
208
|
+
leaseId: string,
|
|
209
|
+
error: Error,
|
|
210
|
+
): Promise<Task | undefined> {
|
|
211
|
+
const current = await taskStore.get(taskId)
|
|
212
|
+
if (!current) return undefined
|
|
213
|
+
const record = readPaymentReleaseRecord(current)
|
|
214
|
+
if (!record || record.lease.id !== leaseId) return undefined
|
|
215
|
+
const retry: PaymentReleaseRecord = {
|
|
216
|
+
...record,
|
|
217
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + PAYMENT_RELEASE_LEASE_MS },
|
|
218
|
+
recoveryAttempts: (record.recoveryAttempts ?? 0) + 1,
|
|
219
|
+
recoveryError: error.message,
|
|
220
|
+
}
|
|
221
|
+
const next = withPaymentReleaseRecord(current, retry)
|
|
222
|
+
if (await compareAndSetTask(taskStore, current, next)) return next
|
|
223
|
+
return await taskStore.get(taskId)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function clearPaymentReleaseRecovery(
|
|
227
|
+
taskStore: TaskStateStore,
|
|
228
|
+
task: Task,
|
|
229
|
+
leaseId: string,
|
|
230
|
+
): Promise<Task> {
|
|
231
|
+
const current = await taskStore.get(task.id) ?? task
|
|
232
|
+
const record = readPaymentReleaseRecord(current)
|
|
233
|
+
if (!record || record.lease.id !== leaseId) return current
|
|
234
|
+
const cleared = clearPaymentRecoveryMarker(clearPaymentReleaseRecord(current))
|
|
235
|
+
if (await compareAndSetTask(taskStore, current, cleared)) return cleared
|
|
236
|
+
return await taskStore.get(task.id) ?? cleared
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function recoverPaymentReleaseIfNeeded(
|
|
240
|
+
task: Task,
|
|
241
|
+
deps: PaymentRecoveryDependencies,
|
|
242
|
+
): Promise<Task> {
|
|
243
|
+
const raw = task.metadata?.[PAYMENT_RELEASE_METADATA_KEY]
|
|
244
|
+
if (raw === undefined) return task
|
|
245
|
+
const record = readPaymentReleaseRecord(task)
|
|
246
|
+
if (!record) {
|
|
247
|
+
return expirePaymentRelease(
|
|
248
|
+
task,
|
|
249
|
+
deps,
|
|
250
|
+
new Error('A2A payment release recovery record is missing'),
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
if (record.lease.expiresAt > Date.now()) return task
|
|
254
|
+
|
|
255
|
+
const renewed: PaymentReleaseRecord = {
|
|
256
|
+
...record,
|
|
257
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + PAYMENT_RELEASE_LEASE_MS },
|
|
258
|
+
}
|
|
259
|
+
const leasedTask = withPaymentReleaseRecord(task, renewed)
|
|
260
|
+
if (!await compareAndSetTask(deps.taskStore, task, leasedTask)) {
|
|
261
|
+
return await deps.taskStore.get(task.id) ?? task
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
if (!deps.paymentOperations) {
|
|
266
|
+
throw new Error('A2A payment release recovery is not configured')
|
|
267
|
+
}
|
|
268
|
+
const operation = deserializePaymentOperation(renewed.paymentOperation)
|
|
269
|
+
await deps.paymentOperations.releasePayment(operation, renewed.reason)
|
|
270
|
+
if (deps.paymentRecovery) {
|
|
271
|
+
const recovered = await deps.recoverDurablePayment(renewed.operationId, { force: true })
|
|
272
|
+
if (recovered && recovered.state !== 'reconciled') {
|
|
273
|
+
throw new Error('durable payment release is still pending')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const recovered = clearPaymentReleaseRecord(leasedTask)
|
|
277
|
+
if (!await compareAndSetTask(deps.taskStore, leasedTask, recovered)) {
|
|
278
|
+
return await deps.taskStore.get(task.id) ?? recovered
|
|
279
|
+
}
|
|
280
|
+
return recovered
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const recoveryError = asError(error)
|
|
283
|
+
const retained = await retainPaymentReleaseForRecovery(
|
|
284
|
+
deps.taskStore,
|
|
285
|
+
task.id,
|
|
286
|
+
renewed.lease.id,
|
|
287
|
+
recoveryError,
|
|
288
|
+
)
|
|
289
|
+
console.error(
|
|
290
|
+
'[a2a] payment release recovery failed for ' + task.id + ':',
|
|
291
|
+
recoveryError.message,
|
|
292
|
+
)
|
|
293
|
+
return retained ?? leasedTask
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function recoverPaymentMarkerIfNeeded(
|
|
298
|
+
task: Task,
|
|
299
|
+
deps: PaymentRecoveryDependencies,
|
|
300
|
+
): Promise<Task> {
|
|
301
|
+
const marker = readPaymentRecoveryMarker(task)
|
|
302
|
+
if (!marker || !deps.paymentRecovery) return task
|
|
303
|
+
try {
|
|
304
|
+
const record = await deps.recoverDurablePayment(marker.id)
|
|
305
|
+
if (record?.state !== 'reconciled') return task
|
|
306
|
+
return clearReconciledPaymentRecoveryMarker(task, deps)
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error(
|
|
309
|
+
`[a2a] durable payment recovery failed for ${task.id}:`,
|
|
310
|
+
error instanceof Error ? error.message : String(error),
|
|
311
|
+
)
|
|
312
|
+
return task
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function clearReconciledPaymentRecoveryMarker(
|
|
317
|
+
task: Task,
|
|
318
|
+
deps: PaymentRecoveryDependencies,
|
|
319
|
+
): Promise<Task> {
|
|
320
|
+
const marker = readPaymentRecoveryMarker(task)
|
|
321
|
+
if (!marker || !deps.paymentRecovery) return task
|
|
322
|
+
const record = await deps.paymentRecovery.store.get(marker.id)
|
|
323
|
+
if (record?.state !== 'reconciled') return task
|
|
324
|
+
const cleared = clearPaymentRecoveryMarker(task)
|
|
325
|
+
if (cleared.status.state === 'working' || cleared.status.state === 'submitted') {
|
|
326
|
+
const failed: Task = {
|
|
327
|
+
...withStatus(cleared, 'failed'),
|
|
328
|
+
metadata: {
|
|
329
|
+
...(cleared.metadata ?? {}),
|
|
330
|
+
gatewayExecutionRecovery: {
|
|
331
|
+
error: 'payment recovery completed without a task result',
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
if (await compareAndSetTask(deps.taskStore, task, failed)) return failed
|
|
336
|
+
return await deps.taskStore.get(task.id) ?? failed
|
|
337
|
+
}
|
|
338
|
+
if (await compareAndSetTask(deps.taskStore, task, cleared)) return cleared
|
|
339
|
+
return await deps.taskStore.get(task.id) ?? cleared
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function withPaymentRecoveryMarker(task: Task, recoveryId: string): Task {
|
|
343
|
+
return {
|
|
344
|
+
...task,
|
|
345
|
+
metadata: {
|
|
346
|
+
...(task.metadata ?? {}),
|
|
347
|
+
[PAYMENT_RECOVERY_METADATA_KEY]: { version: 1, id: recoveryId },
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function withPaymentReleaseRecord(task: Task, record: PaymentReleaseRecord): Task {
|
|
353
|
+
return {
|
|
354
|
+
...task,
|
|
355
|
+
metadata: { ...(task.metadata ?? {}), [PAYMENT_RELEASE_METADATA_KEY]: record },
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function clearPaymentReleaseRecord(task: Task): Task {
|
|
360
|
+
return clearTaskMetadata(task, PAYMENT_RELEASE_METADATA_KEY)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function readPaymentReleaseRecord(task: Task): PaymentReleaseRecord | undefined {
|
|
364
|
+
const raw = task.metadata?.[PAYMENT_RELEASE_METADATA_KEY]
|
|
365
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
366
|
+
const record = raw as Partial<PaymentReleaseRecord>
|
|
367
|
+
if (
|
|
368
|
+
record.version !== 1 ||
|
|
369
|
+
!record.lease ||
|
|
370
|
+
typeof record.lease.id !== 'string' ||
|
|
371
|
+
record.lease.id.length === 0 ||
|
|
372
|
+
typeof record.lease.expiresAt !== 'number' ||
|
|
373
|
+
!Number.isFinite(record.lease.expiresAt) ||
|
|
374
|
+
typeof record.agentSlug !== 'string' ||
|
|
375
|
+
record.agentSlug.length === 0 ||
|
|
376
|
+
typeof record.requestId !== 'string' ||
|
|
377
|
+
record.requestId.length === 0 ||
|
|
378
|
+
typeof record.operationId !== 'string' ||
|
|
379
|
+
record.operationId.length === 0 ||
|
|
380
|
+
!record.paymentOperation ||
|
|
381
|
+
typeof record.paymentOperation !== 'object' ||
|
|
382
|
+
typeof record.reason !== 'string'
|
|
383
|
+
) {
|
|
384
|
+
return undefined
|
|
385
|
+
}
|
|
386
|
+
if (record.paymentOperation.operationId !== record.operationId) return undefined
|
|
387
|
+
try {
|
|
388
|
+
deserializePaymentOperation(record.paymentOperation)
|
|
389
|
+
} catch {
|
|
390
|
+
return undefined
|
|
391
|
+
}
|
|
392
|
+
return record as PaymentReleaseRecord
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function buildPaymentReleaseRecord(
|
|
396
|
+
authz: AuthorizedRequest,
|
|
397
|
+
reason: string,
|
|
398
|
+
): PaymentReleaseRecord | undefined {
|
|
399
|
+
const operation = authz.paymentOperation
|
|
400
|
+
if (!operation) return undefined
|
|
401
|
+
return {
|
|
402
|
+
version: 1,
|
|
403
|
+
lease: { id: cryptoRandomId(), expiresAt: Date.now() + PAYMENT_RELEASE_LEASE_MS },
|
|
404
|
+
agentSlug: authz.agent.slug,
|
|
405
|
+
requestId: authz.requestId,
|
|
406
|
+
operationId: operation.operationId,
|
|
407
|
+
paymentOperation: serializePaymentOperation({ ...operation, state: 'releasing' }),
|
|
408
|
+
reason,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function expirePaymentRelease(
|
|
413
|
+
task: Task,
|
|
414
|
+
deps: PaymentRecoveryDependencies,
|
|
415
|
+
error: Error,
|
|
416
|
+
): Promise<Task> {
|
|
417
|
+
const cleanTask = clearPaymentReleaseRecord(task)
|
|
418
|
+
const terminalTask = withStatus(cleanTask, 'failed')
|
|
419
|
+
const failed: Task = {
|
|
420
|
+
...terminalTask,
|
|
421
|
+
metadata: {
|
|
422
|
+
...(terminalTask.metadata ?? {}),
|
|
423
|
+
gatewayPaymentReleaseRecovery: { error: error.message },
|
|
424
|
+
},
|
|
425
|
+
}
|
|
426
|
+
if (await compareAndSetTask(deps.taskStore, task, failed)) {
|
|
427
|
+
await deps.deliverPush(failed)
|
|
428
|
+
return failed
|
|
429
|
+
}
|
|
430
|
+
return await deps.taskStore.get(task.id) ?? failed
|
|
431
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
import {
|
|
3
|
+
validatePushNotificationUrl,
|
|
4
|
+
type PushNotificationStore,
|
|
5
|
+
type TaskPushNotificationConfig,
|
|
6
|
+
} from './push-notifications'
|
|
7
|
+
import type { TaskStateStore } from './task-state'
|
|
8
|
+
import {
|
|
9
|
+
A2A_ERROR_CODES,
|
|
10
|
+
type JSONRPCRequest,
|
|
11
|
+
type Task,
|
|
12
|
+
type TaskIdParams,
|
|
13
|
+
type TaskPushNotificationConfigGetParams,
|
|
14
|
+
} from './types'
|
|
15
|
+
import { fail, ok } from './jsonrpc'
|
|
16
|
+
|
|
17
|
+
export interface PushConfigMethodDependencies {
|
|
18
|
+
taskStore: TaskStateStore
|
|
19
|
+
pushStore?: PushNotificationStore
|
|
20
|
+
demoMode: boolean
|
|
21
|
+
urlValidator?: (url: URL) => boolean | Promise<boolean>
|
|
22
|
+
authorizeTaskAccess: (
|
|
23
|
+
c: Context,
|
|
24
|
+
req: JSONRPCRequest,
|
|
25
|
+
task: Task,
|
|
26
|
+
) => Promise<Response | undefined>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function handlePushSet(
|
|
30
|
+
c: Context,
|
|
31
|
+
req: JSONRPCRequest,
|
|
32
|
+
deps: PushConfigMethodDependencies,
|
|
33
|
+
): Promise<Response> {
|
|
34
|
+
if (!deps.pushStore) {
|
|
35
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
|
|
36
|
+
}
|
|
37
|
+
const params = req.params as TaskPushNotificationConfig | undefined
|
|
38
|
+
if (!params || typeof params.taskId !== 'string' || !params.pushNotificationConfig?.id) {
|
|
39
|
+
return c.json(
|
|
40
|
+
fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.taskId and params.pushNotificationConfig.id required'),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
if (typeof params.pushNotificationConfig.url !== 'string') {
|
|
44
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'pushNotificationConfig.url required'))
|
|
45
|
+
}
|
|
46
|
+
const task = await deps.taskStore.get(params.taskId)
|
|
47
|
+
if (!task) {
|
|
48
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.taskId}' not found`))
|
|
49
|
+
}
|
|
50
|
+
const accessError = await deps.authorizeTaskAccess(c, req, task)
|
|
51
|
+
if (accessError) return accessError
|
|
52
|
+
const pushUrl = validatePushNotificationUrl(params.pushNotificationConfig.url)
|
|
53
|
+
if (!pushUrl) {
|
|
54
|
+
return c.json(
|
|
55
|
+
fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'pushNotificationConfig.url is not a safe HTTPS destination'),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
if (!deps.demoMode && !deps.urlValidator) {
|
|
59
|
+
return c.json(
|
|
60
|
+
fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'production push URL validation is not configured'),
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
let allowedByHostPolicy = true
|
|
64
|
+
try {
|
|
65
|
+
if (deps.urlValidator) allowedByHostPolicy = await deps.urlValidator(pushUrl)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
allowedByHostPolicy = false
|
|
68
|
+
console.error(
|
|
69
|
+
`[a2a] push URL policy failed for task ${task.id}:`,
|
|
70
|
+
error instanceof Error ? error.message : String(error),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
if (!allowedByHostPolicy) {
|
|
74
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'pushNotificationConfig.url was rejected'))
|
|
75
|
+
}
|
|
76
|
+
await deps.pushStore.set(params.taskId, params.pushNotificationConfig)
|
|
77
|
+
const stored = await deps.pushStore.get(params.taskId, params.pushNotificationConfig.id)
|
|
78
|
+
return c.json(ok(req.id, { taskId: params.taskId, pushNotificationConfig: stored }))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function handlePushGet(
|
|
82
|
+
c: Context,
|
|
83
|
+
req: JSONRPCRequest,
|
|
84
|
+
deps: PushConfigMethodDependencies,
|
|
85
|
+
): Promise<Response> {
|
|
86
|
+
if (!deps.pushStore) {
|
|
87
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
|
|
88
|
+
}
|
|
89
|
+
const params = req.params as TaskPushNotificationConfigGetParams | undefined
|
|
90
|
+
if (!params || typeof params.id !== 'string' || typeof params.pushNotificationConfigId !== 'string') {
|
|
91
|
+
return c.json(
|
|
92
|
+
fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id and params.pushNotificationConfigId required'),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
const task = await deps.taskStore.get(params.id)
|
|
96
|
+
if (!task) {
|
|
97
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`))
|
|
98
|
+
}
|
|
99
|
+
const accessError = await deps.authorizeTaskAccess(c, req, task)
|
|
100
|
+
if (accessError) return accessError
|
|
101
|
+
const cfg = await deps.pushStore.get(params.id, params.pushNotificationConfigId)
|
|
102
|
+
if (!cfg) {
|
|
103
|
+
return c.json(
|
|
104
|
+
fail(
|
|
105
|
+
req.id,
|
|
106
|
+
A2A_ERROR_CODES.TASK_NOT_FOUND,
|
|
107
|
+
`push config '${params.pushNotificationConfigId}' not found for task '${params.id}'`,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return c.json(ok(req.id, { taskId: params.id, pushNotificationConfig: cfg }))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function handlePushList(
|
|
115
|
+
c: Context,
|
|
116
|
+
req: JSONRPCRequest,
|
|
117
|
+
deps: PushConfigMethodDependencies,
|
|
118
|
+
): Promise<Response> {
|
|
119
|
+
if (!deps.pushStore) {
|
|
120
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
|
|
121
|
+
}
|
|
122
|
+
const params = req.params as TaskIdParams | undefined
|
|
123
|
+
if (!params || typeof params.id !== 'string') {
|
|
124
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id required'))
|
|
125
|
+
}
|
|
126
|
+
const task = await deps.taskStore.get(params.id)
|
|
127
|
+
if (!task) {
|
|
128
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`))
|
|
129
|
+
}
|
|
130
|
+
const accessError = await deps.authorizeTaskAccess(c, req, task)
|
|
131
|
+
if (accessError) return accessError
|
|
132
|
+
const configs = await deps.pushStore.list(params.id)
|
|
133
|
+
return c.json(ok(req.id, configs.map((cfg) => ({ taskId: params.id, pushNotificationConfig: cfg }))))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function handlePushDelete(
|
|
137
|
+
c: Context,
|
|
138
|
+
req: JSONRPCRequest,
|
|
139
|
+
deps: PushConfigMethodDependencies,
|
|
140
|
+
): Promise<Response> {
|
|
141
|
+
if (!deps.pushStore) {
|
|
142
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.PUSH_NOT_SUPPORTED, 'push notifications not configured'))
|
|
143
|
+
}
|
|
144
|
+
const params = req.params as TaskPushNotificationConfigGetParams | undefined
|
|
145
|
+
if (!params || typeof params.id !== 'string' || typeof params.pushNotificationConfigId !== 'string') {
|
|
146
|
+
return c.json(
|
|
147
|
+
fail(req.id, A2A_ERROR_CODES.INVALID_PARAMS, 'params.id and params.pushNotificationConfigId required'),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
const task = await deps.taskStore.get(params.id)
|
|
151
|
+
if (!task) {
|
|
152
|
+
return c.json(fail(req.id, A2A_ERROR_CODES.TASK_NOT_FOUND, `task '${params.id}' not found`))
|
|
153
|
+
}
|
|
154
|
+
const accessError = await deps.authorizeTaskAccess(c, req, task)
|
|
155
|
+
if (accessError) return accessError
|
|
156
|
+
await deps.pushStore.delete(params.id, params.pushNotificationConfigId)
|
|
157
|
+
return c.json(ok(req.id, null))
|
|
158
|
+
}
|