@vallum/standards 0.0.0-prerelease
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 +56 -0
- package/dist/a2a.d.ts +1 -0
- package/dist/a2a.js +1 -0
- package/dist/a2aHttp.d.ts +63 -0
- package/dist/a2aHttp.js +338 -0
- package/dist/a2aNodeServer.d.ts +17 -0
- package/dist/a2aNodeServer.js +156 -0
- package/dist/a2aPush.d.ts +162 -0
- package/dist/a2aPush.js +608 -0
- package/dist/a2aTask.d.ts +125 -0
- package/dist/a2aTask.js +327 -0
- package/dist/ap2.d.ts +38 -0
- package/dist/ap2.js +56 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/x402.d.ts +62 -0
- package/dist/x402.js +76 -0
- package/package.json +38 -0
package/dist/a2aPush.js
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { A2A_TASK_MEDIA_TYPE, A2A_TASK_PROTOCOL_VERSION, redactA2ATaskForLog, } from "./a2aTask.js";
|
|
5
|
+
export class A2APushNotificationError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
status;
|
|
8
|
+
constructor(code, message, status = 400) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "A2APushNotificationError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export class LocalA2APushNotificationStore {
|
|
16
|
+
#configs = new Map();
|
|
17
|
+
put(config) {
|
|
18
|
+
const taskConfigs = this.#configs.get(config.taskId) ?? new Map();
|
|
19
|
+
taskConfigs.set(config.id, clone(config));
|
|
20
|
+
this.#configs.set(config.taskId, taskConfigs);
|
|
21
|
+
return clone(config);
|
|
22
|
+
}
|
|
23
|
+
get(taskId, id) {
|
|
24
|
+
const config = this.#configs.get(taskId)?.get(id);
|
|
25
|
+
return config ? clone(config) : undefined;
|
|
26
|
+
}
|
|
27
|
+
list(taskId, pageSize) {
|
|
28
|
+
const configs = [...(this.#configs.get(taskId)?.values() ?? [])]
|
|
29
|
+
.sort((left, right) => left.createdAt.localeCompare(right.createdAt))
|
|
30
|
+
.slice(0, pageSize ?? Number.POSITIVE_INFINITY)
|
|
31
|
+
.map((config) => clone(config));
|
|
32
|
+
return { configs };
|
|
33
|
+
}
|
|
34
|
+
delete(taskId, id) {
|
|
35
|
+
const taskConfigs = this.#configs.get(taskId);
|
|
36
|
+
if (!taskConfigs)
|
|
37
|
+
return false;
|
|
38
|
+
const deleted = taskConfigs.delete(id);
|
|
39
|
+
if (taskConfigs.size === 0)
|
|
40
|
+
this.#configs.delete(taskId);
|
|
41
|
+
return deleted;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class LocalA2APushNotificationAttemptStore {
|
|
45
|
+
#attempts = [];
|
|
46
|
+
record(attempt) {
|
|
47
|
+
this.#attempts.push(clone(attempt));
|
|
48
|
+
}
|
|
49
|
+
list() {
|
|
50
|
+
return this.#attempts.map((attempt) => clone(attempt));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export class JsonlA2APushNotificationAttemptStore {
|
|
54
|
+
#filePath;
|
|
55
|
+
constructor(filePath) {
|
|
56
|
+
if (typeof filePath !== "string" || filePath.trim() === "") {
|
|
57
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push attempt store path is required.");
|
|
58
|
+
}
|
|
59
|
+
this.#filePath = filePath;
|
|
60
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
record(attempt) {
|
|
63
|
+
appendFileSync(this.#filePath, `${JSON.stringify(sanitizePushAttempt(attempt))}\n`, "utf8");
|
|
64
|
+
}
|
|
65
|
+
list() {
|
|
66
|
+
if (!existsSync(this.#filePath))
|
|
67
|
+
return [];
|
|
68
|
+
const raw = readFileSync(this.#filePath, "utf8");
|
|
69
|
+
if (raw.trim() === "")
|
|
70
|
+
return [];
|
|
71
|
+
return raw.split(/\n/u)
|
|
72
|
+
.filter((line) => line.trim() !== "")
|
|
73
|
+
.map((line) => {
|
|
74
|
+
try {
|
|
75
|
+
return sanitizePushAttempt(JSON.parse(line));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push attempt store JSONL is invalid.");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class JsonFileA2APushNotificationDeliveryQueue {
|
|
84
|
+
#filePath;
|
|
85
|
+
constructor(filePath) {
|
|
86
|
+
if (typeof filePath !== "string" || filePath.trim() === "") {
|
|
87
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push delivery queue path is required.");
|
|
88
|
+
}
|
|
89
|
+
this.#filePath = filePath;
|
|
90
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
91
|
+
if (!existsSync(filePath))
|
|
92
|
+
this.#write([]);
|
|
93
|
+
}
|
|
94
|
+
enqueue(request, options = {}) {
|
|
95
|
+
const entries = this.#read();
|
|
96
|
+
const entry = sanitizeQueueEntry({
|
|
97
|
+
id: options.id ?? `push_delivery_${crypto.randomUUID()}`,
|
|
98
|
+
enqueuedAt: (options.enqueuedAt ?? new Date()).toISOString(),
|
|
99
|
+
status: "queued",
|
|
100
|
+
request,
|
|
101
|
+
});
|
|
102
|
+
entries.push(entry);
|
|
103
|
+
this.#write(entries);
|
|
104
|
+
return clone(entry);
|
|
105
|
+
}
|
|
106
|
+
list() {
|
|
107
|
+
return this.#read().map((entry) => clone(entry));
|
|
108
|
+
}
|
|
109
|
+
claim(options = {}) {
|
|
110
|
+
const entries = this.#read();
|
|
111
|
+
const index = entries.findIndex((entry) => entry.status === "queued");
|
|
112
|
+
if (index < 0)
|
|
113
|
+
return undefined;
|
|
114
|
+
const claimed = sanitizeQueueEntry({
|
|
115
|
+
...entries[index],
|
|
116
|
+
status: "claimed",
|
|
117
|
+
claimedAt: (options.now ?? new Date()).toISOString(),
|
|
118
|
+
});
|
|
119
|
+
entries[index] = claimed;
|
|
120
|
+
this.#write(entries);
|
|
121
|
+
return clone(claimed);
|
|
122
|
+
}
|
|
123
|
+
complete(id, options = {}) {
|
|
124
|
+
const entries = this.#read();
|
|
125
|
+
const index = entries.findIndex((entry) => entry.id === id && entry.status !== "completed");
|
|
126
|
+
if (index < 0)
|
|
127
|
+
return false;
|
|
128
|
+
entries[index] = sanitizeQueueEntry({
|
|
129
|
+
...entries[index],
|
|
130
|
+
status: "completed",
|
|
131
|
+
completedAt: (options.now ?? new Date()).toISOString(),
|
|
132
|
+
});
|
|
133
|
+
this.#write(entries);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
fail(id, options = {}) {
|
|
137
|
+
const entries = this.#read();
|
|
138
|
+
const index = entries.findIndex((entry) => entry.id === id && entry.status !== "completed" && entry.status !== "failed");
|
|
139
|
+
if (index < 0)
|
|
140
|
+
return false;
|
|
141
|
+
entries[index] = sanitizeQueueEntry({
|
|
142
|
+
...entries[index],
|
|
143
|
+
status: "failed",
|
|
144
|
+
failedAt: (options.now ?? new Date()).toISOString(),
|
|
145
|
+
});
|
|
146
|
+
this.#write(entries);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
#read() {
|
|
150
|
+
if (!existsSync(this.#filePath))
|
|
151
|
+
return [];
|
|
152
|
+
const raw = readFileSync(this.#filePath, "utf8");
|
|
153
|
+
if (raw.trim() === "")
|
|
154
|
+
return [];
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(raw);
|
|
157
|
+
if (!Array.isArray(parsed)) {
|
|
158
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push delivery queue JSON is invalid.");
|
|
159
|
+
}
|
|
160
|
+
return parsed.map((entry) => sanitizeQueueEntry(entry));
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error instanceof A2APushNotificationError)
|
|
164
|
+
throw error;
|
|
165
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push delivery queue JSON is invalid.");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
#write(entries) {
|
|
169
|
+
const sanitized = entries.map((entry) => sanitizeQueueEntry(entry));
|
|
170
|
+
writeFileSync(this.#filePath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function createA2APushNotificationConfig(options) {
|
|
174
|
+
const config = parsePushNotificationConfig(options.taskId, options.value, {
|
|
175
|
+
allowedCallbackHosts: options.allowedCallbackHosts,
|
|
176
|
+
});
|
|
177
|
+
return options.store.put({
|
|
178
|
+
...config,
|
|
179
|
+
id: config.id ?? `push_${crypto.randomUUID()}`,
|
|
180
|
+
taskId: options.taskId,
|
|
181
|
+
createdAt: (options.now ?? new Date()).toISOString(),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
export function getA2APushNotificationConfig(options) {
|
|
185
|
+
const config = options.store.get(options.taskId, options.id);
|
|
186
|
+
if (!config) {
|
|
187
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_NOT_FOUND", "A2A push notification config was not found.", 404);
|
|
188
|
+
}
|
|
189
|
+
return config;
|
|
190
|
+
}
|
|
191
|
+
export function listA2APushNotificationConfigs(options) {
|
|
192
|
+
return options.store.list(options.taskId, options.pageSize);
|
|
193
|
+
}
|
|
194
|
+
export function deleteA2APushNotificationConfig(options) {
|
|
195
|
+
return {
|
|
196
|
+
taskId: options.taskId,
|
|
197
|
+
id: options.id,
|
|
198
|
+
deleted: options.store.delete(options.taskId, options.id),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export async function deliverA2APushNotifications(options) {
|
|
202
|
+
const configs = options.store.list(options.task.id).configs;
|
|
203
|
+
const attempts = [];
|
|
204
|
+
const maxAttempts = normalizeMaxAttempts(options.maxAttempts);
|
|
205
|
+
const retryDelayMs = normalizeRetryDelayMs(options.retryDelayMs);
|
|
206
|
+
const now = options.now ?? (() => new Date());
|
|
207
|
+
for (const config of configs) {
|
|
208
|
+
const request = buildA2APushNotificationDeliveryRequest(config, options.task);
|
|
209
|
+
if (!options.transport) {
|
|
210
|
+
const attempt = {
|
|
211
|
+
configId: config.id,
|
|
212
|
+
taskId: config.taskId,
|
|
213
|
+
url: config.url,
|
|
214
|
+
attemptNumber: 1,
|
|
215
|
+
observedAt: now().toISOString(),
|
|
216
|
+
status: "skipped",
|
|
217
|
+
errorCode: "A2A_PUSH_TRANSPORT_UNCONFIGURED",
|
|
218
|
+
};
|
|
219
|
+
recordPushAttempt(attempt, attempts, options.attemptStore);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
for (let attemptNumber = 1; attemptNumber <= maxAttempts; attemptNumber += 1) {
|
|
223
|
+
try {
|
|
224
|
+
const response = await options.transport(request);
|
|
225
|
+
const delivered = response.status >= 200 && response.status <= 299;
|
|
226
|
+
const observedAt = now();
|
|
227
|
+
const attempt = {
|
|
228
|
+
configId: config.id,
|
|
229
|
+
taskId: config.taskId,
|
|
230
|
+
url: config.url,
|
|
231
|
+
attemptNumber,
|
|
232
|
+
observedAt: observedAt.toISOString(),
|
|
233
|
+
status: delivered ? "delivered" : "failed",
|
|
234
|
+
httpStatus: response.status,
|
|
235
|
+
...(delivered ? {} : {
|
|
236
|
+
errorCode: "A2A_PUSH_TRANSPORT_FAILED",
|
|
237
|
+
...(attemptNumber < maxAttempts ? { nextRetryAt: addMilliseconds(observedAt, retryDelayMs).toISOString() } : {}),
|
|
238
|
+
}),
|
|
239
|
+
};
|
|
240
|
+
recordPushAttempt(attempt, attempts, options.attemptStore);
|
|
241
|
+
if (delivered)
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
const observedAt = now();
|
|
246
|
+
const attempt = {
|
|
247
|
+
configId: config.id,
|
|
248
|
+
taskId: config.taskId,
|
|
249
|
+
url: config.url,
|
|
250
|
+
attemptNumber,
|
|
251
|
+
observedAt: observedAt.toISOString(),
|
|
252
|
+
status: "failed",
|
|
253
|
+
errorCode: "A2A_PUSH_TRANSPORT_FAILED",
|
|
254
|
+
...(attemptNumber < maxAttempts ? { nextRetryAt: addMilliseconds(observedAt, retryDelayMs).toISOString() } : {}),
|
|
255
|
+
};
|
|
256
|
+
recordPushAttempt(attempt, attempts, options.attemptStore);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return { attempts };
|
|
261
|
+
}
|
|
262
|
+
export function queueA2APushNotificationDeliveries(options) {
|
|
263
|
+
const configs = options.store.list(options.task.id).configs;
|
|
264
|
+
const entries = configs.map((config) => options.queue.enqueue(buildA2APushNotificationDeliveryRequest(config, options.task), { enqueuedAt: (options.now ?? (() => new Date()))() }));
|
|
265
|
+
return { entries };
|
|
266
|
+
}
|
|
267
|
+
export async function processNextA2APushNotificationDelivery(options) {
|
|
268
|
+
const observedAt = (options.now ?? (() => new Date()))();
|
|
269
|
+
const entry = options.queue.claim({ now: observedAt });
|
|
270
|
+
if (!entry)
|
|
271
|
+
return { status: "empty" };
|
|
272
|
+
try {
|
|
273
|
+
const response = await options.transport(entry.request);
|
|
274
|
+
const delivered = response.status >= 200 && response.status <= 299;
|
|
275
|
+
const attempt = {
|
|
276
|
+
configId: entry.request.config.id,
|
|
277
|
+
taskId: entry.request.config.taskId,
|
|
278
|
+
url: entry.request.url,
|
|
279
|
+
attemptNumber: 1,
|
|
280
|
+
observedAt: observedAt.toISOString(),
|
|
281
|
+
status: delivered ? "delivered" : "failed",
|
|
282
|
+
httpStatus: response.status,
|
|
283
|
+
...(delivered ? {} : { errorCode: "A2A_PUSH_TRANSPORT_FAILED" }),
|
|
284
|
+
};
|
|
285
|
+
options.attemptStore?.record(attempt);
|
|
286
|
+
if (delivered) {
|
|
287
|
+
options.queue.complete(entry.id, { now: observedAt });
|
|
288
|
+
return { status: "delivered", entry: options.queue.list().find((candidate) => candidate.id === entry.id), attempt };
|
|
289
|
+
}
|
|
290
|
+
options.queue.fail(entry.id, { now: observedAt });
|
|
291
|
+
return { status: "failed", entry: options.queue.list().find((candidate) => candidate.id === entry.id), attempt };
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
const attempt = {
|
|
295
|
+
configId: entry.request.config.id,
|
|
296
|
+
taskId: entry.request.config.taskId,
|
|
297
|
+
url: entry.request.url,
|
|
298
|
+
attemptNumber: 1,
|
|
299
|
+
observedAt: observedAt.toISOString(),
|
|
300
|
+
status: "failed",
|
|
301
|
+
errorCode: "A2A_PUSH_TRANSPORT_FAILED",
|
|
302
|
+
};
|
|
303
|
+
options.attemptStore?.record(attempt);
|
|
304
|
+
options.queue.fail(entry.id, { now: observedAt });
|
|
305
|
+
return { status: "failed", entry: options.queue.list().find((candidate) => candidate.id === entry.id), attempt };
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
export function buildA2APushNotificationDeliveryRequest(config, task) {
|
|
309
|
+
const body = {
|
|
310
|
+
kind: "task",
|
|
311
|
+
task: redactA2ATaskForLog(task),
|
|
312
|
+
};
|
|
313
|
+
return {
|
|
314
|
+
method: "POST",
|
|
315
|
+
url: config.url,
|
|
316
|
+
headers: {
|
|
317
|
+
"content-type": `${A2A_TASK_MEDIA_TYPE}; charset=utf-8`,
|
|
318
|
+
"a2a-version": A2A_TASK_PROTOCOL_VERSION,
|
|
319
|
+
},
|
|
320
|
+
body,
|
|
321
|
+
json: `${JSON.stringify(body)}\n`,
|
|
322
|
+
config: clone(config),
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export function createA2APushHttpTransport(options = {}) {
|
|
326
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
327
|
+
if (typeof fetchImpl !== "function") {
|
|
328
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push HTTP transport requires fetch support.");
|
|
329
|
+
}
|
|
330
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
331
|
+
return async (request) => {
|
|
332
|
+
const url = safeWebhookUrl(request.url, {
|
|
333
|
+
allowedCallbackHosts: options.allowedCallbackHosts,
|
|
334
|
+
});
|
|
335
|
+
const controller = new AbortController();
|
|
336
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
337
|
+
try {
|
|
338
|
+
const response = await fetchImpl(url, {
|
|
339
|
+
method: request.method,
|
|
340
|
+
headers: publicDeliveryHeaders(request.headers),
|
|
341
|
+
body: request.json,
|
|
342
|
+
redirect: "manual",
|
|
343
|
+
signal: controller.signal,
|
|
344
|
+
});
|
|
345
|
+
return { status: response.status };
|
|
346
|
+
}
|
|
347
|
+
finally {
|
|
348
|
+
clearTimeout(timeout);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function parsePushNotificationConfig(taskId, value, options = {}) {
|
|
353
|
+
if (!isRecord(value)) {
|
|
354
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push notification config must be an object.");
|
|
355
|
+
}
|
|
356
|
+
const bodyTaskId = optionalString(value.taskId, "$.taskId");
|
|
357
|
+
if (bodyTaskId !== undefined && bodyTaskId !== taskId) {
|
|
358
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push notification task id must match the route.");
|
|
359
|
+
}
|
|
360
|
+
if (typeof value.token === "string" && value.token.trim() !== "") {
|
|
361
|
+
throw new A2APushNotificationError("A2A_PUSH_CREDENTIAL_STORAGE_UNSUPPORTED", "A2A local push notification config does not store webhook tokens.");
|
|
362
|
+
}
|
|
363
|
+
const authentication = parseAuthentication(value.authentication);
|
|
364
|
+
const id = optionalString(value.id, "$.id");
|
|
365
|
+
const tenant = optionalString(value.tenant, "$.tenant");
|
|
366
|
+
return {
|
|
367
|
+
...(id ? { id } : {}),
|
|
368
|
+
url: safeWebhookUrl(value.url, {
|
|
369
|
+
allowedCallbackHosts: options.allowedCallbackHosts,
|
|
370
|
+
}),
|
|
371
|
+
...(tenant ? { tenant } : {}),
|
|
372
|
+
...(authentication ? { authentication } : {}),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
function parseAuthentication(value) {
|
|
376
|
+
if (value === undefined)
|
|
377
|
+
return undefined;
|
|
378
|
+
if (!isRecord(value)) {
|
|
379
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push notification authentication must be an object.");
|
|
380
|
+
}
|
|
381
|
+
if (typeof value.credentials === "string" && value.credentials.trim() !== "") {
|
|
382
|
+
throw new A2APushNotificationError("A2A_PUSH_CREDENTIAL_STORAGE_UNSUPPORTED", "A2A local push notification config does not store webhook credentials.");
|
|
383
|
+
}
|
|
384
|
+
const schemes = Array.isArray(value.schemes)
|
|
385
|
+
? value.schemes
|
|
386
|
+
: typeof value.scheme === "string"
|
|
387
|
+
? [value.scheme]
|
|
388
|
+
: [];
|
|
389
|
+
if (schemes.length === 0) {
|
|
390
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push notification authentication schemes are required when authentication is present.");
|
|
391
|
+
}
|
|
392
|
+
const normalized = schemes.map((scheme, index) => {
|
|
393
|
+
if (typeof scheme !== "string" || scheme.trim() === "") {
|
|
394
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", `A2A push notification authentication scheme ${index} is invalid.`);
|
|
395
|
+
}
|
|
396
|
+
return scheme.trim();
|
|
397
|
+
});
|
|
398
|
+
return { schemes: normalized };
|
|
399
|
+
}
|
|
400
|
+
function safeWebhookUrl(value, options = {}) {
|
|
401
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
402
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push notification URL is required.");
|
|
403
|
+
}
|
|
404
|
+
let parsed;
|
|
405
|
+
try {
|
|
406
|
+
parsed = new URL(value);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
throw new A2APushNotificationError("A2A_PUSH_URL_UNSAFE", "A2A push notification URL must be a valid HTTPS URL.");
|
|
410
|
+
}
|
|
411
|
+
if (parsed.protocol !== "https:"
|
|
412
|
+
|| parsed.username
|
|
413
|
+
|| parsed.password
|
|
414
|
+
|| parsed.search
|
|
415
|
+
|| parsed.hash
|
|
416
|
+
|| isUnsafeWebhookHost(parsed.hostname)) {
|
|
417
|
+
throw new A2APushNotificationError("A2A_PUSH_URL_UNSAFE", "A2A push notification URL must be public HTTPS without credentials, query strings, or fragments.");
|
|
418
|
+
}
|
|
419
|
+
if (!isAllowedCallbackHost(parsed.hostname, options.allowedCallbackHosts)) {
|
|
420
|
+
throw new A2APushNotificationError("A2A_PUSH_URL_UNSAFE", "A2A push notification URL host is not on the configured allowlist.");
|
|
421
|
+
}
|
|
422
|
+
return parsed.toString();
|
|
423
|
+
}
|
|
424
|
+
function optionalString(value, path) {
|
|
425
|
+
if (value === undefined)
|
|
426
|
+
return undefined;
|
|
427
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
428
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", `${path} must be a non-empty string when present.`);
|
|
429
|
+
}
|
|
430
|
+
return value.trim();
|
|
431
|
+
}
|
|
432
|
+
function normalizeTimeoutMs(value) {
|
|
433
|
+
if (value === undefined)
|
|
434
|
+
return 5000;
|
|
435
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
436
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push HTTP transport timeout must be positive.");
|
|
437
|
+
}
|
|
438
|
+
return Math.floor(value);
|
|
439
|
+
}
|
|
440
|
+
function normalizeMaxAttempts(value) {
|
|
441
|
+
if (value === undefined)
|
|
442
|
+
return 1;
|
|
443
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
444
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push delivery max attempts must be positive.");
|
|
445
|
+
}
|
|
446
|
+
return Math.floor(value);
|
|
447
|
+
}
|
|
448
|
+
function normalizeRetryDelayMs(value) {
|
|
449
|
+
if (value === undefined)
|
|
450
|
+
return 1000;
|
|
451
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
452
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push delivery retry delay must be zero or positive.");
|
|
453
|
+
}
|
|
454
|
+
return Math.floor(value);
|
|
455
|
+
}
|
|
456
|
+
function addMilliseconds(value, milliseconds) {
|
|
457
|
+
return new Date(value.getTime() + milliseconds);
|
|
458
|
+
}
|
|
459
|
+
function recordPushAttempt(attempt, attempts, store) {
|
|
460
|
+
attempts.push(attempt);
|
|
461
|
+
store?.record(attempt);
|
|
462
|
+
}
|
|
463
|
+
function publicDeliveryHeaders(headers) {
|
|
464
|
+
const result = {};
|
|
465
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
466
|
+
const normalized = name.trim().toLowerCase();
|
|
467
|
+
if (normalized === "authorization"
|
|
468
|
+
|| normalized === "proxy-authorization"
|
|
469
|
+
|| normalized === "cookie"
|
|
470
|
+
|| normalized === "set-cookie") {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
result[normalized] = value;
|
|
474
|
+
}
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
477
|
+
function sanitizeQueueEntry(entry) {
|
|
478
|
+
return {
|
|
479
|
+
id: nonEmptyAttemptString(entry.id, "queue id"),
|
|
480
|
+
enqueuedAt: validAttemptIsoDate(entry.enqueuedAt, "queue enqueue time"),
|
|
481
|
+
...(entry.claimedAt === undefined ? {} : { claimedAt: validAttemptIsoDate(entry.claimedAt, "queue claim time") }),
|
|
482
|
+
...(entry.completedAt === undefined ? {} : { completedAt: validAttemptIsoDate(entry.completedAt, "queue completion time") }),
|
|
483
|
+
...(entry.failedAt === undefined ? {} : { failedAt: validAttemptIsoDate(entry.failedAt, "queue failure time") }),
|
|
484
|
+
status: validQueueStatus(entry.status),
|
|
485
|
+
request: sanitizeDeliveryRequest(entry.request),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
function sanitizeDeliveryRequest(request) {
|
|
489
|
+
const config = sanitizePushConfig(request.config);
|
|
490
|
+
const body = {
|
|
491
|
+
kind: "task",
|
|
492
|
+
task: redactA2ATaskForLog(request.body.task),
|
|
493
|
+
};
|
|
494
|
+
return {
|
|
495
|
+
method: "POST",
|
|
496
|
+
url: safeWebhookUrl(request.url),
|
|
497
|
+
headers: publicDeliveryHeaders({
|
|
498
|
+
...request.headers,
|
|
499
|
+
"content-type": `${A2A_TASK_MEDIA_TYPE}; charset=utf-8`,
|
|
500
|
+
"a2a-version": A2A_TASK_PROTOCOL_VERSION,
|
|
501
|
+
}),
|
|
502
|
+
body,
|
|
503
|
+
json: `${JSON.stringify(body)}\n`,
|
|
504
|
+
config,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
function sanitizePushConfig(config) {
|
|
508
|
+
return {
|
|
509
|
+
id: nonEmptyAttemptString(config.id, "config id"),
|
|
510
|
+
taskId: nonEmptyAttemptString(config.taskId, "task id"),
|
|
511
|
+
url: safeWebhookUrl(config.url),
|
|
512
|
+
createdAt: validAttemptIsoDate(config.createdAt, "config created time"),
|
|
513
|
+
...(config.tenant ? { tenant: config.tenant } : {}),
|
|
514
|
+
...(config.authentication ? { authentication: {
|
|
515
|
+
schemes: config.authentication.schemes.map((scheme) => nonEmptyAttemptString(scheme, "authentication scheme")),
|
|
516
|
+
} } : {}),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function sanitizePushAttempt(attempt) {
|
|
520
|
+
return {
|
|
521
|
+
configId: nonEmptyAttemptString(attempt.configId, "config id"),
|
|
522
|
+
taskId: nonEmptyAttemptString(attempt.taskId, "task id"),
|
|
523
|
+
url: safeWebhookUrl(attempt.url),
|
|
524
|
+
...(attempt.attemptNumber === undefined ? {} : { attemptNumber: positiveAttemptInteger(attempt.attemptNumber, "attempt number") }),
|
|
525
|
+
...(attempt.observedAt === undefined ? {} : { observedAt: validAttemptIsoDate(attempt.observedAt, "observed time") }),
|
|
526
|
+
...(attempt.nextRetryAt === undefined ? {} : { nextRetryAt: validAttemptIsoDate(attempt.nextRetryAt, "retry time") }),
|
|
527
|
+
status: validAttemptStatus(attempt.status),
|
|
528
|
+
...(attempt.httpStatus === undefined ? {} : { httpStatus: validHttpStatus(attempt.httpStatus) }),
|
|
529
|
+
...(attempt.errorCode === undefined ? {} : { errorCode: validAttemptErrorCode(attempt.errorCode) }),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function nonEmptyAttemptString(value, label) {
|
|
533
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
534
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", `A2A push attempt ${label} is invalid.`);
|
|
535
|
+
}
|
|
536
|
+
return value.trim();
|
|
537
|
+
}
|
|
538
|
+
function positiveAttemptInteger(value, label) {
|
|
539
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
540
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", `A2A push attempt ${label} is invalid.`);
|
|
541
|
+
}
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
function validHttpStatus(value) {
|
|
545
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 100 || value > 599) {
|
|
546
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push attempt HTTP status is invalid.");
|
|
547
|
+
}
|
|
548
|
+
return value;
|
|
549
|
+
}
|
|
550
|
+
function validAttemptIsoDate(value, label) {
|
|
551
|
+
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
|
|
552
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", `A2A push attempt ${label} is invalid.`);
|
|
553
|
+
}
|
|
554
|
+
return new Date(value).toISOString();
|
|
555
|
+
}
|
|
556
|
+
function validAttemptStatus(value) {
|
|
557
|
+
if (value === "delivered" || value === "failed" || value === "skipped")
|
|
558
|
+
return value;
|
|
559
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push attempt status is invalid.");
|
|
560
|
+
}
|
|
561
|
+
function validQueueStatus(value) {
|
|
562
|
+
if (value === "queued" || value === "claimed" || value === "completed" || value === "failed")
|
|
563
|
+
return value;
|
|
564
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push delivery queue status is invalid.");
|
|
565
|
+
}
|
|
566
|
+
function validAttemptErrorCode(value) {
|
|
567
|
+
if (value === "A2A_PUSH_TRANSPORT_UNCONFIGURED" || value === "A2A_PUSH_TRANSPORT_FAILED")
|
|
568
|
+
return value;
|
|
569
|
+
throw new A2APushNotificationError("A2A_PUSH_CONFIG_INVALID", "A2A push attempt error code is invalid.");
|
|
570
|
+
}
|
|
571
|
+
function isUnsafeWebhookHost(hostname) {
|
|
572
|
+
const normalized = hostname.trim().toLowerCase();
|
|
573
|
+
if (normalized === "localhost" || normalized.endsWith(".localhost"))
|
|
574
|
+
return true;
|
|
575
|
+
const ipVersion = isIP(normalized);
|
|
576
|
+
if (ipVersion === 4) {
|
|
577
|
+
const [first = 0, second = 0] = normalized.split(".").map((octet) => Number.parseInt(octet, 10));
|
|
578
|
+
return first === 0
|
|
579
|
+
|| first === 10
|
|
580
|
+
|| first === 127
|
|
581
|
+
|| (first === 100 && second >= 64 && second <= 127)
|
|
582
|
+
|| (first === 169 && second === 254)
|
|
583
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
584
|
+
|| (first === 192 && second === 168)
|
|
585
|
+
|| (first === 198 && (second === 18 || second === 19))
|
|
586
|
+
|| first >= 224;
|
|
587
|
+
}
|
|
588
|
+
if (ipVersion === 6) {
|
|
589
|
+
return normalized === "::"
|
|
590
|
+
|| normalized === "::1"
|
|
591
|
+
|| normalized.startsWith("fc")
|
|
592
|
+
|| normalized.startsWith("fd")
|
|
593
|
+
|| normalized.startsWith("fe80:");
|
|
594
|
+
}
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
function isAllowedCallbackHost(hostname, allowedCallbackHosts) {
|
|
598
|
+
if (!allowedCallbackHosts || allowedCallbackHosts.length === 0)
|
|
599
|
+
return true;
|
|
600
|
+
const normalized = hostname.trim().toLowerCase();
|
|
601
|
+
return allowedCallbackHosts.some((allowed) => allowed.trim().toLowerCase() === normalized);
|
|
602
|
+
}
|
|
603
|
+
function clone(value) {
|
|
604
|
+
return JSON.parse(JSON.stringify(value));
|
|
605
|
+
}
|
|
606
|
+
function isRecord(value) {
|
|
607
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
608
|
+
}
|