forge-remote 0.1.14 → 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.
- package/firestore.rules +8 -0
- package/package.json +2 -9
- package/src/cli.js +62 -0
- package/src/firebase.js +5 -0
- package/src/notifications.js +202 -0
- package/src/session-manager.js +71 -3
- package/src/update-checker.js +72 -0
- package/src/webhook-server.js +714 -0
- package/src/webhook-watcher.js +111 -0
package/firestore.rules
CHANGED
|
@@ -37,6 +37,14 @@ service cloud.firestore {
|
|
|
37
37
|
allow create: if isSignedIn();
|
|
38
38
|
allow update: if isSignedIn();
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
// ---- Webhooks (subcollection) ----
|
|
42
|
+
match /webhooks/{webhookId} {
|
|
43
|
+
allow read: if isSignedIn();
|
|
44
|
+
allow create: if isSignedIn();
|
|
45
|
+
allow update: if isSignedIn();
|
|
46
|
+
allow delete: if isSignedIn();
|
|
47
|
+
}
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
// ---- Sessions ----
|
package/package.json
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forge-remote",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Desktop relay for Forge Remote — monitor and control Claude Code sessions from your phone",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"author": "Daniel Wendel <daniel@ironforgeapps.com> (https://ironforgeapps.com)",
|
|
8
|
-
"
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "https://github.com/IronForgeApps/forge-remote.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/IronForgeApps/forge-remote/issues"
|
|
14
|
-
},
|
|
15
|
-
"homepage": "https://github.com/IronForgeApps/forge-remote#readme",
|
|
8
|
+
"homepage": "https://forgeremote.com",
|
|
16
9
|
"engines": {
|
|
17
10
|
"node": ">=18.0.0"
|
|
18
11
|
},
|
package/src/cli.js
CHANGED
|
@@ -30,7 +30,10 @@ import {
|
|
|
30
30
|
import { stopAllTunnels } from "./tunnel-manager.js";
|
|
31
31
|
import { stopAllCaptures } from "./screenshot-manager.js";
|
|
32
32
|
import { scanProjects } from "./project-scanner.js";
|
|
33
|
+
import { startWebhookServer, stopWebhookServer } from "./webhook-server.js";
|
|
34
|
+
import { watchWebhookConfigs, stopWatching } from "./webhook-watcher.js";
|
|
33
35
|
import * as log from "./logger.js";
|
|
36
|
+
import { checkForUpdate } from "./update-checker.js";
|
|
34
37
|
|
|
35
38
|
program
|
|
36
39
|
.name("forge-remote")
|
|
@@ -126,6 +129,7 @@ program
|
|
|
126
129
|
"Start the desktop relay (register, heartbeat, listen for commands)",
|
|
127
130
|
)
|
|
128
131
|
.action(async () => {
|
|
132
|
+
await checkForUpdate();
|
|
129
133
|
initFirebase();
|
|
130
134
|
const desktopId = await registerDesktop();
|
|
131
135
|
|
|
@@ -140,6 +144,13 @@ program
|
|
|
140
144
|
|
|
141
145
|
listenForCommands(desktopId);
|
|
142
146
|
|
|
147
|
+
// Start webhook server with tunnel for external integrations.
|
|
148
|
+
const webhookServer = await startWebhookServer(desktopId);
|
|
149
|
+
if (webhookServer) {
|
|
150
|
+
watchWebhookConfigs(desktopId, webhookServer.tunnelUrl);
|
|
151
|
+
log.info(`Webhook server ready at ${webhookServer.tunnelUrl}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
143
154
|
// Print startup banner.
|
|
144
155
|
log.banner(hostname(), getPlatformName(), desktopId, projects.length);
|
|
145
156
|
|
|
@@ -162,6 +173,8 @@ program
|
|
|
162
173
|
}, 5000);
|
|
163
174
|
|
|
164
175
|
try {
|
|
176
|
+
stopWatching();
|
|
177
|
+
await stopWebhookServer();
|
|
165
178
|
await shutdownAllSessions();
|
|
166
179
|
stopAllTunnels();
|
|
167
180
|
stopAllCaptures();
|
|
@@ -183,6 +196,7 @@ program
|
|
|
183
196
|
.command("pair")
|
|
184
197
|
.description("Generate a pairing QR code for the mobile app")
|
|
185
198
|
.action(async () => {
|
|
199
|
+
await checkForUpdate();
|
|
186
200
|
initFirebase();
|
|
187
201
|
const desktopId = await registerDesktop();
|
|
188
202
|
|
|
@@ -232,4 +246,52 @@ program
|
|
|
232
246
|
await runInit({ projectId: opts.projectId });
|
|
233
247
|
});
|
|
234
248
|
|
|
249
|
+
program
|
|
250
|
+
.command("set-pro <uid>")
|
|
251
|
+
.description(
|
|
252
|
+
"Grant permanent Pro access to a Firebase Auth UID via custom claims",
|
|
253
|
+
)
|
|
254
|
+
.action(async (uid) => {
|
|
255
|
+
initFirebase();
|
|
256
|
+
|
|
257
|
+
const { getAuth } = await import("firebase-admin/auth");
|
|
258
|
+
const auth = getAuth();
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
// Verify the UID exists.
|
|
262
|
+
const user = await auth.getUser(uid);
|
|
263
|
+
log.info(
|
|
264
|
+
`Found user: ${user.uid} (created ${user.metadata.creationTime})`,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// Set the custom claim.
|
|
268
|
+
await auth.setCustomUserClaims(uid, { pro: true });
|
|
269
|
+
log.success(`Set pro: true custom claim on UID ${uid}`);
|
|
270
|
+
log.info(
|
|
271
|
+
"The user must re-open the app (or force-refresh their token) for this to take effect.",
|
|
272
|
+
);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
log.error(`Failed to set custom claims: ${e.message}`);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
program
|
|
280
|
+
.command("remove-pro <uid>")
|
|
281
|
+
.description("Revoke permanent Pro access from a Firebase Auth UID")
|
|
282
|
+
.action(async (uid) => {
|
|
283
|
+
initFirebase();
|
|
284
|
+
|
|
285
|
+
const { getAuth } = await import("firebase-admin/auth");
|
|
286
|
+
const auth = getAuth();
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
await auth.setCustomUserClaims(uid, { pro: false });
|
|
290
|
+
log.success(`Removed pro claim from UID ${uid}`);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
log.error(`Failed to remove custom claims: ${e.message}`);
|
|
293
|
+
process.exit(1);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
235
297
|
program.parse();
|
package/src/firebase.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { initializeApp, cert } from "firebase-admin/app";
|
|
2
2
|
import { getFirestore, FieldValue, Timestamp } from "firebase-admin/firestore";
|
|
3
|
+
import { getMessaging as _getMessaging } from "firebase-admin/messaging";
|
|
3
4
|
import { readFileSync, existsSync } from "fs";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { homedir } from "os";
|
|
@@ -43,4 +44,8 @@ export function getDb() {
|
|
|
43
44
|
return db;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export function getMessaging() {
|
|
48
|
+
return _getMessaging();
|
|
49
|
+
}
|
|
50
|
+
|
|
46
51
|
export { FieldValue, Timestamp };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Forge Remote Relay — Push Notification Module
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
+
|
|
5
|
+
import { getDb, getMessaging } from "./firebase.js";
|
|
6
|
+
import * as log from "./logger.js";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// FCM token retrieval
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Read all FCM tokens for a desktop from Firestore.
|
|
14
|
+
* Tokens are stored at: desktops/{desktopId}/fcmTokens/{platform}
|
|
15
|
+
* Returns an array of { token, platform, docRef } objects.
|
|
16
|
+
*/
|
|
17
|
+
async function getFcmTokens(desktopId) {
|
|
18
|
+
const db = getDb();
|
|
19
|
+
const snap = await db
|
|
20
|
+
.collection("desktops")
|
|
21
|
+
.doc(desktopId)
|
|
22
|
+
.collection("fcmTokens")
|
|
23
|
+
.get();
|
|
24
|
+
|
|
25
|
+
return snap.docs.map((doc) => ({
|
|
26
|
+
token: doc.data().token,
|
|
27
|
+
platform: doc.id,
|
|
28
|
+
docRef: doc.ref,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Core send function
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send a push notification to all devices registered for a desktop.
|
|
38
|
+
* Uses FCM HTTP v1 API via Firebase Admin SDK.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} desktopId - Desktop document ID
|
|
41
|
+
* @param {object} notification - { title, body }
|
|
42
|
+
* @param {object} data - Custom data payload (all values must be strings)
|
|
43
|
+
*/
|
|
44
|
+
async function sendPushNotification(desktopId, notification, data = {}) {
|
|
45
|
+
let tokens;
|
|
46
|
+
try {
|
|
47
|
+
tokens = await getFcmTokens(desktopId);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
log.warn(
|
|
50
|
+
`[notifications] Failed to read FCM tokens for ${desktopId}: ${e.message}`,
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (tokens.length === 0) return;
|
|
56
|
+
|
|
57
|
+
const messaging = getMessaging();
|
|
58
|
+
|
|
59
|
+
for (const { token, platform, docRef } of tokens) {
|
|
60
|
+
try {
|
|
61
|
+
await messaging.send({
|
|
62
|
+
token,
|
|
63
|
+
notification,
|
|
64
|
+
data,
|
|
65
|
+
// Use high priority for permission requests on Android.
|
|
66
|
+
android: {
|
|
67
|
+
priority: data.type === "permission_request" ? "high" : "normal",
|
|
68
|
+
notification: {
|
|
69
|
+
channelId:
|
|
70
|
+
data.type === "permission_request"
|
|
71
|
+
? "permissions_channel"
|
|
72
|
+
: "sessions_channel",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
apns: {
|
|
76
|
+
payload: {
|
|
77
|
+
aps: {
|
|
78
|
+
alert: notification,
|
|
79
|
+
sound: "default",
|
|
80
|
+
// Use critical alert priority for permission requests.
|
|
81
|
+
...(data.type === "permission_request" && {
|
|
82
|
+
"interruption-level": "time-sensitive",
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
} catch (e) {
|
|
89
|
+
const code = e.code || "";
|
|
90
|
+
// Clean up invalid/expired tokens.
|
|
91
|
+
if (
|
|
92
|
+
code === "messaging/registration-token-not-registered" ||
|
|
93
|
+
code === "messaging/invalid-registration-token"
|
|
94
|
+
) {
|
|
95
|
+
log.warn(
|
|
96
|
+
`[notifications] Removing stale FCM token for ${platform}: ${code}`,
|
|
97
|
+
);
|
|
98
|
+
try {
|
|
99
|
+
await docRef.delete();
|
|
100
|
+
} catch {
|
|
101
|
+
// Best-effort cleanup.
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
log.warn(`[notifications] Failed to send to ${platform}: ${e.message}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Convenience methods — called from session-manager.js
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Notify mobile that a permission request needs approval.
|
|
116
|
+
*/
|
|
117
|
+
export async function notifyPermissionRequest(
|
|
118
|
+
desktopId,
|
|
119
|
+
sessionId,
|
|
120
|
+
{ toolName, commandPreview, projectName },
|
|
121
|
+
) {
|
|
122
|
+
await sendPushNotification(
|
|
123
|
+
desktopId,
|
|
124
|
+
{
|
|
125
|
+
title: "Permission Needed",
|
|
126
|
+
body: `${toolName}: ${commandPreview}`.slice(0, 200),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: "permission_request",
|
|
130
|
+
sessionId,
|
|
131
|
+
toolName: toolName || "",
|
|
132
|
+
commandPreview: (commandPreview || "").slice(0, 200),
|
|
133
|
+
projectName: projectName || "",
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Notify mobile that a session completed successfully.
|
|
140
|
+
*/
|
|
141
|
+
export async function notifySessionComplete(
|
|
142
|
+
desktopId,
|
|
143
|
+
sessionId,
|
|
144
|
+
{ projectName },
|
|
145
|
+
) {
|
|
146
|
+
await sendPushNotification(
|
|
147
|
+
desktopId,
|
|
148
|
+
{
|
|
149
|
+
title: "Task Complete",
|
|
150
|
+
body: `${projectName}: Claude finished the task`,
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
type: "session_complete",
|
|
154
|
+
sessionId,
|
|
155
|
+
projectName: projectName || "",
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Notify mobile that a session encountered an error.
|
|
162
|
+
*/
|
|
163
|
+
export async function notifySessionError(
|
|
164
|
+
desktopId,
|
|
165
|
+
sessionId,
|
|
166
|
+
{ projectName, errorMessage },
|
|
167
|
+
) {
|
|
168
|
+
await sendPushNotification(
|
|
169
|
+
desktopId,
|
|
170
|
+
{
|
|
171
|
+
title: "Session Error",
|
|
172
|
+
body: `${projectName}: ${errorMessage || "An error occurred"}`.slice(
|
|
173
|
+
0,
|
|
174
|
+
200,
|
|
175
|
+
),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
type: "session_error",
|
|
179
|
+
sessionId,
|
|
180
|
+
projectName: projectName || "",
|
|
181
|
+
errorMessage: (errorMessage || "").slice(0, 200),
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Notify mobile that Claude is waiting for input.
|
|
188
|
+
*/
|
|
189
|
+
export async function notifySessionIdle(desktopId, sessionId, { projectName }) {
|
|
190
|
+
await sendPushNotification(
|
|
191
|
+
desktopId,
|
|
192
|
+
{
|
|
193
|
+
title: "Claude is Waiting",
|
|
194
|
+
body: `${projectName}: Claude needs your input`,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
type: "session_idle",
|
|
198
|
+
sessionId,
|
|
199
|
+
projectName: projectName || "",
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}
|
package/src/session-manager.js
CHANGED
|
@@ -15,6 +15,13 @@ import {
|
|
|
15
15
|
stopCapturing,
|
|
16
16
|
hasActiveSimulator,
|
|
17
17
|
} from "./screenshot-manager.js";
|
|
18
|
+
import { postToSlack } from "./webhook-server.js";
|
|
19
|
+
import {
|
|
20
|
+
notifyPermissionRequest,
|
|
21
|
+
notifySessionComplete,
|
|
22
|
+
notifySessionError,
|
|
23
|
+
notifySessionIdle,
|
|
24
|
+
} from "./notifications.js";
|
|
18
25
|
|
|
19
26
|
// ---------------------------------------------------------------------------
|
|
20
27
|
// Resolve the user's shell environment so spawned processes inherit PATH,
|
|
@@ -153,10 +160,15 @@ const activeSessions = new Map();
|
|
|
153
160
|
*/
|
|
154
161
|
const MAX_SESSIONS = parseInt(process.env.FORGE_MAX_SESSIONS, 10) || 3;
|
|
155
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Maximum concurrent sessions allowed via webhook triggers.
|
|
165
|
+
*/
|
|
166
|
+
export const MAX_WEBHOOK_SESSIONS = 5;
|
|
167
|
+
|
|
156
168
|
/**
|
|
157
169
|
* Returns the count of real sessions (excluding command-watcher sentinel keys).
|
|
158
170
|
*/
|
|
159
|
-
function getActiveSessionCount() {
|
|
171
|
+
export function getActiveSessionCount() {
|
|
160
172
|
let count = 0;
|
|
161
173
|
for (const key of activeSessions.keys()) {
|
|
162
174
|
if (!key.startsWith("cmd-watcher-")) count++;
|
|
@@ -458,7 +470,7 @@ export async function startNewSession(desktopId, payload) {
|
|
|
458
470
|
);
|
|
459
471
|
}
|
|
460
472
|
|
|
461
|
-
const { prompt, projectPath, model } = payload || {};
|
|
473
|
+
const { prompt, projectPath, model, webhookMeta } = payload || {};
|
|
462
474
|
const db = getDb();
|
|
463
475
|
const resolvedModel = model || "sonnet";
|
|
464
476
|
const resolvedPath = projectPath || process.cwd();
|
|
@@ -503,6 +515,7 @@ export async function startNewSession(desktopId, payload) {
|
|
|
503
515
|
process: null,
|
|
504
516
|
desktopId,
|
|
505
517
|
projectPath: resolvedPath,
|
|
518
|
+
projectName,
|
|
506
519
|
model: resolvedModel,
|
|
507
520
|
startTime: Date.now(),
|
|
508
521
|
messageCount: 0,
|
|
@@ -511,6 +524,8 @@ export async function startNewSession(desktopId, payload) {
|
|
|
511
524
|
lastToolCall: null, // Last tool_use block (for permission requests)
|
|
512
525
|
permissionNeeded: false, // True when Claude reports permission denial
|
|
513
526
|
permissionWatcher: null, // Firestore unsubscribe for permission doc
|
|
527
|
+
webhookMeta: webhookMeta || null, // Webhook metadata for reply callbacks
|
|
528
|
+
lastAssistantText: "", // Last assistant message text (for webhook replies)
|
|
514
529
|
});
|
|
515
530
|
|
|
516
531
|
// Desktop terminal banner.
|
|
@@ -705,6 +720,13 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
705
720
|
"Process timed out — no output received. Claude CLI may need re-authentication or the model may be unavailable.",
|
|
706
721
|
});
|
|
707
722
|
|
|
723
|
+
// Push notification for timeout error.
|
|
724
|
+
const timeoutSess = activeSessions.get(sessionId);
|
|
725
|
+
notifySessionError(timeoutSess?.desktopId || desktopId, sessionId, {
|
|
726
|
+
projectName: timeoutSess?.projectName || "Unknown",
|
|
727
|
+
errorMessage: "Process timed out — no output received",
|
|
728
|
+
}).catch(() => {});
|
|
729
|
+
|
|
708
730
|
await db
|
|
709
731
|
.collection("sessions")
|
|
710
732
|
.doc(sessionId)
|
|
@@ -862,6 +884,17 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
862
884
|
lastActivity: FieldValue.serverTimestamp(),
|
|
863
885
|
});
|
|
864
886
|
watchPermissionDecision(sessionId, permDocId);
|
|
887
|
+
|
|
888
|
+
// Push notification for permission request.
|
|
889
|
+
notifyPermissionRequest(sess.desktopId, sessionId, {
|
|
890
|
+
toolName: toolForPermission.name || "Unknown tool",
|
|
891
|
+
commandPreview:
|
|
892
|
+
toolForPermission.input?.command ||
|
|
893
|
+
toolForPermission.input?.content ||
|
|
894
|
+
JSON.stringify(toolForPermission.input || {}).slice(0, 200),
|
|
895
|
+
projectName: sess.projectName || "Unknown",
|
|
896
|
+
}).catch(() => {});
|
|
897
|
+
|
|
865
898
|
log.session(
|
|
866
899
|
sessionId,
|
|
867
900
|
"Permission needed — waiting for mobile approval",
|
|
@@ -884,6 +917,19 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
884
917
|
timestamp: FieldValue.serverTimestamp(),
|
|
885
918
|
});
|
|
886
919
|
|
|
920
|
+
// Push notification for idle.
|
|
921
|
+
notifySessionIdle(sess.desktopId, sessionId, {
|
|
922
|
+
projectName: sess.projectName || "Unknown",
|
|
923
|
+
}).catch(() => {});
|
|
924
|
+
|
|
925
|
+
// If this session was triggered by a webhook with a reply URL,
|
|
926
|
+
// post Claude's last response back to the source (e.g., Slack).
|
|
927
|
+
if (sess?.webhookMeta?.replyUrl && sess.lastAssistantText) {
|
|
928
|
+
postToSlack(sess.webhookMeta.replyUrl, sess.lastAssistantText).catch(
|
|
929
|
+
() => {},
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
887
933
|
log.session(
|
|
888
934
|
sessionId,
|
|
889
935
|
"Turn complete — session idle, waiting for input",
|
|
@@ -917,6 +963,12 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
917
963
|
timestamp: FieldValue.serverTimestamp(),
|
|
918
964
|
});
|
|
919
965
|
|
|
966
|
+
// Push notification for error.
|
|
967
|
+
notifySessionError(sess?.desktopId || desktopId, sessionId, {
|
|
968
|
+
projectName: sess?.projectName || "Unknown",
|
|
969
|
+
errorMessage: `Process exited with code ${code}`,
|
|
970
|
+
}).catch(() => {});
|
|
971
|
+
|
|
920
972
|
log.sessionEnded({
|
|
921
973
|
sessionId,
|
|
922
974
|
status: "error",
|
|
@@ -938,6 +990,12 @@ async function runClaudeProcess(sessionId, prompt) {
|
|
|
938
990
|
errorMessage: `Failed to start: ${err.message}`,
|
|
939
991
|
});
|
|
940
992
|
|
|
993
|
+
// Push notification for spawn error.
|
|
994
|
+
notifySessionError(sess?.desktopId || desktopId, sessionId, {
|
|
995
|
+
projectName: sess?.projectName || "Unknown",
|
|
996
|
+
errorMessage: `Failed to start: ${err.message}`,
|
|
997
|
+
}).catch(() => {});
|
|
998
|
+
|
|
941
999
|
await db
|
|
942
1000
|
.collection("sessions")
|
|
943
1001
|
.doc(sessionId)
|
|
@@ -1005,6 +1063,13 @@ async function stopSession(sessionId) {
|
|
|
1005
1063
|
timestamp: FieldValue.serverTimestamp(),
|
|
1006
1064
|
});
|
|
1007
1065
|
|
|
1066
|
+
// Push notification for session completion.
|
|
1067
|
+
if (session?.desktopId) {
|
|
1068
|
+
notifySessionComplete(session.desktopId, sessionId, {
|
|
1069
|
+
projectName: session.projectName || "Unknown",
|
|
1070
|
+
}).catch(() => {});
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1008
1073
|
log.sessionEnded({
|
|
1009
1074
|
sessionId,
|
|
1010
1075
|
status: "completed",
|
|
@@ -1263,7 +1328,10 @@ const PERMISSION_PATTERNS = [
|
|
|
1263
1328
|
async function storeAssistantMessage(sessionId, text) {
|
|
1264
1329
|
const db = getDb();
|
|
1265
1330
|
const session = activeSessions.get(sessionId);
|
|
1266
|
-
if (session)
|
|
1331
|
+
if (session) {
|
|
1332
|
+
session.messageCount = (session.messageCount || 0) + 1;
|
|
1333
|
+
session.lastAssistantText = text; // Track for webhook reply callbacks
|
|
1334
|
+
}
|
|
1267
1335
|
|
|
1268
1336
|
await db.collection("sessions").doc(sessionId).collection("messages").add({
|
|
1269
1337
|
type: "assistant",
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks npm registry for newer versions and warns the user.
|
|
3
|
+
*
|
|
4
|
+
* Runs non-blocking — never delays startup if the check fails or times out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "fs";
|
|
8
|
+
import { join, dirname } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import * as log from "./logger.js";
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
/** Read the local package version from package.json. */
|
|
15
|
+
function getLocalVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const pkg = JSON.parse(
|
|
18
|
+
readFileSync(join(__dirname, "..", "package.json"), "utf-8"),
|
|
19
|
+
);
|
|
20
|
+
return pkg.version;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compare two semver strings. Returns true if remote is newer than local.
|
|
28
|
+
*/
|
|
29
|
+
function isNewer(local, remote) {
|
|
30
|
+
const parse = (v) => v.split(".").map(Number);
|
|
31
|
+
const [lMaj, lMin, lPat] = parse(local);
|
|
32
|
+
const [rMaj, rMin, rPat] = parse(remote);
|
|
33
|
+
if (rMaj !== lMaj) return rMaj > lMaj;
|
|
34
|
+
if (rMin !== lMin) return rMin > lMin;
|
|
35
|
+
return rPat > lPat;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check npm for a newer version. Logs a warning if one is found.
|
|
40
|
+
* Never throws — silently returns on any failure.
|
|
41
|
+
*/
|
|
42
|
+
export async function checkForUpdate() {
|
|
43
|
+
const localVersion = getLocalVersion();
|
|
44
|
+
if (!localVersion) return;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const controller = new AbortController();
|
|
48
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
49
|
+
|
|
50
|
+
const res = await fetch("https://registry.npmjs.org/forge-remote/latest", {
|
|
51
|
+
signal: controller.signal,
|
|
52
|
+
});
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
|
|
55
|
+
if (!res.ok) return;
|
|
56
|
+
|
|
57
|
+
const data = await res.json();
|
|
58
|
+
const remoteVersion = data.version;
|
|
59
|
+
|
|
60
|
+
if (remoteVersion && isNewer(localVersion, remoteVersion)) {
|
|
61
|
+
console.log();
|
|
62
|
+
log.warn(
|
|
63
|
+
`A new version of forge-remote is available: ${localVersion} → ${remoteVersion}`,
|
|
64
|
+
);
|
|
65
|
+
log.warn(" Run: npm update -g forge-remote");
|
|
66
|
+
log.warn(" Then: forge-remote init (to update Firestore rules)");
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// Network error, timeout, etc. — don't bother the user.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Forge Remote Relay — Webhook Config Watcher
|
|
2
|
+
// Copyright (c) 2025-2026 Iron Forge Apps
|
|
3
|
+
// Created by Daniel Wendel, CEO/Founder of Iron Forge Apps
|
|
4
|
+
|
|
5
|
+
import { getDb } from "./firebase.js";
|
|
6
|
+
import * as log from "./logger.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* In-memory cache of webhook configurations.
|
|
10
|
+
* Map<webhookId, config>
|
|
11
|
+
*/
|
|
12
|
+
const webhookConfigs = new Map();
|
|
13
|
+
|
|
14
|
+
/** Firestore unsubscribe function. */
|
|
15
|
+
let unsubscribe = null;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Watch Firestore for webhook config changes and maintain an in-memory cache.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} desktopId
|
|
21
|
+
* @param {string} tunnelBaseUrl — Public tunnel URL to build webhook URLs from
|
|
22
|
+
*/
|
|
23
|
+
export function watchWebhookConfigs(desktopId, tunnelBaseUrl) {
|
|
24
|
+
const db = getDb();
|
|
25
|
+
const webhooksRef = db
|
|
26
|
+
.collection("desktops")
|
|
27
|
+
.doc(desktopId)
|
|
28
|
+
.collection("webhooks");
|
|
29
|
+
|
|
30
|
+
unsubscribe = webhooksRef.onSnapshot(
|
|
31
|
+
(snap) => {
|
|
32
|
+
for (const change of snap.docChanges()) {
|
|
33
|
+
const id = change.doc.id;
|
|
34
|
+
const data = change.doc.data();
|
|
35
|
+
|
|
36
|
+
if (change.type === "removed") {
|
|
37
|
+
webhookConfigs.delete(id);
|
|
38
|
+
log.info(`Webhook removed: ${id}`);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// added or modified
|
|
43
|
+
webhookConfigs.set(id, data);
|
|
44
|
+
|
|
45
|
+
if (change.type === "added") {
|
|
46
|
+
log.info(
|
|
47
|
+
`Webhook registered: ${id} (source: ${data.source || "custom"}, enabled: ${data.enabled !== false})`,
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
log.info(`Webhook updated: ${id}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// If the webhook doc doesn't have a webhookUrl yet, write it back.
|
|
54
|
+
if (!data.webhookUrl) {
|
|
55
|
+
const webhookUrl = `${tunnelBaseUrl}/hooks/${id}`;
|
|
56
|
+
webhooksRef
|
|
57
|
+
.doc(id)
|
|
58
|
+
.update({ webhookUrl })
|
|
59
|
+
.then(() => {
|
|
60
|
+
log.info(`Wrote webhook URL for ${id}: ${webhookUrl}`);
|
|
61
|
+
})
|
|
62
|
+
.catch((err) => {
|
|
63
|
+
log.error(
|
|
64
|
+
`Failed to write webhook URL for ${id}: ${err.message}`,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
(err) => {
|
|
71
|
+
log.error(`Webhook watcher error: ${err.message}`);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
log.info(`Watching webhook configs for desktop ${desktopId}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Get a webhook config from the in-memory cache.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} webhookId
|
|
82
|
+
* @returns {object|null}
|
|
83
|
+
*/
|
|
84
|
+
export function getWebhookConfig(webhookId) {
|
|
85
|
+
return webhookConfigs.get(webhookId) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get the count of enabled webhooks.
|
|
90
|
+
*
|
|
91
|
+
* @returns {number}
|
|
92
|
+
*/
|
|
93
|
+
export function getActiveWebhookCount() {
|
|
94
|
+
let count = 0;
|
|
95
|
+
for (const config of webhookConfigs.values()) {
|
|
96
|
+
if (config.enabled !== false) count++;
|
|
97
|
+
}
|
|
98
|
+
return count;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Stop watching Firestore for webhook changes.
|
|
103
|
+
*/
|
|
104
|
+
export function stopWatching() {
|
|
105
|
+
if (unsubscribe) {
|
|
106
|
+
unsubscribe();
|
|
107
|
+
unsubscribe = null;
|
|
108
|
+
log.info("Stopped watching webhook configs");
|
|
109
|
+
}
|
|
110
|
+
webhookConfigs.clear();
|
|
111
|
+
}
|