forge-remote 0.1.13 → 0.1.15

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.
@@ -0,0 +1,714 @@
1
+ // Forge Remote Relay — Secure Webhook Receiver
2
+ // Copyright (c) 2025-2026 Iron Forge Apps
3
+ // Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
4
+
5
+ import { createServer } from "node:http";
6
+ import crypto from "node:crypto";
7
+ import { getDb, FieldValue } from "./firebase.js";
8
+ import { startTunnel, stopTunnel } from "./tunnel-manager.js";
9
+ import {
10
+ startNewSession,
11
+ getActiveSessionCount,
12
+ MAX_WEBHOOK_SESSIONS,
13
+ } from "./session-manager.js";
14
+ import { getWebhookConfig } from "./webhook-watcher.js";
15
+ import * as log from "./logger.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
22
+ const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
23
+ const RATE_LIMIT_MAX = 10; // max 10 requests per webhook per minute
24
+ const DEDUP_MAX_SIZE = 1000;
25
+ const SLACK_TIMESTAMP_MAX_AGE_S = 300; // 5 minutes
26
+ const MAX_VARIABLE_LENGTH = 1000;
27
+
28
+ // Unique session ID used for tunnel-manager (not a real Claude session).
29
+ const WEBHOOK_TUNNEL_ID = "__webhook-server__";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Module state
33
+ // ---------------------------------------------------------------------------
34
+
35
+ let server = null;
36
+ let serverPort = null;
37
+ let tunnelUrl = null;
38
+ let currentDesktopId = null;
39
+
40
+ /** LRU deduplication set — stores recent delivery IDs. */
41
+ const recentDeliveryIds = new Set();
42
+ const deliveryIdOrder = []; // oldest first, for eviction
43
+
44
+ /** Per-webhook sliding window rate limiter. Map<webhookId, number[]> */
45
+ const rateLimitWindows = new Map();
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Public API
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Start the webhook HTTP server on a random port, open a tunnel, and
53
+ * store the public URL in Firestore.
54
+ *
55
+ * @param {string} desktopId
56
+ * @returns {Promise<{server: import('http').Server, port: number, tunnelUrl: string}|null>}
57
+ */
58
+ export async function startWebhookServer(desktopId) {
59
+ currentDesktopId = desktopId;
60
+
61
+ // Create HTTP server on a random port.
62
+ const httpServer = createServer(handleRequest);
63
+
64
+ const port = await new Promise((resolve, reject) => {
65
+ httpServer.listen(0, "127.0.0.1", () => {
66
+ resolve(httpServer.address().port);
67
+ });
68
+ httpServer.on("error", reject);
69
+ });
70
+
71
+ server = httpServer;
72
+ serverPort = port;
73
+
74
+ log.info(`Webhook server listening on 127.0.0.1:${port}`);
75
+
76
+ // Start a cloudflare tunnel for the webhook server.
77
+ const url = await startTunnel(WEBHOOK_TUNNEL_ID, port);
78
+ if (!url) {
79
+ log.warn("Could not create tunnel for webhook server — webhooks disabled");
80
+ httpServer.close();
81
+ server = null;
82
+ serverPort = null;
83
+ return null;
84
+ }
85
+
86
+ tunnelUrl = url;
87
+
88
+ // Store the public URL in Firestore.
89
+ try {
90
+ const db = getDb();
91
+ await db.collection("desktops").doc(desktopId).update({
92
+ webhookServerUrl: url,
93
+ });
94
+ } catch (err) {
95
+ log.error(`Failed to store webhook URL in Firestore: ${err.message}`);
96
+ }
97
+
98
+ return { server: httpServer, port, tunnelUrl: url };
99
+ }
100
+
101
+ /**
102
+ * Stop the webhook server and its tunnel.
103
+ */
104
+ export async function stopWebhookServer() {
105
+ if (tunnelUrl) {
106
+ await stopTunnel(WEBHOOK_TUNNEL_ID);
107
+ tunnelUrl = null;
108
+ }
109
+
110
+ if (server) {
111
+ server.close();
112
+ server = null;
113
+ serverPort = null;
114
+ log.info("Webhook server stopped");
115
+ }
116
+
117
+ // Clear webhook URL from Firestore.
118
+ if (currentDesktopId) {
119
+ try {
120
+ const db = getDb();
121
+ await db.collection("desktops").doc(currentDesktopId).update({
122
+ webhookServerUrl: FieldValue.delete(),
123
+ });
124
+ } catch {
125
+ // Best effort — relay is shutting down.
126
+ }
127
+ currentDesktopId = null;
128
+ }
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Request handler
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Main HTTP request handler.
137
+ *
138
+ * @param {import('http').IncomingMessage} req
139
+ * @param {import('http').ServerResponse} res
140
+ */
141
+ async function handleRequest(req, res) {
142
+ // Health check endpoint.
143
+ if (req.method === "GET" && req.url === "/health") {
144
+ return sendJson(res, 200, { status: "ok" });
145
+ }
146
+
147
+ // Parse webhook route: POST /hooks/:webhookId
148
+ const match = req.url?.match(/^\/hooks\/([a-zA-Z0-9_-]+)$/);
149
+ if (!match) {
150
+ return sendJson(res, 404, { error: "not found" });
151
+ }
152
+
153
+ if (req.method !== "POST") {
154
+ res.setHeader("allow", "POST");
155
+ return sendJson(res, 405, { error: "method not allowed" });
156
+ }
157
+
158
+ const webhookId = match[1];
159
+ const sourceIp =
160
+ req.headers["x-forwarded-for"]?.split(",")[0]?.trim() ||
161
+ req.socket.remoteAddress ||
162
+ "unknown";
163
+
164
+ try {
165
+ await handleWebhookPost(req, res, webhookId, sourceIp);
166
+ } catch (err) {
167
+ log.error(`Webhook handler error: ${err.message}`);
168
+ await writeAuditLog(
169
+ webhookId,
170
+ "unknown",
171
+ sourceIp,
172
+ "rejected",
173
+ err.message,
174
+ null,
175
+ );
176
+ sendJson(res, 500, { error: "internal server error" });
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Handle a POST to /hooks/:webhookId.
182
+ */
183
+ async function handleWebhookPost(req, res, webhookId, sourceIp) {
184
+ // 1. Look up webhook config.
185
+ const config = getWebhookConfig(webhookId);
186
+ if (!config) {
187
+ await writeAuditLog(
188
+ webhookId,
189
+ "unknown",
190
+ sourceIp,
191
+ "rejected",
192
+ "webhook not found",
193
+ null,
194
+ );
195
+ return sendJson(res, 404, { error: "webhook not found" });
196
+ }
197
+
198
+ if (config.enabled === false) {
199
+ await writeAuditLog(
200
+ webhookId,
201
+ config.source || "custom",
202
+ sourceIp,
203
+ "rejected",
204
+ "webhook disabled",
205
+ null,
206
+ );
207
+ return sendJson(res, 403, { error: "webhook disabled" });
208
+ }
209
+
210
+ const source = config.source || "custom";
211
+
212
+ // 2. Read body with size limit.
213
+ let rawBody;
214
+ try {
215
+ rawBody = await readBody(req, MAX_BODY_SIZE);
216
+ } catch (err) {
217
+ if (err.message === "payload too large") {
218
+ await writeAuditLog(
219
+ webhookId,
220
+ source,
221
+ sourceIp,
222
+ "rejected",
223
+ "payload too large",
224
+ null,
225
+ );
226
+ return sendJson(res, 413, { error: "payload too large" });
227
+ }
228
+ throw err;
229
+ }
230
+
231
+ // 2a. Handle Slack url_verification challenge (must happen before
232
+ // signature validation — we don't have the Slack signing secret).
233
+ if (source === "slack") {
234
+ try {
235
+ const maybeChallenge = JSON.parse(rawBody.toString("utf-8"));
236
+ if (
237
+ maybeChallenge.type === "url_verification" &&
238
+ maybeChallenge.challenge
239
+ ) {
240
+ log.info(
241
+ `Slack URL verification for webhook ${webhookId} — responding with challenge`,
242
+ );
243
+ return sendJson(res, 200, { challenge: maybeChallenge.challenge });
244
+ }
245
+ } catch {
246
+ // Not valid JSON — continue to normal flow which will reject it.
247
+ }
248
+ }
249
+
250
+ // 3. Delivery ID deduplication.
251
+ const deliveryId =
252
+ req.headers["x-github-delivery"] || req.headers["x-webhook-delivery-id"];
253
+
254
+ if (deliveryId) {
255
+ if (recentDeliveryIds.has(deliveryId)) {
256
+ log.info(`Duplicate delivery ignored: ${deliveryId}`);
257
+ return sendJson(res, 200, { status: "duplicate" });
258
+ }
259
+ addDeliveryId(deliveryId);
260
+ }
261
+
262
+ // 4. Signature validation.
263
+ const signatureValid = validateSignature(
264
+ source,
265
+ config,
266
+ req.headers,
267
+ rawBody,
268
+ );
269
+ if (!signatureValid) {
270
+ await writeAuditLog(
271
+ webhookId,
272
+ source,
273
+ sourceIp,
274
+ "rejected",
275
+ "invalid signature",
276
+ null,
277
+ );
278
+ return sendJson(res, 401, { error: "invalid signature" });
279
+ }
280
+
281
+ // 5. Per-webhook rate limiting.
282
+ if (isWebhookRateLimited(webhookId)) {
283
+ await writeAuditLog(
284
+ webhookId,
285
+ source,
286
+ sourceIp,
287
+ "rejected",
288
+ "rate limited",
289
+ null,
290
+ );
291
+ return sendJson(res, 429, { error: "rate limited" });
292
+ }
293
+
294
+ // 6. Concurrent session cap.
295
+ const activeCount = getActiveSessionCount();
296
+ if (activeCount >= MAX_WEBHOOK_SESSIONS) {
297
+ await writeAuditLog(
298
+ webhookId,
299
+ source,
300
+ sourceIp,
301
+ "rejected",
302
+ "at session capacity",
303
+ null,
304
+ );
305
+ return sendJson(res, 503, { error: "at session capacity" });
306
+ }
307
+
308
+ // 7. Parse payload and render prompt template.
309
+ let payload;
310
+ try {
311
+ payload = JSON.parse(rawBody.toString("utf-8"));
312
+ } catch {
313
+ await writeAuditLog(
314
+ webhookId,
315
+ source,
316
+ sourceIp,
317
+ "rejected",
318
+ "invalid JSON body",
319
+ null,
320
+ );
321
+ return sendJson(res, 400, { error: "invalid JSON body" });
322
+ }
323
+
324
+ const prompt = renderTemplate(config.promptTemplate || "", payload);
325
+ const projectPath = config.projectPath || process.cwd();
326
+ const model = config.model || "sonnet";
327
+
328
+ // 8. Start a new session.
329
+ let sessionId;
330
+ try {
331
+ sessionId = await startNewSession(currentDesktopId, {
332
+ prompt,
333
+ projectPath,
334
+ model,
335
+ webhookMeta: {
336
+ webhookId,
337
+ source,
338
+ replyUrl:
339
+ source === "slack" ? config.sourceConfig?.replyWebhookUrl : null,
340
+ },
341
+ });
342
+ } catch (err) {
343
+ await writeAuditLog(
344
+ webhookId,
345
+ source,
346
+ sourceIp,
347
+ "rejected",
348
+ `session start failed: ${err.message}`,
349
+ null,
350
+ );
351
+ return sendJson(res, 500, { error: "failed to start session" });
352
+ }
353
+
354
+ // 9. Audit log + update trigger count.
355
+ await writeAuditLog(webhookId, source, sourceIp, "accepted", null, sessionId);
356
+ await updateTriggerCount(webhookId);
357
+
358
+ log.info(
359
+ `Webhook ${webhookId} (${source}) accepted — session ${sessionId || "started"}`,
360
+ );
361
+
362
+ return sendJson(res, 200, {
363
+ status: "accepted",
364
+ sessionId: sessionId || null,
365
+ });
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Body reading
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * Read the full request body up to a byte limit.
374
+ *
375
+ * @param {import('http').IncomingMessage} req
376
+ * @param {number} maxBytes
377
+ * @returns {Promise<Buffer>}
378
+ */
379
+ function readBody(req, maxBytes) {
380
+ return new Promise((resolve, reject) => {
381
+ const chunks = [];
382
+ let size = 0;
383
+
384
+ req.on("data", (chunk) => {
385
+ size += chunk.length;
386
+ if (size > maxBytes) {
387
+ req.destroy();
388
+ reject(new Error("payload too large"));
389
+ return;
390
+ }
391
+ chunks.push(chunk);
392
+ });
393
+
394
+ req.on("end", () => resolve(Buffer.concat(chunks)));
395
+ req.on("error", reject);
396
+ });
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Delivery ID deduplication (LRU)
401
+ // ---------------------------------------------------------------------------
402
+
403
+ function addDeliveryId(id) {
404
+ if (recentDeliveryIds.size >= DEDUP_MAX_SIZE) {
405
+ // Evict the oldest.
406
+ const oldest = deliveryIdOrder.shift();
407
+ recentDeliveryIds.delete(oldest);
408
+ }
409
+ recentDeliveryIds.add(id);
410
+ deliveryIdOrder.push(id);
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Signature validation
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * Validate the webhook signature based on source type.
419
+ * Returns true if valid, false otherwise.
420
+ */
421
+ function validateSignature(source, config, headers, rawBody) {
422
+ const secret = config.webhookSecret;
423
+ if (!secret) {
424
+ // No secret configured — skip validation.
425
+ log.warn(
426
+ "Webhook has no secret configured — skipping signature validation",
427
+ );
428
+ return true;
429
+ }
430
+
431
+ switch (source) {
432
+ case "github":
433
+ return validateGitHubSignature(headers, rawBody, secret);
434
+ case "slack":
435
+ return validateSlackSignature(headers, rawBody, secret);
436
+ default:
437
+ return validateCustomSignature(
438
+ headers,
439
+ rawBody,
440
+ secret,
441
+ config.sourceConfig,
442
+ );
443
+ }
444
+ }
445
+
446
+ /**
447
+ * GitHub: Validate x-hub-signature-256 with HMAC-SHA256.
448
+ */
449
+ function validateGitHubSignature(headers, rawBody, secret) {
450
+ const signature = headers["x-hub-signature-256"];
451
+ if (!signature) return false;
452
+
453
+ const expected =
454
+ "sha256=" +
455
+ crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
456
+
457
+ // Constant-time comparison.
458
+ try {
459
+ return crypto.timingSafeEqual(
460
+ Buffer.from(signature),
461
+ Buffer.from(expected),
462
+ );
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Slack: Validate x-slack-signature using v0:timestamp:body.
470
+ * Also checks timestamp is within 5 minutes to prevent replay.
471
+ *
472
+ * Slack signing secrets start with a known prefix. If the stored secret
473
+ * is not a real Slack signing secret (e.g. auto-generated by the app),
474
+ * skip validation — the URL verification challenge already proves ownership.
475
+ */
476
+ function validateSlackSignature(headers, rawBody, secret) {
477
+ const signature = headers["x-slack-signature"];
478
+ const timestampStr = headers["x-slack-request-timestamp"];
479
+
480
+ // If no Slack signature headers, this isn't a Slack request — reject.
481
+ if (!signature || !timestampStr) return false;
482
+
483
+ // If the stored secret doesn't look like a Slack signing secret,
484
+ // skip validation (user hasn't configured it yet).
485
+ if (!secret.startsWith("v0=") && secret.length < 40) {
486
+ log.warn(
487
+ "Slack webhook secret doesn't appear to be a Slack signing secret — skipping signature validation",
488
+ );
489
+ return true;
490
+ }
491
+
492
+ // Replay protection — timestamp must be within 5 minutes.
493
+ const timestamp = parseInt(timestampStr, 10);
494
+ if (isNaN(timestamp)) return false;
495
+ const now = Math.floor(Date.now() / 1000);
496
+ if (Math.abs(now - timestamp) > SLACK_TIMESTAMP_MAX_AGE_S) return false;
497
+
498
+ const baseString = `v0:${timestampStr}:${rawBody.toString("utf-8")}`;
499
+ const expected =
500
+ "v0=" +
501
+ crypto.createHmac("sha256", secret).update(baseString).digest("hex");
502
+
503
+ try {
504
+ return crypto.timingSafeEqual(
505
+ Buffer.from(signature),
506
+ Buffer.from(expected),
507
+ );
508
+ } catch {
509
+ return false;
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Custom: Validate a configurable signature header with HMAC-SHA256.
515
+ */
516
+ function validateCustomSignature(headers, rawBody, secret, sourceConfig) {
517
+ const headerName = (
518
+ sourceConfig?.signatureHeader || "x-webhook-signature"
519
+ ).toLowerCase();
520
+ const signature = headers[headerName];
521
+ if (!signature) return false;
522
+
523
+ const expected = crypto
524
+ .createHmac("sha256", secret)
525
+ .update(rawBody)
526
+ .digest("hex");
527
+
528
+ try {
529
+ return crypto.timingSafeEqual(
530
+ Buffer.from(signature),
531
+ Buffer.from(expected),
532
+ );
533
+ } catch {
534
+ return false;
535
+ }
536
+ }
537
+
538
+ // ---------------------------------------------------------------------------
539
+ // Rate limiting (per-webhook sliding window)
540
+ // ---------------------------------------------------------------------------
541
+
542
+ function isWebhookRateLimited(webhookId) {
543
+ const now = Date.now();
544
+ let timestamps = rateLimitWindows.get(webhookId);
545
+ if (!timestamps) {
546
+ timestamps = [];
547
+ rateLimitWindows.set(webhookId, timestamps);
548
+ }
549
+
550
+ // Remove entries outside the window.
551
+ while (timestamps.length > 0 && timestamps[0] < now - RATE_LIMIT_WINDOW_MS) {
552
+ timestamps.shift();
553
+ }
554
+
555
+ if (timestamps.length >= RATE_LIMIT_MAX) {
556
+ return true;
557
+ }
558
+
559
+ timestamps.push(now);
560
+ return false;
561
+ }
562
+
563
+ // ---------------------------------------------------------------------------
564
+ // Template rendering
565
+ // ---------------------------------------------------------------------------
566
+
567
+ /**
568
+ * Replace {{variable}} placeholders with values from the payload.
569
+ * Supports dot-notation for nested access (e.g., {{pull_request.title}}).
570
+ *
571
+ * Unresolved variables become [unknown: varName].
572
+ * Values are sanitized: max 1000 chars, control chars stripped except newlines.
573
+ */
574
+ function renderTemplate(template, payload) {
575
+ return template.replace(/\{\{([^}]+)\}\}/g, (_match, varPath) => {
576
+ const trimmed = varPath.trim();
577
+ const value = resolveNestedValue(payload, trimmed);
578
+
579
+ if (value === undefined || value === null) {
580
+ return `[unknown: ${trimmed}]`;
581
+ }
582
+
583
+ // Convert to string and sanitize.
584
+ let str = String(value);
585
+
586
+ // Strip control characters except newlines (\n) and carriage returns (\r).
587
+ str = str.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, "");
588
+
589
+ // Truncate to max variable length.
590
+ if (str.length > MAX_VARIABLE_LENGTH) {
591
+ str = str.slice(0, MAX_VARIABLE_LENGTH) + "...";
592
+ }
593
+
594
+ return str;
595
+ });
596
+ }
597
+
598
+ /**
599
+ * Resolve a dot-notation path against an object.
600
+ * e.g., resolveNestedValue({a: {b: "c"}}, "a.b") => "c"
601
+ */
602
+ function resolveNestedValue(obj, path) {
603
+ const parts = path.split(".");
604
+ let current = obj;
605
+ for (const part of parts) {
606
+ if (current == null || typeof current !== "object") return undefined;
607
+ current = current[part];
608
+ }
609
+ return current;
610
+ }
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // Audit logging
614
+ // ---------------------------------------------------------------------------
615
+
616
+ /**
617
+ * Write an audit log entry to Firestore.
618
+ */
619
+ async function writeAuditLog(
620
+ webhookId,
621
+ source,
622
+ sourceIp,
623
+ status,
624
+ reason,
625
+ sessionId,
626
+ ) {
627
+ if (!currentDesktopId) return;
628
+
629
+ try {
630
+ const db = getDb();
631
+ await db
632
+ .collection("desktops")
633
+ .doc(currentDesktopId)
634
+ .collection("webhookLogs")
635
+ .add({
636
+ webhookId,
637
+ source,
638
+ timestamp: FieldValue.serverTimestamp(),
639
+ sourceIp,
640
+ status,
641
+ ...(reason ? { reason } : {}),
642
+ ...(sessionId ? { sessionId } : {}),
643
+ });
644
+ } catch (err) {
645
+ log.error(`Failed to write webhook audit log: ${err.message}`);
646
+ }
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // Trigger count
651
+ // ---------------------------------------------------------------------------
652
+
653
+ /**
654
+ * Increment the trigger count on the webhook document in Firestore.
655
+ */
656
+ async function updateTriggerCount(webhookId) {
657
+ if (!currentDesktopId) return;
658
+
659
+ try {
660
+ const db = getDb();
661
+ await db
662
+ .collection("desktops")
663
+ .doc(currentDesktopId)
664
+ .collection("webhooks")
665
+ .doc(webhookId)
666
+ .update({
667
+ triggerCount: FieldValue.increment(1),
668
+ lastTriggeredAt: FieldValue.serverTimestamp(),
669
+ });
670
+ } catch (err) {
671
+ log.error(
672
+ `Failed to update trigger count for ${webhookId}: ${err.message}`,
673
+ );
674
+ }
675
+ }
676
+
677
+ // ---------------------------------------------------------------------------
678
+ // Slack reply
679
+ // ---------------------------------------------------------------------------
680
+
681
+ /**
682
+ * Post a message to a Slack Incoming Webhook URL.
683
+ * Used to send Claude's response back to the channel that triggered the session.
684
+ */
685
+ export async function postToSlack(webhookUrl, text) {
686
+ try {
687
+ const body = JSON.stringify({ text });
688
+ const res = await fetch(webhookUrl, {
689
+ method: "POST",
690
+ headers: { "content-type": "application/json" },
691
+ body,
692
+ });
693
+ if (!res.ok) {
694
+ log.error(`Slack reply failed (${res.status}): ${await res.text()}`);
695
+ } else {
696
+ log.success("Posted Claude's response back to Slack");
697
+ }
698
+ } catch (err) {
699
+ log.error(`Failed to post to Slack: ${err.message}`);
700
+ }
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // Helpers
705
+ // ---------------------------------------------------------------------------
706
+
707
+ function sendJson(res, statusCode, data) {
708
+ const body = JSON.stringify(data);
709
+ res.writeHead(statusCode, {
710
+ "content-type": "application/json",
711
+ "content-length": Buffer.byteLength(body),
712
+ });
713
+ res.end(body);
714
+ }