@svsprotocol/solana 0.1.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/LICENSE +158 -0
- package/README.md +365 -0
- package/dist/action-production-proof-evidence.js +553 -0
- package/dist/adapter-catalog.d.ts +29 -0
- package/dist/adapter-catalog.js +146 -0
- package/dist/adapter-core.d.ts +48 -0
- package/dist/adapter-core.js +249 -0
- package/dist/approval-signature.js +197 -0
- package/dist/base58.js +69 -0
- package/dist/bot-auth.js +50 -0
- package/dist/bot-certification-evidence.js +342 -0
- package/dist/bot-first-action-runbook.js +299 -0
- package/dist/bot-integration-contract.js +41 -0
- package/dist/certified-submit-status.js +176 -0
- package/dist/common.d.ts +1135 -0
- package/dist/elizaos.d.ts +43 -0
- package/dist/elizaos.js +227 -0
- package/dist/goat.d.ts +47 -0
- package/dist/goat.js +261 -0
- package/dist/index.d.ts +330 -0
- package/dist/index.js +128 -0
- package/dist/protocol.d.ts +205 -0
- package/dist/protocol.js +900 -0
- package/dist/receipt.js +51 -0
- package/dist/signed-proof-read-protection.js +495 -0
- package/dist/solana-agent-kit.d.ts +35 -0
- package/dist/solana-agent-kit.js +151 -0
- package/dist/svs-client.js +1232 -0
- package/dist/vercel-ai.d.ts +47 -0
- package/dist/vercel-ai.js +266 -0
- package/dist/verified-agent-adoption-kit.js +471 -0
- package/dist/verified-agent-profile.js +329 -0
- package/dist/verified-agent-registry-consumer.js +421 -0
- package/dist/verified-agent-registry.d.ts +36 -0
- package/dist/verified-agent-registry.js +826 -0
- package/dist/verified-agent-trust-score.js +335 -0
- package/dist/webhooks.js +834 -0
- package/package.json +72 -0
package/dist/webhooks.js
ADDED
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { hashObject } from "./receipt.js";
|
|
5
|
+
|
|
6
|
+
export const WEBHOOK_EVENT_VERSION = "svs.webhook-event.v1";
|
|
7
|
+
export const WEBHOOK_SIGNATURE_VERSION = "v1";
|
|
8
|
+
export const WEBHOOK_REPLAY_STORE_VERSION = "svs.webhook-replay-store.v1";
|
|
9
|
+
export const DEFAULT_WEBHOOK_REPLAY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
export const DEFAULT_WEBHOOK_RETRY_DELAYS_MS = [
|
|
11
|
+
60 * 1000,
|
|
12
|
+
5 * 60 * 1000,
|
|
13
|
+
15 * 60 * 1000,
|
|
14
|
+
60 * 60 * 1000,
|
|
15
|
+
6 * 60 * 60 * 1000
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export async function emitWebhookEvent({
|
|
19
|
+
outboxDir = "./data/webhook-outbox",
|
|
20
|
+
botRegistryPath = "./.devnet/bots.json",
|
|
21
|
+
eventType,
|
|
22
|
+
record,
|
|
23
|
+
previousStatus = null,
|
|
24
|
+
eventData = null,
|
|
25
|
+
fetchImpl = globalThis.fetch,
|
|
26
|
+
deliver = true,
|
|
27
|
+
createdAt = new Date().toISOString()
|
|
28
|
+
} = {}) {
|
|
29
|
+
if (!record) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const bot = await findWebhookBot({
|
|
34
|
+
botRegistryPath,
|
|
35
|
+
botId: record.source?.authenticatedBotId ?? record.intent?.botId
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!bot?.webhookUrl) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const event = createWebhookEvent({
|
|
43
|
+
eventType,
|
|
44
|
+
record,
|
|
45
|
+
previousStatus,
|
|
46
|
+
bot,
|
|
47
|
+
eventData,
|
|
48
|
+
createdAt
|
|
49
|
+
});
|
|
50
|
+
const outboxEntry = {
|
|
51
|
+
...event,
|
|
52
|
+
delivery: {
|
|
53
|
+
mode: "outbox",
|
|
54
|
+
url: bot.webhookUrl,
|
|
55
|
+
attemptedAt: null,
|
|
56
|
+
deliveredAt: null,
|
|
57
|
+
statusCode: null,
|
|
58
|
+
ok: false,
|
|
59
|
+
error: null
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const path = join(outboxDir, `${event.createdAt.replaceAll(":", "-")}-${event.eventId}.json`);
|
|
63
|
+
|
|
64
|
+
await writeWebhookEvent(path, outboxEntry);
|
|
65
|
+
|
|
66
|
+
if (!deliver) {
|
|
67
|
+
return {
|
|
68
|
+
path,
|
|
69
|
+
event: outboxEntry
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const delivered = await deliverWebhookEvent({
|
|
74
|
+
path,
|
|
75
|
+
event: outboxEntry,
|
|
76
|
+
webhookSecret: bot.webhookSecret,
|
|
77
|
+
fetchImpl
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
path,
|
|
82
|
+
event: delivered
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function emitBotWebhookTest({
|
|
87
|
+
outboxDir = "./data/webhook-outbox",
|
|
88
|
+
botRegistryPath = "./.devnet/bots.json",
|
|
89
|
+
botId,
|
|
90
|
+
fetchImpl = globalThis.fetch,
|
|
91
|
+
deliver = true,
|
|
92
|
+
createdAt = new Date().toISOString()
|
|
93
|
+
} = {}) {
|
|
94
|
+
if (!botId) {
|
|
95
|
+
throw new Error("botId is required.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const bot = await findWebhookBot({ botRegistryPath, botId });
|
|
99
|
+
|
|
100
|
+
if (!bot?.webhookUrl) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const record = {
|
|
105
|
+
id: `webhook-test-${randomUUID()}`,
|
|
106
|
+
status: "webhook_test",
|
|
107
|
+
authorizationType: "webhook_test",
|
|
108
|
+
intent: {
|
|
109
|
+
botId,
|
|
110
|
+
controllerWallet: null
|
|
111
|
+
},
|
|
112
|
+
source: {
|
|
113
|
+
authenticatedBotId: botId,
|
|
114
|
+
actionRequestHash: null,
|
|
115
|
+
idempotencyKey: null
|
|
116
|
+
},
|
|
117
|
+
policySummary: {
|
|
118
|
+
id: null
|
|
119
|
+
},
|
|
120
|
+
receipt: {
|
|
121
|
+
receiptId: null,
|
|
122
|
+
receiptHash: null
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
const event = createWebhookEvent({
|
|
126
|
+
eventType: "bot.webhook_test",
|
|
127
|
+
record,
|
|
128
|
+
previousStatus: null,
|
|
129
|
+
bot,
|
|
130
|
+
createdAt
|
|
131
|
+
});
|
|
132
|
+
const outboxEntry = {
|
|
133
|
+
...event,
|
|
134
|
+
delivery: {
|
|
135
|
+
mode: "outbox",
|
|
136
|
+
url: bot.webhookUrl,
|
|
137
|
+
attemptedAt: null,
|
|
138
|
+
deliveredAt: null,
|
|
139
|
+
statusCode: null,
|
|
140
|
+
ok: false,
|
|
141
|
+
error: null
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const path = join(outboxDir, `${event.createdAt.replaceAll(":", "-")}-${event.eventId}.json`);
|
|
145
|
+
|
|
146
|
+
await writeWebhookEvent(path, outboxEntry);
|
|
147
|
+
|
|
148
|
+
if (!deliver) {
|
|
149
|
+
return {
|
|
150
|
+
path,
|
|
151
|
+
event: outboxEntry
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const delivered = await deliverWebhookEvent({
|
|
156
|
+
path,
|
|
157
|
+
event: outboxEntry,
|
|
158
|
+
webhookSecret: bot.webhookSecret,
|
|
159
|
+
fetchImpl
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
path,
|
|
164
|
+
event: delivered
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function listWebhookEvents({
|
|
169
|
+
outboxDir = "./data/webhook-outbox"
|
|
170
|
+
} = {}) {
|
|
171
|
+
let files;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
files = await readdir(outboxDir);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error.code === "ENOENT") {
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return Promise.all(
|
|
184
|
+
files
|
|
185
|
+
.filter((file) => file.endsWith(".json"))
|
|
186
|
+
.sort()
|
|
187
|
+
.map(async (file) => {
|
|
188
|
+
const path = join(outboxDir, file);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
path,
|
|
192
|
+
event: JSON.parse(await readFile(path, "utf8"))
|
|
193
|
+
};
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function retryWebhookEvents({
|
|
199
|
+
outboxDir = "./data/webhook-outbox",
|
|
200
|
+
botRegistryPath = "./.devnet/bots.json",
|
|
201
|
+
eventId = null,
|
|
202
|
+
onlyFailed = true,
|
|
203
|
+
force = false,
|
|
204
|
+
limit = 25,
|
|
205
|
+
fetchImpl = globalThis.fetch,
|
|
206
|
+
now = new Date()
|
|
207
|
+
} = {}) {
|
|
208
|
+
const entries = await listWebhookEvents({ outboxDir });
|
|
209
|
+
const hasMatchingEvent = !eventId || entries.some(({ event }) => event.eventId === eventId);
|
|
210
|
+
const retryable = entries
|
|
211
|
+
.filter(({ event }) => !eventId || event.eventId === eventId)
|
|
212
|
+
.filter(({ event }) => !onlyFailed || !event.delivery?.ok)
|
|
213
|
+
.filter(({ event }) => force || isWebhookRetryDue(event, now))
|
|
214
|
+
.slice(0, limit);
|
|
215
|
+
const results = [];
|
|
216
|
+
|
|
217
|
+
for (const entry of retryable) {
|
|
218
|
+
const bot = await findWebhookBot({
|
|
219
|
+
botRegistryPath,
|
|
220
|
+
botId: entry.event.authenticatedBotId ?? entry.event.botId
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
if (!bot?.webhookUrl) {
|
|
224
|
+
results.push({
|
|
225
|
+
path: entry.path,
|
|
226
|
+
event: entry.event,
|
|
227
|
+
retried: false,
|
|
228
|
+
error: "No active bot webhook URL is configured."
|
|
229
|
+
});
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const event = {
|
|
234
|
+
...entry.event,
|
|
235
|
+
webhookUrl: bot.webhookUrl,
|
|
236
|
+
delivery: {
|
|
237
|
+
...(entry.event.delivery ?? {}),
|
|
238
|
+
url: bot.webhookUrl
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const delivered = await deliverWebhookEvent({
|
|
242
|
+
path: entry.path,
|
|
243
|
+
event,
|
|
244
|
+
webhookSecret: bot.webhookSecret,
|
|
245
|
+
fetchImpl,
|
|
246
|
+
now
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
results.push({
|
|
250
|
+
path: entry.path,
|
|
251
|
+
event: delivered,
|
|
252
|
+
retried: true,
|
|
253
|
+
error: null
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (eventId && !hasMatchingEvent) {
|
|
258
|
+
throw new Error(`Webhook event ${eventId} was not found or is not retryable.`);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
version: "svs.webhook-retry-result.v1",
|
|
263
|
+
retriedAt: now.toISOString(),
|
|
264
|
+
eventId,
|
|
265
|
+
onlyFailed,
|
|
266
|
+
force,
|
|
267
|
+
count: results.length,
|
|
268
|
+
delivered: results.filter((result) => result.event.delivery?.ok).length,
|
|
269
|
+
failed: results.filter((result) => result.retried && !result.event.delivery?.ok).length,
|
|
270
|
+
skipped: results.filter((result) => !result.retried).length,
|
|
271
|
+
results
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function createWebhookEvent({
|
|
276
|
+
eventType,
|
|
277
|
+
record,
|
|
278
|
+
previousStatus,
|
|
279
|
+
bot,
|
|
280
|
+
eventData,
|
|
281
|
+
createdAt
|
|
282
|
+
}) {
|
|
283
|
+
const payload = {
|
|
284
|
+
version: WEBHOOK_EVENT_VERSION,
|
|
285
|
+
eventId: randomUUID(),
|
|
286
|
+
eventType,
|
|
287
|
+
createdAt,
|
|
288
|
+
botId: record.intent?.botId ?? null,
|
|
289
|
+
authenticatedBotId: record.source?.authenticatedBotId ?? null,
|
|
290
|
+
recordId: record.id,
|
|
291
|
+
previousStatus,
|
|
292
|
+
status: record.status,
|
|
293
|
+
authorizationType: record.authorizationType,
|
|
294
|
+
controllerWallet: record.intent?.controllerWallet ?? null,
|
|
295
|
+
policyId: record.policySummary?.id ?? record.receipt?.policyId ?? null,
|
|
296
|
+
receiptHash: record.receipt?.receiptHash ?? null,
|
|
297
|
+
receiptId: record.receipt?.receiptId ?? record.id,
|
|
298
|
+
broadcastSignature: record.broadcast?.signature ?? null,
|
|
299
|
+
anchorSignature: record.anchor?.signature ?? null,
|
|
300
|
+
feePaymentSignature: record.feePayment?.signature ?? null,
|
|
301
|
+
idempotencyKey: record.source?.idempotencyKey ?? null,
|
|
302
|
+
actionRequestHash: record.source?.actionRequestHash ?? null,
|
|
303
|
+
links: {
|
|
304
|
+
action: `/api/actions/${encodeURIComponent(record.id)}`,
|
|
305
|
+
receipt: `/api/actions/${encodeURIComponent(record.id)}/receipt`,
|
|
306
|
+
report: `/api/actions/${encodeURIComponent(record.id)}/report`
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
if (eventData !== null && eventData !== undefined) {
|
|
311
|
+
payload.data = eventData;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
...payload,
|
|
316
|
+
webhookUrl: bot.webhookUrl,
|
|
317
|
+
payloadHashAlgorithm: "sha256",
|
|
318
|
+
payloadHash: hashObject(payload)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function deliverWebhookEvent({
|
|
323
|
+
path,
|
|
324
|
+
event,
|
|
325
|
+
webhookSecret,
|
|
326
|
+
fetchImpl,
|
|
327
|
+
now = new Date()
|
|
328
|
+
}) {
|
|
329
|
+
const attemptedAt = now.toISOString();
|
|
330
|
+
|
|
331
|
+
if (!fetchImpl) {
|
|
332
|
+
const failed = withDeliveryAttempt(event, {
|
|
333
|
+
attemptedAt,
|
|
334
|
+
error: "fetch is not available."
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await writeWebhookEvent(path, failed);
|
|
338
|
+
return failed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const body = JSON.stringify(event);
|
|
343
|
+
const signatureHeaders = webhookSecret
|
|
344
|
+
? createWebhookSignatureHeaders({ body, webhookSecret, timestamp: attemptedAt })
|
|
345
|
+
: {};
|
|
346
|
+
const response = await fetchImpl(event.webhookUrl, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
headers: {
|
|
349
|
+
"content-type": "application/json",
|
|
350
|
+
"svs-event-type": event.eventType,
|
|
351
|
+
"svs-event-id": event.eventId,
|
|
352
|
+
"svs-payload-hash": event.payloadHash,
|
|
353
|
+
...signatureHeaders
|
|
354
|
+
},
|
|
355
|
+
body
|
|
356
|
+
});
|
|
357
|
+
const delivered = withDeliveryAttempt(event, {
|
|
358
|
+
attemptedAt,
|
|
359
|
+
deliveredAt: response.ok ? now.toISOString() : null,
|
|
360
|
+
statusCode: response.status,
|
|
361
|
+
ok: Boolean(response.ok),
|
|
362
|
+
error: response.ok ? null : `HTTP ${response.status}`,
|
|
363
|
+
signature: signatureHeaders["svs-webhook-signature"] ?? null,
|
|
364
|
+
signatureTimestamp: signatureHeaders["svs-webhook-timestamp"] ?? null
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
await writeWebhookEvent(path, delivered);
|
|
368
|
+
return delivered;
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const failed = withDeliveryAttempt(event, {
|
|
371
|
+
attemptedAt,
|
|
372
|
+
error: error.message
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await writeWebhookEvent(path, failed);
|
|
376
|
+
return failed;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function createWebhookSignatureHeaders({
|
|
381
|
+
body,
|
|
382
|
+
webhookSecret,
|
|
383
|
+
timestamp = new Date().toISOString()
|
|
384
|
+
}) {
|
|
385
|
+
if (!webhookSecret) {
|
|
386
|
+
return {};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
"svs-webhook-timestamp": timestamp,
|
|
391
|
+
"svs-webhook-signature": createWebhookSignature({ body, webhookSecret, timestamp })
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function createWebhookSignature({ body, webhookSecret, timestamp }) {
|
|
396
|
+
if (!webhookSecret) {
|
|
397
|
+
throw new Error("webhookSecret is required.");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (!timestamp) {
|
|
401
|
+
throw new Error("timestamp is required.");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const digest = createHmac("sha256", webhookSecret)
|
|
405
|
+
.update(`${timestamp}.${body}`)
|
|
406
|
+
.digest("hex");
|
|
407
|
+
|
|
408
|
+
return `${WEBHOOK_SIGNATURE_VERSION}=${digest}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function verifyWebhookSignature({
|
|
412
|
+
body,
|
|
413
|
+
webhookSecret,
|
|
414
|
+
timestamp,
|
|
415
|
+
signature,
|
|
416
|
+
toleranceMs = 5 * 60 * 1000,
|
|
417
|
+
now = new Date()
|
|
418
|
+
}) {
|
|
419
|
+
if (!webhookSecret || !timestamp || !signature) {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
reason: "missing_signature_fields"
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const signedAt = new Date(timestamp);
|
|
427
|
+
|
|
428
|
+
if (Number.isNaN(signedAt.getTime())) {
|
|
429
|
+
return {
|
|
430
|
+
ok: false,
|
|
431
|
+
reason: "invalid_timestamp"
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (toleranceMs !== null && Math.abs(now.getTime() - signedAt.getTime()) > toleranceMs) {
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
reason: "timestamp_outside_tolerance"
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const expected = createWebhookSignature({ body, webhookSecret, timestamp });
|
|
443
|
+
|
|
444
|
+
if (!constantTimeEqual(signature, expected)) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
reason: "signature_mismatch"
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
ok: true,
|
|
453
|
+
reason: "valid",
|
|
454
|
+
scheme: "hmac-sha256",
|
|
455
|
+
signatureVersion: WEBHOOK_SIGNATURE_VERSION
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function verifyWebhookRequest({
|
|
460
|
+
body,
|
|
461
|
+
headers = {},
|
|
462
|
+
webhookSecret,
|
|
463
|
+
seenEventIds = null,
|
|
464
|
+
toleranceMs = 5 * 60 * 1000,
|
|
465
|
+
now = new Date()
|
|
466
|
+
} = {}) {
|
|
467
|
+
const rawBody = normalizeWebhookBody(body);
|
|
468
|
+
const eventType = getHeader(headers, "svs-event-type");
|
|
469
|
+
const eventId = getHeader(headers, "svs-event-id");
|
|
470
|
+
const payloadHash = getHeader(headers, "svs-payload-hash");
|
|
471
|
+
const timestamp = getHeader(headers, "svs-webhook-timestamp");
|
|
472
|
+
const signature = getHeader(headers, "svs-webhook-signature");
|
|
473
|
+
|
|
474
|
+
let event;
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
event = JSON.parse(rawBody);
|
|
478
|
+
} catch (_error) {
|
|
479
|
+
return webhookRequestResult(false, "invalid_json", null);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (event.version !== WEBHOOK_EVENT_VERSION) {
|
|
483
|
+
return webhookRequestResult(false, "unsupported_event_version", event);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (eventType && eventType !== event.eventType) {
|
|
487
|
+
return webhookRequestResult(false, "event_type_header_mismatch", event);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (eventId && eventId !== event.eventId) {
|
|
491
|
+
return webhookRequestResult(false, "event_id_header_mismatch", event);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (payloadHash && payloadHash !== event.payloadHash) {
|
|
495
|
+
return webhookRequestResult(false, "payload_hash_header_mismatch", event);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const expectedPayloadHash = hashObject(createWebhookPayloadFromEvent(event));
|
|
499
|
+
|
|
500
|
+
if (event.payloadHash !== expectedPayloadHash) {
|
|
501
|
+
return webhookRequestResult(false, "payload_hash_mismatch", event, { expectedPayloadHash });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const signatureResult = verifyWebhookSignature({
|
|
505
|
+
body: rawBody,
|
|
506
|
+
webhookSecret,
|
|
507
|
+
timestamp,
|
|
508
|
+
signature,
|
|
509
|
+
toleranceMs,
|
|
510
|
+
now
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
if (!signatureResult.ok) {
|
|
514
|
+
return webhookRequestResult(false, signatureResult.reason, event, { signature: signatureResult });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (seenEventIds?.has?.(event.eventId)) {
|
|
518
|
+
return webhookRequestResult(false, "replayed_event", event, { signature: signatureResult });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
seenEventIds?.add?.(event.eventId);
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
reason: "valid",
|
|
526
|
+
event,
|
|
527
|
+
checks: {
|
|
528
|
+
eventVersion: WEBHOOK_EVENT_VERSION,
|
|
529
|
+
payloadHash: event.payloadHash,
|
|
530
|
+
signature: signatureResult
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export async function verifyWebhookRequestWithReplayStore({
|
|
536
|
+
body,
|
|
537
|
+
headers = {},
|
|
538
|
+
webhookSecret,
|
|
539
|
+
replayStorePath = "./data/webhook-receiver-replays.json",
|
|
540
|
+
replayTtlMs = DEFAULT_WEBHOOK_REPLAY_TTL_MS,
|
|
541
|
+
maxReplayEvents = 10000,
|
|
542
|
+
toleranceMs = 5 * 60 * 1000,
|
|
543
|
+
now = new Date()
|
|
544
|
+
} = {}) {
|
|
545
|
+
const store = await loadWebhookReplayStore({
|
|
546
|
+
path: replayStorePath,
|
|
547
|
+
maxAgeMs: replayTtlMs,
|
|
548
|
+
maxEntries: maxReplayEvents,
|
|
549
|
+
now
|
|
550
|
+
});
|
|
551
|
+
const replayGuard = createWebhookReplayGuard(store, now);
|
|
552
|
+
const result = verifyWebhookRequest({
|
|
553
|
+
body,
|
|
554
|
+
headers,
|
|
555
|
+
webhookSecret,
|
|
556
|
+
seenEventIds: replayGuard,
|
|
557
|
+
toleranceMs,
|
|
558
|
+
now
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
if (result.ok || store.pruned) {
|
|
562
|
+
await writeWebhookReplayStore(replayStorePath, store);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
...result,
|
|
567
|
+
replayStore: result.ok
|
|
568
|
+
? {
|
|
569
|
+
path: replayStorePath,
|
|
570
|
+
eventCount: store.acceptedEvents.length
|
|
571
|
+
}
|
|
572
|
+
: undefined
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function withDeliveryAttempt(event, delivery) {
|
|
577
|
+
const priorAttempts = event.deliveryAttempts ?? [];
|
|
578
|
+
const attemptNumber = priorAttempts.length + 1;
|
|
579
|
+
const ok = Boolean(delivery.ok);
|
|
580
|
+
const nextAttemptAt = ok
|
|
581
|
+
? null
|
|
582
|
+
: calculateNextWebhookAttemptAt({
|
|
583
|
+
attemptedAt: delivery.attemptedAt,
|
|
584
|
+
failedAttemptCount: priorAttempts.filter((attempt) => !attempt.ok).length + 1
|
|
585
|
+
});
|
|
586
|
+
const attempt = {
|
|
587
|
+
attempt: attemptNumber,
|
|
588
|
+
mode: "http",
|
|
589
|
+
url: event.webhookUrl ?? event.delivery?.url ?? null,
|
|
590
|
+
attemptedAt: delivery.attemptedAt,
|
|
591
|
+
deliveredAt: delivery.deliveredAt ?? null,
|
|
592
|
+
statusCode: delivery.statusCode ?? null,
|
|
593
|
+
ok,
|
|
594
|
+
error: delivery.error ?? null,
|
|
595
|
+
signature: delivery.signature ?? null,
|
|
596
|
+
signatureTimestamp: delivery.signatureTimestamp ?? null,
|
|
597
|
+
nextAttemptAt
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
...event,
|
|
602
|
+
deliveryAttempts: [
|
|
603
|
+
...priorAttempts,
|
|
604
|
+
attempt
|
|
605
|
+
],
|
|
606
|
+
delivery: {
|
|
607
|
+
...event.delivery,
|
|
608
|
+
...attempt
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function isWebhookRetryDue(event, now) {
|
|
614
|
+
if (event.delivery?.ok) return false;
|
|
615
|
+
if (!event.delivery?.nextAttemptAt) return true;
|
|
616
|
+
|
|
617
|
+
return new Date(event.delivery.nextAttemptAt).getTime() <= now.getTime();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function calculateNextWebhookAttemptAt({
|
|
621
|
+
attemptedAt,
|
|
622
|
+
failedAttemptCount
|
|
623
|
+
}) {
|
|
624
|
+
const delay = DEFAULT_WEBHOOK_RETRY_DELAYS_MS[
|
|
625
|
+
Math.min(failedAttemptCount - 1, DEFAULT_WEBHOOK_RETRY_DELAYS_MS.length - 1)
|
|
626
|
+
];
|
|
627
|
+
|
|
628
|
+
return new Date(new Date(attemptedAt).getTime() + delay).toISOString();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function loadWebhookReplayStore({
|
|
632
|
+
path,
|
|
633
|
+
maxAgeMs,
|
|
634
|
+
maxEntries,
|
|
635
|
+
now
|
|
636
|
+
}) {
|
|
637
|
+
let store;
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
store = normalizeWebhookReplayStore(JSON.parse(await readFile(path, "utf8")));
|
|
641
|
+
} catch (error) {
|
|
642
|
+
if (error.code !== "ENOENT") {
|
|
643
|
+
throw error;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
store = createEmptyWebhookReplayStore();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return pruneWebhookReplayStore(store, { maxAgeMs, maxEntries, now });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function createEmptyWebhookReplayStore() {
|
|
653
|
+
return {
|
|
654
|
+
version: WEBHOOK_REPLAY_STORE_VERSION,
|
|
655
|
+
updatedAt: null,
|
|
656
|
+
acceptedEvents: []
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function normalizeWebhookReplayStore(store) {
|
|
661
|
+
if (store?.version !== WEBHOOK_REPLAY_STORE_VERSION) {
|
|
662
|
+
return createEmptyWebhookReplayStore();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return {
|
|
666
|
+
version: WEBHOOK_REPLAY_STORE_VERSION,
|
|
667
|
+
updatedAt: store.updatedAt ?? null,
|
|
668
|
+
acceptedEvents: Array.isArray(store.acceptedEvents)
|
|
669
|
+
? store.acceptedEvents
|
|
670
|
+
.filter((event) => event?.eventId)
|
|
671
|
+
.map((event) => ({
|
|
672
|
+
eventId: String(event.eventId),
|
|
673
|
+
acceptedAt: event.acceptedAt ?? null
|
|
674
|
+
}))
|
|
675
|
+
: []
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function pruneWebhookReplayStore(store, { maxAgeMs, maxEntries, now }) {
|
|
680
|
+
const cutoff = maxAgeMs === null ? null : now.getTime() - maxAgeMs;
|
|
681
|
+
const originalCount = store.acceptedEvents.length;
|
|
682
|
+
const acceptedEvents = store.acceptedEvents
|
|
683
|
+
.filter((event) => {
|
|
684
|
+
if (cutoff === null) return true;
|
|
685
|
+
const acceptedAt = new Date(event.acceptedAt);
|
|
686
|
+
|
|
687
|
+
return !Number.isNaN(acceptedAt.getTime()) && acceptedAt.getTime() >= cutoff;
|
|
688
|
+
})
|
|
689
|
+
.slice(-maxEntries);
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
...store,
|
|
693
|
+
acceptedEvents,
|
|
694
|
+
pruned: acceptedEvents.length !== originalCount
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function createWebhookReplayGuard(store, now) {
|
|
699
|
+
const seen = new Set(store.acceptedEvents.map((event) => event.eventId));
|
|
700
|
+
|
|
701
|
+
return {
|
|
702
|
+
has(eventId) {
|
|
703
|
+
return seen.has(eventId);
|
|
704
|
+
},
|
|
705
|
+
add(eventId) {
|
|
706
|
+
if (seen.has(eventId)) return;
|
|
707
|
+
|
|
708
|
+
seen.add(eventId);
|
|
709
|
+
store.acceptedEvents.push({
|
|
710
|
+
eventId,
|
|
711
|
+
acceptedAt: now.toISOString()
|
|
712
|
+
});
|
|
713
|
+
store.updatedAt = now.toISOString();
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function writeWebhookReplayStore(path, store) {
|
|
719
|
+
const persisted = {
|
|
720
|
+
version: WEBHOOK_REPLAY_STORE_VERSION,
|
|
721
|
+
updatedAt: store.updatedAt ?? null,
|
|
722
|
+
acceptedEvents: store.acceptedEvents
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
await mkdir(dirname(path), { recursive: true });
|
|
726
|
+
await writeFile(path, `${JSON.stringify(persisted, null, 2)}\n`, { mode: 0o600 });
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function createWebhookPayloadFromEvent(event) {
|
|
730
|
+
const payload = {
|
|
731
|
+
version: event.version,
|
|
732
|
+
eventId: event.eventId,
|
|
733
|
+
eventType: event.eventType,
|
|
734
|
+
createdAt: event.createdAt,
|
|
735
|
+
botId: event.botId,
|
|
736
|
+
authenticatedBotId: event.authenticatedBotId,
|
|
737
|
+
recordId: event.recordId,
|
|
738
|
+
previousStatus: event.previousStatus,
|
|
739
|
+
status: event.status,
|
|
740
|
+
authorizationType: event.authorizationType,
|
|
741
|
+
controllerWallet: event.controllerWallet,
|
|
742
|
+
policyId: event.policyId,
|
|
743
|
+
receiptHash: event.receiptHash,
|
|
744
|
+
receiptId: event.receiptId,
|
|
745
|
+
broadcastSignature: event.broadcastSignature,
|
|
746
|
+
anchorSignature: event.anchorSignature,
|
|
747
|
+
feePaymentSignature: event.feePaymentSignature,
|
|
748
|
+
idempotencyKey: event.idempotencyKey,
|
|
749
|
+
actionRequestHash: event.actionRequestHash,
|
|
750
|
+
links: event.links
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
if (event.data !== undefined) {
|
|
754
|
+
payload.data = event.data;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return payload;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function getHeader(headers, name) {
|
|
761
|
+
if (headers?.get) {
|
|
762
|
+
return headers.get(name);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const lowerName = name.toLowerCase();
|
|
766
|
+
|
|
767
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
768
|
+
if (key.toLowerCase() === lowerName) {
|
|
769
|
+
return Array.isArray(value) ? value[0] : value;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function normalizeWebhookBody(body) {
|
|
777
|
+
if (Buffer.isBuffer(body)) {
|
|
778
|
+
return body.toString("utf8");
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (body instanceof Uint8Array) {
|
|
782
|
+
return Buffer.from(body).toString("utf8");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (typeof body === "string") {
|
|
786
|
+
return body;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return JSON.stringify(body ?? {});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function webhookRequestResult(ok, reason, event, checks = {}) {
|
|
793
|
+
return {
|
|
794
|
+
ok,
|
|
795
|
+
reason,
|
|
796
|
+
event,
|
|
797
|
+
checks
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function constantTimeEqual(left, right) {
|
|
802
|
+
const leftBuffer = Buffer.from(String(left));
|
|
803
|
+
const rightBuffer = Buffer.from(String(right));
|
|
804
|
+
|
|
805
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async function findWebhookBot({ botRegistryPath, botId }) {
|
|
809
|
+
if (!botId) {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
try {
|
|
814
|
+
const registry = JSON.parse(await readFile(botRegistryPath, "utf8"));
|
|
815
|
+
const bot = (registry.bots ?? []).find((candidate) => candidate.id === botId);
|
|
816
|
+
|
|
817
|
+
if (!bot || bot.status === "disabled") {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return bot;
|
|
822
|
+
} catch (error) {
|
|
823
|
+
if (error.code === "ENOENT") {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
throw error;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function writeWebhookEvent(path, event) {
|
|
832
|
+
await mkdir(dirname(path), { recursive: true });
|
|
833
|
+
await writeFile(path, `${JSON.stringify(event, null, 2)}\n`);
|
|
834
|
+
}
|