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 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.14",
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
- "repository": {
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
+ }
@@ -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) session.messageCount = (session.messageCount || 0) + 1;
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
+ }