@rodit/rodit-auth-be 9.11.14
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/CHANGELOG.md +54 -0
- package/README.md +3543 -0
- package/index.js +1884 -0
- package/lib/auth/authentication.js +1971 -0
- package/lib/auth/roditmanager.js +627 -0
- package/lib/auth/sessionmanager.js +1302 -0
- package/lib/auth/tokenservice.js +2418 -0
- package/lib/blockchain/blockchainservice.js +1715 -0
- package/lib/blockchain/statemanager.js +1614 -0
- package/lib/middleware/authenticationmw.js +2301 -0
- package/lib/middleware/environcredentialstoremw.js +176 -0
- package/lib/middleware/filecredentialstoremw.js +158 -0
- package/lib/middleware/loggingmw.js +82 -0
- package/lib/middleware/performanceexamplemw.js +58 -0
- package/lib/middleware/performancemw.js +172 -0
- package/lib/middleware/ratelimitmw.js +171 -0
- package/lib/middleware/validatepermissionsmw.js +439 -0
- package/lib/middleware/vaultcredentialstoremw.js +617 -0
- package/lib/middleware/versioningmw.js +142 -0
- package/lib/middleware/webhookhandlermw.js +1388 -0
- package/package.json +57 -0
- package/services/configsdk.js +588 -0
- package/services/env.js +34 -0
- package/services/error-response.js +29 -0
- package/services/logger.js +160 -0
- package/services/performanceservice.js +568 -0
- package/services/utils.js +1024 -0
- package/services/versionmanager.js +81 -0
|
@@ -0,0 +1,1388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook event handling
|
|
3
|
+
* Copyright (c) 2026 Discernible IO. All rights reserved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// webhookhandlermw.js
|
|
7
|
+
// Reusable webhook handler for RODiT SDK
|
|
8
|
+
|
|
9
|
+
const crypto = require("crypto");
|
|
10
|
+
const https = require("https");
|
|
11
|
+
const { Agent } = require("undici");
|
|
12
|
+
const config = require('../../services/configsdk');
|
|
13
|
+
const { isStrictEnvironment, getNodeEnv } = require('../../services/env');
|
|
14
|
+
const logger = require("../../services/logger");
|
|
15
|
+
const { createLogContext, logErrorWithMetrics } = logger;
|
|
16
|
+
const { ulid } = require("ulid");
|
|
17
|
+
const { sendError } = require("../../services/error-response");
|
|
18
|
+
const nacl = require("tweetnacl");
|
|
19
|
+
const stateManager = require("../blockchain/statemanager");
|
|
20
|
+
const { authenticate_webhook } = require("../auth/authentication");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create a raw body parser middleware specifically for webhook endpoints
|
|
24
|
+
* This preserves the raw body for signature verification
|
|
25
|
+
* @returns {Function} Express middleware
|
|
26
|
+
*/
|
|
27
|
+
function createRawBodyParser() {
|
|
28
|
+
return (req, res, next) => {
|
|
29
|
+
if (req.headers['content-type'] !== 'application/json') {
|
|
30
|
+
const requestId = req.requestId || req.headers['x-request-id'] || ulid();
|
|
31
|
+
return sendError(res, {
|
|
32
|
+
statusCode: 415,
|
|
33
|
+
requestId,
|
|
34
|
+
code: 'UNSUPPORTED_MEDIA_TYPE',
|
|
35
|
+
message: 'Only application/json is supported'
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let data = '';
|
|
40
|
+
req.setEncoding('utf8');
|
|
41
|
+
|
|
42
|
+
req.on('data', (chunk) => {
|
|
43
|
+
data += chunk;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
req.on('end', () => {
|
|
47
|
+
// Store the raw body for signature verification
|
|
48
|
+
req.rawBody = data;
|
|
49
|
+
|
|
50
|
+
// Parse JSON for convenience
|
|
51
|
+
try {
|
|
52
|
+
req.body = JSON.parse(data);
|
|
53
|
+
next();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
const requestId = req.requestId || req.headers['x-request-id'] || ulid();
|
|
56
|
+
return sendError(res, {
|
|
57
|
+
statusCode: 400,
|
|
58
|
+
requestId,
|
|
59
|
+
code: 'INVALID_JSON_PAYLOAD',
|
|
60
|
+
message: 'Invalid JSON payload'
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create middleware for webhook request processing
|
|
69
|
+
* Webhooks use digital signature authentication only - no API tokens needed
|
|
70
|
+
* @returns {Function} Express middleware
|
|
71
|
+
*/
|
|
72
|
+
function createWebhookProcessingMiddleware() {
|
|
73
|
+
return (req, res, next) => {
|
|
74
|
+
// Mark this as a webhook request for logging purposes
|
|
75
|
+
req.isWebhookRequest = true;
|
|
76
|
+
next();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create middleware to attach the server's public key to the request
|
|
82
|
+
* @param {Object} stateManager - State manager instance
|
|
83
|
+
* @returns {Function} Express middleware
|
|
84
|
+
*/
|
|
85
|
+
function createPublicKeyMiddleware(stateManager) {
|
|
86
|
+
return async (req, res, next) => {
|
|
87
|
+
const requestId = crypto.randomUUID();
|
|
88
|
+
const logContext = {
|
|
89
|
+
requestId,
|
|
90
|
+
apiEndpoint: req.path,
|
|
91
|
+
method: req.method,
|
|
92
|
+
headers: Object.keys(req.headers),
|
|
93
|
+
hasSignature: !!req.headers["x-signature"],
|
|
94
|
+
hasTimestamp: !!req.headers["x-timestamp"],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
// Check if this is a test environment where we should bypass signature verification
|
|
99
|
+
// Get the peer public key from the state manager
|
|
100
|
+
const peerBase64urlJwkPublicKey = stateManager.getPeerBase64urlJwkPublicKey();
|
|
101
|
+
|
|
102
|
+
// If the peer public key is not available and we're not in test mode, return an error
|
|
103
|
+
if (!peerBase64urlJwkPublicKey) {
|
|
104
|
+
logger.warnWithContext("Peer public key not available in state manager", logContext);
|
|
105
|
+
|
|
106
|
+
// On main, we need the key
|
|
107
|
+
if (isStrictEnvironment()) {
|
|
108
|
+
logger.errorWithContext("Peer public key not available in main environment", logContext);
|
|
109
|
+
return sendError(res, {
|
|
110
|
+
statusCode: 500,
|
|
111
|
+
requestId,
|
|
112
|
+
code: "PEER_KEY_UNAVAILABLE",
|
|
113
|
+
message: "Peer public key not available"
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// In development or test, we'll continue without the key and skip verification
|
|
118
|
+
logger.infoWithContext("Continuing without peer public key in non-main environment", {
|
|
119
|
+
...logContext,
|
|
120
|
+
environment: getNodeEnv()
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (peerBase64urlJwkPublicKey) {
|
|
125
|
+
// Log that we're using the peer public key
|
|
126
|
+
logger.infoWithContext("Using peer public key from state manager", {
|
|
127
|
+
...logContext,
|
|
128
|
+
keyFormat: "JWK",
|
|
129
|
+
keyFound: true
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
logger.debugWithContext("Processing peer public key", {
|
|
134
|
+
...logContext,
|
|
135
|
+
keyLength: peerBase64urlJwkPublicKey ? peerBase64urlJwkPublicKey.length : 0,
|
|
136
|
+
keyFormat: "base64url_encoded_hex"
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// The key is already in base64url format and should be decoded directly to bytes
|
|
140
|
+
req.peer_bytes_ed25519_public_key = new Uint8Array(
|
|
141
|
+
Buffer.from(peerBase64urlJwkPublicKey, "base64url")
|
|
142
|
+
);
|
|
143
|
+
req.server_bytes_ed25519_public_key = req.peer_bytes_ed25519_public_key;
|
|
144
|
+
req.server_public_key_base64url = peerBase64urlJwkPublicKey;
|
|
145
|
+
|
|
146
|
+
logger.debugWithContext("Processed peer public key", {
|
|
147
|
+
...logContext,
|
|
148
|
+
keyLength: req.peer_bytes_ed25519_public_key.length,
|
|
149
|
+
keyFormat: "base64url_decoded_to_bytes"
|
|
150
|
+
});
|
|
151
|
+
} catch (jwkError) {
|
|
152
|
+
logger.errorWithContext("Error converting JWK peer public key", {
|
|
153
|
+
...logContext,
|
|
154
|
+
error: jwkError.message
|
|
155
|
+
});
|
|
156
|
+
return sendError(res, {
|
|
157
|
+
statusCode: 500,
|
|
158
|
+
requestId,
|
|
159
|
+
code: "PEER_KEY_PROCESSING_ERROR",
|
|
160
|
+
message: "Error processing peer public key",
|
|
161
|
+
details: { cause: jwkError.message }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
next();
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger.errorWithContext("Error extracting server public key", {
|
|
169
|
+
...logContext,
|
|
170
|
+
error: error.message,
|
|
171
|
+
stack: error.stack,
|
|
172
|
+
});
|
|
173
|
+
return sendError(res, {
|
|
174
|
+
statusCode: 500,
|
|
175
|
+
requestId,
|
|
176
|
+
code: "SERVER_CONFIG_ERROR",
|
|
177
|
+
message: "Server configuration error"
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create middleware to authenticate webhook requests
|
|
185
|
+
* @returns {Function} Express middleware
|
|
186
|
+
*/
|
|
187
|
+
function createWebhookAuthenticationMiddleware() {
|
|
188
|
+
return async (req, res, next) => {
|
|
189
|
+
const requestId = crypto.randomUUID();
|
|
190
|
+
const logContext = {
|
|
191
|
+
requestId,
|
|
192
|
+
apiEndpoint: req.path,
|
|
193
|
+
method: req.method,
|
|
194
|
+
headers: Object.keys(req.headers),
|
|
195
|
+
bodyKeys: Object.keys(req.jsonBody || req.body || {}),
|
|
196
|
+
bodySize: req.jsonBody ? JSON.stringify(req.jsonBody).length : 0,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const signature_hex_ofpayload = req.headers["x-signature"];
|
|
201
|
+
const timestamp = req.headers["x-timestamp"];
|
|
202
|
+
|
|
203
|
+
// Use the raw body that was captured by our middleware
|
|
204
|
+
const payload = req.rawBody;
|
|
205
|
+
|
|
206
|
+
if (!signature_hex_ofpayload || !timestamp || !payload) {
|
|
207
|
+
logger.debugWithContext("Missing required webhook authentication parameters", {
|
|
208
|
+
...logContext,
|
|
209
|
+
hasSignature: !!signature_hex_ofpayload,
|
|
210
|
+
hasTimestamp: !!timestamp,
|
|
211
|
+
hasPayload: !!payload
|
|
212
|
+
});
|
|
213
|
+
return sendError(res, {
|
|
214
|
+
statusCode: 400,
|
|
215
|
+
requestId,
|
|
216
|
+
code: 'MISSING_AUTH_PARAMS',
|
|
217
|
+
message: "Missing required authentication parameters"
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Log the payload hash and signature for debugging
|
|
222
|
+
const payloadHash = crypto
|
|
223
|
+
.createHash("sha256")
|
|
224
|
+
.update(payload)
|
|
225
|
+
.digest("hex");
|
|
226
|
+
|
|
227
|
+
logger.debugWithContext("Webhook payload hash and signature", {
|
|
228
|
+
...logContext,
|
|
229
|
+
payloadHash: payloadHash,
|
|
230
|
+
payloadWithTimestamp: payload + (timestamp || ''),
|
|
231
|
+
payloadWithTimestampHash: crypto
|
|
232
|
+
.createHash("sha256")
|
|
233
|
+
.update(payload + (timestamp || ''))
|
|
234
|
+
.digest("hex"),
|
|
235
|
+
signature: signature_hex_ofpayload,
|
|
236
|
+
timestamp: timestamp
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Update log context with body info
|
|
240
|
+
if (Array.isArray(req.body)) {
|
|
241
|
+
logContext.bodyIsArray = true;
|
|
242
|
+
logContext.bodyLength = req.body.length;
|
|
243
|
+
} else {
|
|
244
|
+
logContext.bodyIsArray = false;
|
|
245
|
+
logContext.bodyKeys = Object.keys(req.body || {});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
logContext.bodySize = payload.length;
|
|
249
|
+
logContext.hasSignature = !!signature_hex_ofpayload;
|
|
250
|
+
logContext.hasTimestamp = !!timestamp;
|
|
251
|
+
|
|
252
|
+
// Check if we have the server's public key
|
|
253
|
+
if (!req.server_public_key_base64url) {
|
|
254
|
+
// In test environments or with bypass flag, we might want to bypass verification
|
|
255
|
+
const isTestEnv = String(config.get('NODE_ENV', 'development')).toLowerCase() === 'test';
|
|
256
|
+
const bypassWebhookVerification = config.get('SECURITY_OPTIONS.BYPASS_WEBHOOK_VERIFICATION', false) === true;
|
|
257
|
+
if (isTestEnv || bypassWebhookVerification) {
|
|
258
|
+
logger.warnWithContext("Bypassing webhook authentication in test environment", logContext);
|
|
259
|
+
return next();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return sendError(res, {
|
|
263
|
+
statusCode: 500,
|
|
264
|
+
requestId,
|
|
265
|
+
code: "SERVER_CONFIG_ERROR",
|
|
266
|
+
message: "Server configuration error"
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Authenticate the webhook using the server's public key
|
|
271
|
+
logger.debugWithContext("Authenticating webhook signature", logContext);
|
|
272
|
+
const publicKeyBase64url = req.server_public_key_base64url;
|
|
273
|
+
|
|
274
|
+
// Call the authentication function with proper error handling
|
|
275
|
+
let authResult;
|
|
276
|
+
try {
|
|
277
|
+
authResult = await authenticate_webhook(
|
|
278
|
+
payload,
|
|
279
|
+
signature_hex_ofpayload,
|
|
280
|
+
timestamp,
|
|
281
|
+
publicKeyBase64url
|
|
282
|
+
);
|
|
283
|
+
} catch (authError) {
|
|
284
|
+
return sendError(res, {
|
|
285
|
+
statusCode: 500,
|
|
286
|
+
requestId,
|
|
287
|
+
code: "WEBHOOK_AUTH_ERROR",
|
|
288
|
+
message: "Webhook authentication error",
|
|
289
|
+
details: { cause: authError.message }
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!authResult.isValid) {
|
|
294
|
+
logger.warnWithContext("Invalid webhook signature", {
|
|
295
|
+
...logContext,
|
|
296
|
+
result: 'failure',
|
|
297
|
+
reason: 'Invalid webhook signature',
|
|
298
|
+
error: authResult.error?.message,
|
|
299
|
+
code: authResult.error?.code || 'UNKNOWN_ERROR'
|
|
300
|
+
});
|
|
301
|
+
return sendError(res, {
|
|
302
|
+
statusCode: 401,
|
|
303
|
+
requestId,
|
|
304
|
+
code: authResult.error?.code || 'WEBHOOK_SIGNATURE_INVALID',
|
|
305
|
+
message: authResult.error?.message || "Invalid webhook signature"
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
logger.infoWithContext("Webhook authenticated successfully", {
|
|
310
|
+
...logContext,
|
|
311
|
+
authDuration: authResult.duration,
|
|
312
|
+
component: "WebhookHandler"
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// Store authentication result for later use
|
|
316
|
+
req.webhookAuthResult = authResult;
|
|
317
|
+
|
|
318
|
+
next();
|
|
319
|
+
} catch (error) {
|
|
320
|
+
logger.errorWithContext("Error authenticating webhook", {
|
|
321
|
+
...logContext,
|
|
322
|
+
error: error.message,
|
|
323
|
+
stack: error.stack
|
|
324
|
+
});
|
|
325
|
+
return sendError(res, {
|
|
326
|
+
statusCode: 500,
|
|
327
|
+
requestId,
|
|
328
|
+
code: "WEBHOOK_AUTHENTICATION_ERROR",
|
|
329
|
+
message: "Webhook authentication error"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Process a webhook event and extract its data
|
|
337
|
+
* @param {Object} req - Express request object
|
|
338
|
+
* @param {Object} logContext - Logging context
|
|
339
|
+
* @returns {Object} Extracted event data
|
|
340
|
+
*/
|
|
341
|
+
function processWebhookEvent(req, logContext = {}) {
|
|
342
|
+
try {
|
|
343
|
+
// Check if the body is valid before attempting to destructure
|
|
344
|
+
if (!req.body || typeof req.body !== 'object') {
|
|
345
|
+
logger.errorWithContext("Invalid webhook payload format", {
|
|
346
|
+
...logContext,
|
|
347
|
+
component: "WebhookHandler",
|
|
348
|
+
bodyType: typeof req.body,
|
|
349
|
+
bodyIsNull: req.body === null,
|
|
350
|
+
contentType: req.headers['content-type']
|
|
351
|
+
});
|
|
352
|
+
return { error: "Invalid payload format" };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { event, data, isError, timestamp: payloadTimestamp, requestId: payloadRequestId } = req.body;
|
|
356
|
+
|
|
357
|
+
const eventType = typeof event === "string" ? event.trim() : "";
|
|
358
|
+
|
|
359
|
+
if (!eventType) {
|
|
360
|
+
logger.errorWithContext("Webhook payload missing event type", {
|
|
361
|
+
...logContext,
|
|
362
|
+
component: "WebhookHandler",
|
|
363
|
+
rawEventValue: event,
|
|
364
|
+
hasEventField: Object.prototype.hasOwnProperty.call(req.body, "event"),
|
|
365
|
+
});
|
|
366
|
+
return { error: "Event type is required but was not provided" };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
logger.infoWithContext("Processing webhook payload", {
|
|
370
|
+
...logContext,
|
|
371
|
+
component: "WebhookHandler",
|
|
372
|
+
event: eventType,
|
|
373
|
+
eventType,
|
|
374
|
+
isError,
|
|
375
|
+
payloadTimestamp,
|
|
376
|
+
payloadRequestId,
|
|
377
|
+
dataKeys: data ? Object.keys(data) : [],
|
|
378
|
+
dataType: typeof data,
|
|
379
|
+
dataSize: data ? JSON.stringify(data).length : 0
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
type: eventType,
|
|
384
|
+
name: eventType,
|
|
385
|
+
event: eventType,
|
|
386
|
+
data,
|
|
387
|
+
isError,
|
|
388
|
+
timestamp: payloadTimestamp,
|
|
389
|
+
requestId: payloadRequestId,
|
|
390
|
+
error: null
|
|
391
|
+
};
|
|
392
|
+
} catch (error) {
|
|
393
|
+
logger.debugWithContext("Error processing webhook payload", {
|
|
394
|
+
...logContext,
|
|
395
|
+
component: "WebhookHandler",
|
|
396
|
+
error: error.message,
|
|
397
|
+
stack: error.stack
|
|
398
|
+
});
|
|
399
|
+
return { error: error.message };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Create a complete webhook handler for Express
|
|
405
|
+
* @param {Object} stateManager - State manager instance
|
|
406
|
+
* @param {Object} configuration - Configuration configuration
|
|
407
|
+
* @returns {Object} Webhook handler with middleware and utilities
|
|
408
|
+
*/
|
|
409
|
+
function createWebhookHandler(stateManager, configuration = {}) {
|
|
410
|
+
const rawBodyParser = createRawBodyParser();
|
|
411
|
+
const webhookProcessingMiddleware = createWebhookProcessingMiddleware();
|
|
412
|
+
const publicKeyMiddleware = createPublicKeyMiddleware(stateManager);
|
|
413
|
+
const authenticationMiddleware = createWebhookAuthenticationMiddleware();
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
// Middleware
|
|
417
|
+
rawBodyParser,
|
|
418
|
+
webhookProcessingMiddleware,
|
|
419
|
+
publicKeyMiddleware,
|
|
420
|
+
authenticationMiddleware,
|
|
421
|
+
|
|
422
|
+
// Utility functions
|
|
423
|
+
processWebhookEvent,
|
|
424
|
+
|
|
425
|
+
// Combined middleware for easy setup
|
|
426
|
+
middleware: [
|
|
427
|
+
rawBodyParser,
|
|
428
|
+
webhookProcessingMiddleware,
|
|
429
|
+
publicKeyMiddleware,
|
|
430
|
+
authenticationMiddleware
|
|
431
|
+
],
|
|
432
|
+
|
|
433
|
+
// Helper to apply middleware based on route
|
|
434
|
+
applyMiddleware: (app, express, options = {}) => {
|
|
435
|
+
const endpoints = Array.isArray(options.endpoints) && options.endpoints.length > 0
|
|
436
|
+
? options.endpoints
|
|
437
|
+
: ['/webhook'];
|
|
438
|
+
const normalizedEndpoints = endpoints.map((endpoint) => {
|
|
439
|
+
const endpointString = String(endpoint || '/webhook');
|
|
440
|
+
return endpointString.startsWith('/') ? endpointString : `/${endpointString}`;
|
|
441
|
+
});
|
|
442
|
+
const endpointSet = new Set(normalizedEndpoints);
|
|
443
|
+
|
|
444
|
+
// Apply raw body parser only to configured webhook routes
|
|
445
|
+
app.use((req, res, next) => {
|
|
446
|
+
if (endpointSet.has(req.path)) {
|
|
447
|
+
rawBodyParser(req, res, next);
|
|
448
|
+
} else {
|
|
449
|
+
express.json()(req, res, next);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Apply webhook processing + key extraction middleware to all webhook routes
|
|
454
|
+
for (const endpoint of normalizedEndpoints) {
|
|
455
|
+
app.use(endpoint, webhookProcessingMiddleware);
|
|
456
|
+
app.use(endpoint, publicKeyMiddleware);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return app;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Send a webhook notification with comprehensive logging
|
|
466
|
+
*
|
|
467
|
+
* @param {Object} data - Webhook envelope. Expected shape: { event: string, data?: any, isError?: boolean }
|
|
468
|
+
* @param {Object} req - Express request object (optional)
|
|
469
|
+
* @param {Object} options - Options object (optional)
|
|
470
|
+
* @param {string} options.endpoint - Target endpoint path (e.g., '/webhook', '/hooks/wake', '/hooks/agent'). Defaults to '/webhook'
|
|
471
|
+
* @returns {Promise<Object>} Webhook delivery result with requestId
|
|
472
|
+
*/
|
|
473
|
+
async function send_webhook(data, req = null, options = {}) {
|
|
474
|
+
// Derive fields from envelope
|
|
475
|
+
const event = data && typeof data === 'object' ? (data.event || 'generic_event') : 'generic_event';
|
|
476
|
+
let isError = !!(data && data.isError);
|
|
477
|
+
|
|
478
|
+
// Always generate a new correlation ID
|
|
479
|
+
const requestId = ulid();
|
|
480
|
+
|
|
481
|
+
// Rebind data to the actual payload object (inner data if present, else entire envelope)
|
|
482
|
+
if (data && Object.prototype.hasOwnProperty.call(data, 'data')) {
|
|
483
|
+
data = data.data;
|
|
484
|
+
}
|
|
485
|
+
const startTime = Date.now();
|
|
486
|
+
|
|
487
|
+
// Create a context object for consistent logging
|
|
488
|
+
const webhookContext = {
|
|
489
|
+
event,
|
|
490
|
+
requestId,
|
|
491
|
+
isError,
|
|
492
|
+
dataType: typeof data,
|
|
493
|
+
operation: "webhook",
|
|
494
|
+
method: "send_webhook",
|
|
495
|
+
component: "WebhookHandler"
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Create base context for all logs in this function
|
|
499
|
+
const baseContext = createLogContext("RoditAuth", "send_webhook", {
|
|
500
|
+
requestId,
|
|
501
|
+
event,
|
|
502
|
+
isError,
|
|
503
|
+
dataSize: typeof data === "object" ? JSON.stringify(data).length : "unknown"
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// Log the webhook attempt
|
|
507
|
+
logger.debugWithContext("Starting webhook delivery", baseContext);
|
|
508
|
+
|
|
509
|
+
// Also log with the infoWithContext pattern used in cruda.js
|
|
510
|
+
logger.infoWithContext("Sending webhook", {
|
|
511
|
+
...webhookContext,
|
|
512
|
+
status: "attempt",
|
|
513
|
+
eventType: event
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
// Webhook URL must come from peer JWT token only
|
|
518
|
+
if (!req || !req.user || !req.user.rodit_webhookurl) {
|
|
519
|
+
const duration = Date.now() - startTime;
|
|
520
|
+
|
|
521
|
+
logger.warnWithContext("Peer JWT webhook URL missing", {
|
|
522
|
+
...baseContext,
|
|
523
|
+
duration,
|
|
524
|
+
hasReq: !!req,
|
|
525
|
+
hasReqUser: !!(req && req.user),
|
|
526
|
+
hasWebhookUrl: !!(req && req.user && req.user.rodit_webhookurl)
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Emit metrics for dashboards
|
|
530
|
+
logger.metric &&
|
|
531
|
+
logger.metric("webhook_delivery_duration_ms", duration, {
|
|
532
|
+
component: "WebhookHandler",
|
|
533
|
+
success: false,
|
|
534
|
+
event,
|
|
535
|
+
error: "WEBHOOK_URL_MISSING",
|
|
536
|
+
});
|
|
537
|
+
logger.metric &&
|
|
538
|
+
logger.metric("webhook_delivery_failures_total", 1, {
|
|
539
|
+
component: "WebhookHandler",
|
|
540
|
+
reason: "PEER_JWT_MISSING",
|
|
541
|
+
event,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// Log error with new logErrorWithMetrics helper
|
|
545
|
+
logErrorWithMetrics(
|
|
546
|
+
"Peer JWT webhook URL missing",
|
|
547
|
+
createLogContext(
|
|
548
|
+
"WebhookHandler",
|
|
549
|
+
"webhook_url_error",
|
|
550
|
+
{
|
|
551
|
+
...webhookContext,
|
|
552
|
+
status: "error"
|
|
553
|
+
}
|
|
554
|
+
),
|
|
555
|
+
new Error("Peer JWT webhook URL not available"),
|
|
556
|
+
"webhook_error_count",
|
|
557
|
+
{ error_type: "peer_jwt_missing" }
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
isValid: false,
|
|
562
|
+
error: {
|
|
563
|
+
code: "WEBHOOK_URL_MISSING",
|
|
564
|
+
message: "Webhook URL not available in peer JWT token",
|
|
565
|
+
requestId,
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Use the webhook URL from the peer's JWT token
|
|
571
|
+
const webhookUrl = req.user.rodit_webhookurl;
|
|
572
|
+
|
|
573
|
+
// Extract endpoint from options (defaults to /webhook)
|
|
574
|
+
const endpoint = options.endpoint || '/webhook';
|
|
575
|
+
|
|
576
|
+
logger.debugWithContext("Using webhook URL from peer identity context", {
|
|
577
|
+
...baseContext,
|
|
578
|
+
webhookSource: "peer_context",
|
|
579
|
+
webhookUrl,
|
|
580
|
+
endpoint
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Normalize base URL and endpoint so we always produce exactly one slash
|
|
584
|
+
// between host and path (e.g. https://host/hooks/wake).
|
|
585
|
+
const cleanWebhookUrl = webhookUrl
|
|
586
|
+
.replace(/^(https?:\/\/)/, "")
|
|
587
|
+
.replace(/\/+$/, "");
|
|
588
|
+
const normalizedEndpoint = `/${String(endpoint || "/webhook").replace(/^\/+/, "")}`;
|
|
589
|
+
const formattedWebhookUrl = `https://${cleanWebhookUrl}${normalizedEndpoint}`;
|
|
590
|
+
|
|
591
|
+
logger.debugWithContext("Webhook URL details", {
|
|
592
|
+
...baseContext,
|
|
593
|
+
rawWebhookUrl: webhookUrl,
|
|
594
|
+
endpoint,
|
|
595
|
+
formattedWebhookUrl
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
const timestamp = Date.now();
|
|
599
|
+
|
|
600
|
+
// Ensure data is serializable before stringifying
|
|
601
|
+
let sanitizedData;
|
|
602
|
+
try {
|
|
603
|
+
// Test if data can be properly serialized
|
|
604
|
+
if (typeof data === 'object' && data !== null) {
|
|
605
|
+
// Create a deep copy to avoid modifying the original data
|
|
606
|
+
sanitizedData = JSON.parse(JSON.stringify(data));
|
|
607
|
+
} else if (data === undefined || data === null) {
|
|
608
|
+
// Handle null/undefined explicitly
|
|
609
|
+
sanitizedData = null;
|
|
610
|
+
} else if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
|
|
611
|
+
// Primitive types can be used directly
|
|
612
|
+
sanitizedData = data;
|
|
613
|
+
} else {
|
|
614
|
+
// For other types (functions, symbols, etc.), create a string representation
|
|
615
|
+
sanitizedData = {
|
|
616
|
+
type: typeof data,
|
|
617
|
+
stringValue: String(data)
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
} catch (serializeError) {
|
|
621
|
+
// If data can't be serialized, create a simplified version
|
|
622
|
+
logger.debugWithContext("Data serialization failed, creating simplified version", {
|
|
623
|
+
...baseContext,
|
|
624
|
+
error: serializeError.message
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Create a simplified version with basic properties
|
|
628
|
+
sanitizedData = {
|
|
629
|
+
type: typeof data,
|
|
630
|
+
summary: "Data could not be serialized to JSON",
|
|
631
|
+
error: serializeError.message
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Create the payload object
|
|
636
|
+
const payloadObj = {
|
|
637
|
+
event,
|
|
638
|
+
data: sanitizedData,
|
|
639
|
+
isError,
|
|
640
|
+
requestId,
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// Create the payload with consistent JSON formatting
|
|
644
|
+
// Sort keys to ensure canonical representation regardless of object creation order
|
|
645
|
+
const payload = JSON.stringify(payloadObj, function(key, value) {
|
|
646
|
+
// Handle special numeric values consistently
|
|
647
|
+
if (typeof value === 'number') {
|
|
648
|
+
if (isNaN(value)) return 'NaN';
|
|
649
|
+
if (value === Infinity) return 'Infinity';
|
|
650
|
+
if (value === -Infinity) return '-Infinity';
|
|
651
|
+
}
|
|
652
|
+
return value;
|
|
653
|
+
}, 0);
|
|
654
|
+
|
|
655
|
+
// Ensure consistent handling of Unicode characters
|
|
656
|
+
const normalizedPayload = payload.normalize('NFC');
|
|
657
|
+
|
|
658
|
+
logger.debug("Preparing webhook payload", {
|
|
659
|
+
component: "WebhookHandler",
|
|
660
|
+
method: "send_webhook",
|
|
661
|
+
requestId,
|
|
662
|
+
payloadSize: normalizedPayload.length,
|
|
663
|
+
event,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Create the string to hash: payload + timestamp
|
|
667
|
+
// This binds the timestamp to the payload for signature verification
|
|
668
|
+
const payloadWithTimestamp = normalizedPayload + timestamp.toString();
|
|
669
|
+
|
|
670
|
+
logger.debugWithContext("Creating payload+timestamp string for signing", {
|
|
671
|
+
...baseContext,
|
|
672
|
+
payloadSize: normalizedPayload.length,
|
|
673
|
+
timestampLength: timestamp.toString().length,
|
|
674
|
+
combinedLength: payloadWithTimestamp.length
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// Generate hash of payload+timestamp
|
|
678
|
+
const sha256_ofpayload = crypto
|
|
679
|
+
.createHash("sha256")
|
|
680
|
+
.update(payloadWithTimestamp)
|
|
681
|
+
.digest();
|
|
682
|
+
|
|
683
|
+
// Log hash details for visibility
|
|
684
|
+
logger.debug("Webhook hash details", {
|
|
685
|
+
component: "WebhookHandler",
|
|
686
|
+
method: "send_webhook",
|
|
687
|
+
requestId,
|
|
688
|
+
hashHex: sha256_ofpayload.toString('hex'),
|
|
689
|
+
hashLength: sha256_ofpayload.length
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
const config_own_rodit = await stateManager.getConfigOwnRodit();
|
|
693
|
+
if (!config_own_rodit || !config_own_rodit.own_rodit_bytes_private_key) {
|
|
694
|
+
throw new Error("Own RODiT private key unavailable for webhook signing");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
logger.debugWithContext("Creating signature", {
|
|
698
|
+
...baseContext,
|
|
699
|
+
hasPrivateKey: !!config_own_rodit.own_rodit_bytes_private_key
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
// Convert private key and generate signature
|
|
703
|
+
const own_rodit_private_key = new Uint8Array(
|
|
704
|
+
config_own_rodit.own_rodit_bytes_private_key
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
// Log the public key from state manager
|
|
708
|
+
const publicKey = stateManager.getOwnBase64urlJwkPublicKey();
|
|
709
|
+
|
|
710
|
+
// Log the key in multiple formats for precise comparison
|
|
711
|
+
logger.debug("Webhook signing key information", {
|
|
712
|
+
component: "WebhookHandler",
|
|
713
|
+
method: "send_webhook",
|
|
714
|
+
requestId,
|
|
715
|
+
publicKeyBase64url: publicKey,
|
|
716
|
+
publicKeyHex: publicKey ? Buffer.from(publicKey, 'base64url').toString('hex') : null,
|
|
717
|
+
keyLength: publicKey ? Buffer.from(publicKey, 'base64url').length : 0
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
const signatureStartTime = Date.now();
|
|
721
|
+
const signature_ofpayload = nacl.sign.detached(
|
|
722
|
+
sha256_ofpayload,
|
|
723
|
+
own_rodit_private_key
|
|
724
|
+
);
|
|
725
|
+
const signatureDuration = Date.now() - signatureStartTime;
|
|
726
|
+
|
|
727
|
+
// Log signature generation metrics
|
|
728
|
+
logger.metric &&
|
|
729
|
+
logger.metric("signature_generation_duration_ms", signatureDuration, {
|
|
730
|
+
component: "WebhookHandler",
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
const signature_hex_ofpayload =
|
|
734
|
+
Buffer.from(signature_ofpayload).toString("hex");
|
|
735
|
+
|
|
736
|
+
// Log signature details for visibility and comparison with client logs
|
|
737
|
+
logger.debugWithContext("Webhook signature details", {
|
|
738
|
+
...baseContext,
|
|
739
|
+
signatureHex: signature_hex_ofpayload,
|
|
740
|
+
signatureBase64: Buffer.from(signature_ofpayload).toString("base64"),
|
|
741
|
+
signatureBase64url: Buffer.from(signature_ofpayload).toString("base64").replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
|
742
|
+
signatureLength: signature_hex_ofpayload.length,
|
|
743
|
+
signatureByteLength: signature_ofpayload.length
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// Log the exact hash that was signed for comparison
|
|
747
|
+
logger.debugWithContext("Webhook hash that was signed", {
|
|
748
|
+
...baseContext,
|
|
749
|
+
hashHex: Buffer.from(sha256_ofpayload).toString('hex'),
|
|
750
|
+
hashBase64: Buffer.from(sha256_ofpayload).toString('base64'),
|
|
751
|
+
hashBase64url: Buffer.from(sha256_ofpayload).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
|
|
752
|
+
hashLength: sha256_ofpayload.length
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
logger.debugWithContext("Sending webhook request", {
|
|
756
|
+
...baseContext,
|
|
757
|
+
webhookUrl: formattedWebhookUrl,
|
|
758
|
+
timestamp: timestamp.toString(),
|
|
759
|
+
payload: ['debug', 'trace'].includes(config.get('LOG_LEVEL', 'info')) ? payload : undefined, // Only log payload in debug mode
|
|
760
|
+
signatureHex: signature_hex_ofpayload
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
// Prepare headers for the webhook request
|
|
764
|
+
// Only include webhook-specific authentication headers (digital signature)
|
|
765
|
+
// No API bearer tokens - webhook security relies on cryptographic signatures
|
|
766
|
+
const headers = {
|
|
767
|
+
"Content-Type": "application/json",
|
|
768
|
+
"X-Signature": signature_hex_ofpayload,
|
|
769
|
+
"X-Timestamp": timestamp.toString(),
|
|
770
|
+
"X-Request-ID": requestId
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// Log the exact headers being sent
|
|
774
|
+
logger.debugWithContext("Webhook request headers", {
|
|
775
|
+
...baseContext,
|
|
776
|
+
headers: headers,
|
|
777
|
+
signatureHeader: signature_hex_ofpayload,
|
|
778
|
+
timestampHeader: timestamp.toString()
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
// SELF-VERIFICATION: Call authenticate_webhook with the same parameters the client will use
|
|
782
|
+
// This helps determine if the issue is in the signature generation/verification or in the data flow
|
|
783
|
+
try {
|
|
784
|
+
logger.info("Performing self-verification before sending webhook", {
|
|
785
|
+
component: "WebhookHandler",
|
|
786
|
+
method: "send_webhook",
|
|
787
|
+
requestId
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// Get our own public key for verification
|
|
791
|
+
const publicKeyForVerification = stateManager.getOwnBase64urlJwkPublicKey();
|
|
792
|
+
|
|
793
|
+
// Call authenticate_webhook with the same parameters the client will receive
|
|
794
|
+
const verificationResult = await authenticate_webhook(
|
|
795
|
+
payload, // The exact payload being sent
|
|
796
|
+
signature_hex_ofpayload, // The signature in hex format
|
|
797
|
+
timestamp.toString(), // The timestamp as a string
|
|
798
|
+
publicKeyForVerification // Our own public key for verification
|
|
799
|
+
);
|
|
800
|
+
|
|
801
|
+
logger.infoWithContext("Self-verification result", {
|
|
802
|
+
...baseContext,
|
|
803
|
+
selfVerificationSuccess: verificationResult.isValid,
|
|
804
|
+
selfVerificationError: verificationResult.error ? verificationResult.error.message : null
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
if (!verificationResult.isValid) {
|
|
808
|
+
logger.warnWithContext("Self-verification failed - client verification will likely fail too", {
|
|
809
|
+
...baseContext,
|
|
810
|
+
error: verificationResult.error ? verificationResult.error.message : "Unknown verification error"
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
} catch (verificationError) {
|
|
814
|
+
logErrorWithMetrics(
|
|
815
|
+
"Error during self-verification",
|
|
816
|
+
baseContext,
|
|
817
|
+
verificationError,
|
|
818
|
+
"webhook_verification_error",
|
|
819
|
+
{ error_type: "self_verification_error" }
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Configure HTTPS agent to skip TLS verification if configured
|
|
824
|
+
// This is necessary when webhook destinations use self-signed certificates
|
|
825
|
+
// Since mutual authentication via digital signatures is already in place,
|
|
826
|
+
// skipping TLS verification is safe in this context
|
|
827
|
+
const skipTlsVerify = config.has('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')
|
|
828
|
+
? String(config.get('SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY')).toLowerCase() === 'true'
|
|
829
|
+
: false;
|
|
830
|
+
|
|
831
|
+
let fetchOptions = {
|
|
832
|
+
method: "POST",
|
|
833
|
+
headers: headers,
|
|
834
|
+
body: payload,
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
if (skipTlsVerify) {
|
|
838
|
+
// Create custom undici Agent that accepts self-signed certificates
|
|
839
|
+
// Node.js fetch uses undici under the hood and requires 'dispatcher' option
|
|
840
|
+
const undiciAgent = new Agent({
|
|
841
|
+
connect: {
|
|
842
|
+
rejectUnauthorized: false
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
fetchOptions.dispatcher = undiciAgent;
|
|
846
|
+
|
|
847
|
+
logger.debugWithContext("Webhook TLS verification disabled", {
|
|
848
|
+
...baseContext,
|
|
849
|
+
skipTlsVerify: true,
|
|
850
|
+
reason: "SECURITY_OPTIONS.WEBHOOK_TLS_SKIP_VERIFY=true"
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// Send webhook request
|
|
855
|
+
const fetchStartTime = Date.now();
|
|
856
|
+
const response = await fetch(formattedWebhookUrl, fetchOptions);
|
|
857
|
+
const fetchDuration = Date.now() - fetchStartTime;
|
|
858
|
+
|
|
859
|
+
// Log fetch duration metrics
|
|
860
|
+
logger.metric("webhook_http_request_duration_ms", fetchDuration, {
|
|
861
|
+
component: "WebhookHandler",
|
|
862
|
+
success: response.ok,
|
|
863
|
+
status: response.status,
|
|
864
|
+
event,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
if (!response.ok) {
|
|
868
|
+
const duration = Date.now() - startTime;
|
|
869
|
+
|
|
870
|
+
logErrorWithMetrics(
|
|
871
|
+
"Webhook delivery failed",
|
|
872
|
+
{
|
|
873
|
+
...baseContext,
|
|
874
|
+
duration,
|
|
875
|
+
status: response.status,
|
|
876
|
+
statusText: response.statusText,
|
|
877
|
+
webhookUrl: formattedWebhookUrl
|
|
878
|
+
},
|
|
879
|
+
new Error(`HTTP ${response.status}: ${response.statusText}`),
|
|
880
|
+
"webhook_delivery_error",
|
|
881
|
+
{ error_type: "http_error", status: response.status }
|
|
882
|
+
);
|
|
883
|
+
|
|
884
|
+
// Emit metrics for dashboards
|
|
885
|
+
logger.metric("webhook_delivery_duration_ms", duration, {
|
|
886
|
+
component: "WebhookHandler",
|
|
887
|
+
success: false,
|
|
888
|
+
event,
|
|
889
|
+
error: "HTTP_ERROR",
|
|
890
|
+
status: response.status,
|
|
891
|
+
});
|
|
892
|
+
logger.metric("webhook_delivery_failures_total", 1, {
|
|
893
|
+
component: "WebhookHandler",
|
|
894
|
+
reason: "HTTP_ERROR",
|
|
895
|
+
status: response.status,
|
|
896
|
+
event,
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
throw new Error(`HTTP error! status: ${response.status}`);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
await response.text();
|
|
903
|
+
|
|
904
|
+
const duration = Date.now() - startTime;
|
|
905
|
+
logger.infoWithContext("Webhook delivered successfully", {
|
|
906
|
+
...baseContext,
|
|
907
|
+
duration,
|
|
908
|
+
webhookUrl: formattedWebhookUrl,
|
|
909
|
+
status: response.status
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// Emit metrics for dashboards
|
|
913
|
+
logger.metric("webhook_delivery_duration_ms", duration, {
|
|
914
|
+
component: "WebhookHandler",
|
|
915
|
+
success: true,
|
|
916
|
+
event,
|
|
917
|
+
});
|
|
918
|
+
logger.metric("successful_webhook_deliveries_total", 1, {
|
|
919
|
+
component: "WebhookHandler",
|
|
920
|
+
event,
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// Removed test-mode DB recording on success
|
|
924
|
+
|
|
925
|
+
// Log success with infoWithContext pattern
|
|
926
|
+
logger.infoWithContext("Webhook sent successfully", {
|
|
927
|
+
...webhookContext,
|
|
928
|
+
status: "success"
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
// Return success result with requestId for tracing
|
|
932
|
+
return {
|
|
933
|
+
isValid: true,
|
|
934
|
+
message: "Webhook sent successfully",
|
|
935
|
+
requestId,
|
|
936
|
+
duration,
|
|
937
|
+
};
|
|
938
|
+
} catch (error) {
|
|
939
|
+
const duration = Date.now() - startTime;
|
|
940
|
+
|
|
941
|
+
logErrorWithMetrics(
|
|
942
|
+
"Webhook send failed",
|
|
943
|
+
{
|
|
944
|
+
...baseContext,
|
|
945
|
+
duration,
|
|
946
|
+
errorCode: error.code || "UNKNOWN_ERROR",
|
|
947
|
+
isError,
|
|
948
|
+
operation: "webhook",
|
|
949
|
+
status: "failed"
|
|
950
|
+
},
|
|
951
|
+
error,
|
|
952
|
+
"webhook_delivery_error",
|
|
953
|
+
{ error_type: "network_error" }
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
// Emit metrics for dashboards
|
|
957
|
+
logger.metric("webhook_delivery_duration_ms", duration, {
|
|
958
|
+
component: "WebhookHandler",
|
|
959
|
+
success: false,
|
|
960
|
+
event,
|
|
961
|
+
error: error.constructor.name,
|
|
962
|
+
});
|
|
963
|
+
logger.metric("webhook_delivery_errors_total", 1, {
|
|
964
|
+
component: "WebhookHandler",
|
|
965
|
+
error: error.constructor.name,
|
|
966
|
+
event,
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
// Log error with errorWithContext pattern
|
|
970
|
+
logger.errorWithContext && logger.errorWithContext(
|
|
971
|
+
"Webhook send failed",
|
|
972
|
+
{
|
|
973
|
+
...webhookContext,
|
|
974
|
+
status: "failed",
|
|
975
|
+
errorMessage: error.message
|
|
976
|
+
},
|
|
977
|
+
error
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
// Return error result with requestId for tracing
|
|
981
|
+
return {
|
|
982
|
+
isValid: false,
|
|
983
|
+
error: {
|
|
984
|
+
code: "WEBHOOK_SEND_ERROR",
|
|
985
|
+
message: `Failed to send webhook: ${error.message}`,
|
|
986
|
+
requestId,
|
|
987
|
+
},
|
|
988
|
+
};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Base class for webhook event handlers
|
|
994
|
+
*/
|
|
995
|
+
class WebhookEventHandler {
|
|
996
|
+
/**
|
|
997
|
+
* Create a new webhook event handler
|
|
998
|
+
* @param {Object} configuration - Configuration configuration
|
|
999
|
+
*/
|
|
1000
|
+
constructor(configuration = {}) {
|
|
1001
|
+
this.configuration = configuration;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Handle a webhook event
|
|
1006
|
+
* @param {Object} event - Event data
|
|
1007
|
+
* @param {Object} req - Express request object
|
|
1008
|
+
* @param {Object} res - Express response object
|
|
1009
|
+
* @returns {Promise<Object>} Response data
|
|
1010
|
+
*/
|
|
1011
|
+
async handleEvent(event, req, res) {
|
|
1012
|
+
throw new Error("Method not implemented");
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Handler for test configuration update events
|
|
1018
|
+
*/
|
|
1019
|
+
class TestConfigUpdateHandler extends WebhookEventHandler {
|
|
1020
|
+
/**
|
|
1021
|
+
* Create a new test configuration update handler
|
|
1022
|
+
* @param {Object} configManager - Configuration manager
|
|
1023
|
+
* @param {Object} configuration - Configuration configuration
|
|
1024
|
+
*/
|
|
1025
|
+
constructor(configManager, configuration = {}) {
|
|
1026
|
+
super(configuration);
|
|
1027
|
+
this.configManager = configManager;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Handle a test configuration update event
|
|
1032
|
+
* @param {Object} event - Event data
|
|
1033
|
+
* @param {Object} req - Express request object
|
|
1034
|
+
* @param {Object} res - Express response object
|
|
1035
|
+
* @returns {Promise<Object>} Response data
|
|
1036
|
+
*/
|
|
1037
|
+
async handleEvent(event, req, res) {
|
|
1038
|
+
const logContext = createLogContext({
|
|
1039
|
+
component: "TestConfigUpdateHandler",
|
|
1040
|
+
event: "handleEvent",
|
|
1041
|
+
requestId: req.requestId || ulid(),
|
|
1042
|
+
eventType: event.type,
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
if (!this.configManager) {
|
|
1047
|
+
const error = new Error("Config manager is required but not provided");
|
|
1048
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1049
|
+
return {
|
|
1050
|
+
success: false,
|
|
1051
|
+
error: error.message,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// Update configuration
|
|
1056
|
+
await this.configManager.updateConfig(event.data);
|
|
1057
|
+
|
|
1058
|
+
logger.infoWithContext("Test configuration updated successfully", logContext);
|
|
1059
|
+
return {
|
|
1060
|
+
success: true,
|
|
1061
|
+
message: "Test configuration updated successfully",
|
|
1062
|
+
};
|
|
1063
|
+
} catch (error) {
|
|
1064
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1065
|
+
return {
|
|
1066
|
+
success: false,
|
|
1067
|
+
error: error.message,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Handler for test suite execution events
|
|
1075
|
+
*/
|
|
1076
|
+
class TestSuiteHandler extends WebhookEventHandler {
|
|
1077
|
+
/**
|
|
1078
|
+
* Create a new test suite handler
|
|
1079
|
+
* @param {Function} runTestSuite - Function to run a test suite
|
|
1080
|
+
* @param {Object} configuration - Configuration configuration
|
|
1081
|
+
*/
|
|
1082
|
+
constructor(runTestSuite, configuration = {}) {
|
|
1083
|
+
super(configuration);
|
|
1084
|
+
this.runTestSuite = runTestSuite;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Handle a test suite execution event
|
|
1089
|
+
* @param {Object} event - Event data
|
|
1090
|
+
* @param {Object} req - Express request object
|
|
1091
|
+
* @param {Object} res - Express response object
|
|
1092
|
+
* @returns {Promise<Object>} Response data
|
|
1093
|
+
*/
|
|
1094
|
+
async handleEvent(event, req, res) {
|
|
1095
|
+
const logContext = createLogContext({
|
|
1096
|
+
component: "TestSuiteHandler",
|
|
1097
|
+
event: "handleEvent",
|
|
1098
|
+
requestId: req.requestId || ulid(),
|
|
1099
|
+
eventType: event.type,
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
try {
|
|
1103
|
+
if (!this.runTestSuite) {
|
|
1104
|
+
const error = new Error("runTestSuite function is required but not provided");
|
|
1105
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1106
|
+
return {
|
|
1107
|
+
success: false,
|
|
1108
|
+
error: error.message,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// Extract test configuration from event data
|
|
1113
|
+
const testOptions = event.data || {};
|
|
1114
|
+
|
|
1115
|
+
// Run the test suite
|
|
1116
|
+
const testResults = await this.runTestSuite(testOptions);
|
|
1117
|
+
|
|
1118
|
+
logger.infoWithContext("Test suite executed successfully", logContext);
|
|
1119
|
+
return {
|
|
1120
|
+
success: true,
|
|
1121
|
+
message: "Test suite executed successfully",
|
|
1122
|
+
results: testResults,
|
|
1123
|
+
};
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1126
|
+
return {
|
|
1127
|
+
success: false,
|
|
1128
|
+
error: error.message,
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Handler for single test execution events
|
|
1136
|
+
*/
|
|
1137
|
+
class SingleTestHandler extends WebhookEventHandler {
|
|
1138
|
+
/**
|
|
1139
|
+
* Create a new single test handler
|
|
1140
|
+
* @param {Function} runSingleTest - Function to run a single test
|
|
1141
|
+
* @param {Object} configuration - Configuration configuration
|
|
1142
|
+
*/
|
|
1143
|
+
constructor(runSingleTest, configuration = {}) {
|
|
1144
|
+
super(configuration);
|
|
1145
|
+
this.runSingleTest = runSingleTest;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Handle a single test execution event
|
|
1150
|
+
* @param {Object} event - Event data
|
|
1151
|
+
* @param {Object} req - Express request object
|
|
1152
|
+
* @param {Object} res - Express response object
|
|
1153
|
+
* @returns {Promise<Object>} Response data
|
|
1154
|
+
*/
|
|
1155
|
+
async handleEvent(event, req, res) {
|
|
1156
|
+
const logContext = createLogContext({
|
|
1157
|
+
component: "SingleTestHandler",
|
|
1158
|
+
event: "handleEvent",
|
|
1159
|
+
requestId: req.requestId || ulid(),
|
|
1160
|
+
eventType: event.type,
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
try {
|
|
1164
|
+
if (!this.runSingleTest) {
|
|
1165
|
+
const error = new Error("runSingleTest function is required but not provided");
|
|
1166
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1167
|
+
return {
|
|
1168
|
+
success: false,
|
|
1169
|
+
error: error.message,
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// Extract test configuration from event data
|
|
1174
|
+
const testOptions = event.data || {};
|
|
1175
|
+
const testName = testOptions.testName;
|
|
1176
|
+
|
|
1177
|
+
if (!testName) {
|
|
1178
|
+
const error = new Error("testName is required but not provided");
|
|
1179
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1180
|
+
return {
|
|
1181
|
+
success: false,
|
|
1182
|
+
error: error.message,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Run the single test
|
|
1187
|
+
const testResults = await this.runSingleTest(testName, testOptions);
|
|
1188
|
+
|
|
1189
|
+
logger.infoWithContext("Single test executed successfully", logContext);
|
|
1190
|
+
return {
|
|
1191
|
+
success: true,
|
|
1192
|
+
message: `Test '${testName}' executed successfully`,
|
|
1193
|
+
results: testResults,
|
|
1194
|
+
};
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1197
|
+
return {
|
|
1198
|
+
success: false,
|
|
1199
|
+
error: error.message,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Handler for comment events
|
|
1207
|
+
*/
|
|
1208
|
+
class CommentEventHandler extends WebhookEventHandler {
|
|
1209
|
+
/**
|
|
1210
|
+
* Create a new comment event handler
|
|
1211
|
+
* @param {Object} configuration - Configuration configuration
|
|
1212
|
+
*/
|
|
1213
|
+
constructor(configuration = {}) {
|
|
1214
|
+
super(configuration);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Handle a comment event
|
|
1219
|
+
* @param {Object} event - Event data
|
|
1220
|
+
* @param {Object} req - Express request object
|
|
1221
|
+
* @param {Object} res - Express response object
|
|
1222
|
+
* @returns {Promise<Object>} Response data
|
|
1223
|
+
*/
|
|
1224
|
+
async handleEvent(event, req, res) {
|
|
1225
|
+
const logContext = createLogContext({
|
|
1226
|
+
component: "CommentEventHandler",
|
|
1227
|
+
event: "handleEvent",
|
|
1228
|
+
requestId: req.requestId || ulid(),
|
|
1229
|
+
eventType: event.type,
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
// Log the comment event
|
|
1234
|
+
logger.infoWithContext("Comment event received", {
|
|
1235
|
+
...logContext,
|
|
1236
|
+
eventType: event.type,
|
|
1237
|
+
commentId: event.data?.commentId,
|
|
1238
|
+
userId: event.data?.userId,
|
|
1239
|
+
testId: event.data?.testId,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// For now, we just acknowledge receipt of the event
|
|
1243
|
+
// In the future, this could store comments in a database or trigger other actions
|
|
1244
|
+
return {
|
|
1245
|
+
success: true,
|
|
1246
|
+
message: `Comment event '${event.type}' processed successfully`,
|
|
1247
|
+
eventType: event.type,
|
|
1248
|
+
timestamp: new Date().toISOString(),
|
|
1249
|
+
};
|
|
1250
|
+
} catch (error) {
|
|
1251
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1252
|
+
return {
|
|
1253
|
+
success: false,
|
|
1254
|
+
error: error.message,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* Factory for creating webhook event handlers
|
|
1262
|
+
*/
|
|
1263
|
+
class WebhookEventHandlerFactory {
|
|
1264
|
+
/**
|
|
1265
|
+
* Create a new webhook event handler factory
|
|
1266
|
+
* @param {Object} dependencies - Dependencies for handlers
|
|
1267
|
+
* @param {Object} configuration - Configuration configuration
|
|
1268
|
+
*/
|
|
1269
|
+
constructor(dependencies = {}, configuration = {}) {
|
|
1270
|
+
this.dependencies = dependencies;
|
|
1271
|
+
this.configuration = configuration;
|
|
1272
|
+
this.handlers = new Map();
|
|
1273
|
+
|
|
1274
|
+
// Register default handlers if dependencies are provided
|
|
1275
|
+
if (dependencies.configManager) {
|
|
1276
|
+
this.registerHandler("test_config_update", new TestConfigUpdateHandler(dependencies.configManager, configuration));
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (dependencies.runTestSuite) {
|
|
1280
|
+
this.registerHandler("run_test_suite", new TestSuiteHandler(dependencies.runTestSuite, configuration));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
if (dependencies.runSingleTest) {
|
|
1284
|
+
this.registerHandler("run_single_test", new SingleTestHandler(dependencies.runSingleTest, configuration));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// Register comment event handlers
|
|
1288
|
+
const commentHandler = new CommentEventHandler(configuration);
|
|
1289
|
+
this.registerHandler("comment_created", commentHandler);
|
|
1290
|
+
this.registerHandler("comment_updated", commentHandler);
|
|
1291
|
+
this.registerHandler("comment_deleted", commentHandler);
|
|
1292
|
+
this.registerHandler("comments_listed", commentHandler);
|
|
1293
|
+
this.registerHandler("create_comment_error", commentHandler);
|
|
1294
|
+
this.registerHandler("update_comment_error", commentHandler);
|
|
1295
|
+
this.registerHandler("delete_comment_error", commentHandler);
|
|
1296
|
+
this.registerHandler("read_comment_error", commentHandler);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Register a handler for an event type
|
|
1301
|
+
* @param {string} eventType - Event type
|
|
1302
|
+
* @param {WebhookEventHandler} handler - Event handler
|
|
1303
|
+
*/
|
|
1304
|
+
registerHandler(eventType, handler) {
|
|
1305
|
+
this.handlers.set(eventType, handler);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Get a handler for an event type
|
|
1310
|
+
* @param {string} eventType - Event type
|
|
1311
|
+
* @returns {WebhookEventHandler|null} Event handler or null if not found
|
|
1312
|
+
*/
|
|
1313
|
+
getHandler(eventType) {
|
|
1314
|
+
return this.handlers.get(eventType) || null;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Handle a webhook event
|
|
1319
|
+
* @param {Object} event - Event data
|
|
1320
|
+
* @param {Object} req - Express request object
|
|
1321
|
+
* @param {Object} res - Express response object
|
|
1322
|
+
* @returns {Promise<Object>} Response data
|
|
1323
|
+
*/
|
|
1324
|
+
async handleEvent(event, req, res) {
|
|
1325
|
+
const logContext = createLogContext({
|
|
1326
|
+
component: "WebhookEventHandlerFactory",
|
|
1327
|
+
event: "handleEvent",
|
|
1328
|
+
requestId: req.requestId || ulid(),
|
|
1329
|
+
eventType: event.type,
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
try {
|
|
1333
|
+
const eventType = event.type;
|
|
1334
|
+
|
|
1335
|
+
if (!eventType) {
|
|
1336
|
+
const error = new Error("Event type is required but not provided");
|
|
1337
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1338
|
+
return {
|
|
1339
|
+
success: false,
|
|
1340
|
+
error: error.message,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const handler = this.getHandler(eventType);
|
|
1345
|
+
|
|
1346
|
+
if (!handler) {
|
|
1347
|
+
logger.infoWithContext("No handler registered for webhook event type; acknowledging event", {
|
|
1348
|
+
...logContext,
|
|
1349
|
+
eventType,
|
|
1350
|
+
mode: "noop-ack"
|
|
1351
|
+
});
|
|
1352
|
+
return {
|
|
1353
|
+
success: true,
|
|
1354
|
+
ignored: true,
|
|
1355
|
+
message: `No handler registered for event type: ${eventType}`,
|
|
1356
|
+
eventType
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
return await handler.handleEvent(event, req, res);
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
logger.errorWithContext(error.message, logContext, error);
|
|
1363
|
+
return {
|
|
1364
|
+
success: false,
|
|
1365
|
+
error: error.message,
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
module.exports = {
|
|
1372
|
+
// Original exports from webhookhandlermw.js
|
|
1373
|
+
createRawBodyParser,
|
|
1374
|
+
createWebhookProcessingMiddleware,
|
|
1375
|
+
createPublicKeyMiddleware,
|
|
1376
|
+
createWebhookAuthenticationMiddleware,
|
|
1377
|
+
processWebhookEvent,
|
|
1378
|
+
createWebhookHandler,
|
|
1379
|
+
send_webhook,
|
|
1380
|
+
|
|
1381
|
+
// Added exports from eventhandler.js
|
|
1382
|
+
WebhookEventHandler,
|
|
1383
|
+
TestConfigUpdateHandler,
|
|
1384
|
+
TestSuiteHandler,
|
|
1385
|
+
SingleTestHandler,
|
|
1386
|
+
CommentEventHandler,
|
|
1387
|
+
WebhookEventHandlerFactory
|
|
1388
|
+
};
|