@chrysb/alphaclaw 0.8.1-beta.0 → 0.8.1-beta.2
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/lib/public/js/components/routes/webhooks-route.js +1 -1
- package/lib/public/js/components/webhooks/create-webhook-modal/index.js +176 -0
- package/lib/public/js/components/webhooks/helpers.js +106 -0
- package/lib/public/js/components/webhooks/index.js +148 -0
- package/lib/public/js/components/webhooks/request-history/index.js +241 -0
- package/lib/public/js/components/webhooks/request-history/use-request-history.js +167 -0
- package/lib/public/js/components/webhooks/webhook-detail/index.js +386 -0
- package/lib/public/js/components/webhooks/webhook-detail/use-webhook-detail.js +277 -0
- package/lib/public/js/components/webhooks/webhook-list/index.js +96 -0
- package/lib/public/js/components/webhooks/webhook-list/use-webhook-list.js +30 -0
- package/lib/public/js/lib/api.js +35 -1
- package/lib/server/db/webhooks/index.js +144 -0
- package/lib/server/db/webhooks/schema.js +13 -0
- package/lib/server/init/register-server-routes.js +19 -0
- package/lib/server/oauth-callback-middleware.js +34 -0
- package/lib/server/routes/proxy.js +2 -0
- package/lib/server/routes/webhooks.js +126 -18
- package/lib/server/webhook-middleware.js +6 -1
- package/lib/server.js +12 -0
- package/package.json +1 -1
- package/lib/public/js/components/webhooks.js +0 -1259
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from "https://esm.sh/preact/hooks";
|
|
2
|
+
import {
|
|
3
|
+
deleteWebhook,
|
|
4
|
+
fetchAgents,
|
|
5
|
+
fetchWebhookDetail,
|
|
6
|
+
rotateWebhookOauthCallback,
|
|
7
|
+
} from "../../../lib/api.js";
|
|
8
|
+
import { useCachedFetch } from "../../../hooks/use-cached-fetch.js";
|
|
9
|
+
import { showToast } from "../../toast.js";
|
|
10
|
+
import { formatAgentFallbackName } from "../helpers.js";
|
|
11
|
+
|
|
12
|
+
export const useWebhookDetail = ({
|
|
13
|
+
selectedHookName = "",
|
|
14
|
+
onBackToList = () => {},
|
|
15
|
+
onRestartRequired = () => {},
|
|
16
|
+
}) => {
|
|
17
|
+
const [authMode, setAuthMode] = useState("headers");
|
|
18
|
+
const [deleting, setDeleting] = useState(false);
|
|
19
|
+
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
20
|
+
const [deleteTransformDir, setDeleteTransformDir] = useState(true);
|
|
21
|
+
const [rotatingOauthCallback, setRotatingOauthCallback] = useState(false);
|
|
22
|
+
const [showRotateOauthConfirm, setShowRotateOauthConfirm] = useState(false);
|
|
23
|
+
const [sendingTestWebhook, setSendingTestWebhook] = useState(false);
|
|
24
|
+
|
|
25
|
+
const detailCacheKey = useMemo(
|
|
26
|
+
() => `/api/webhooks/${encodeURIComponent(String(selectedHookName || ""))}`,
|
|
27
|
+
[selectedHookName],
|
|
28
|
+
);
|
|
29
|
+
const detailFetchState = useCachedFetch(
|
|
30
|
+
detailCacheKey,
|
|
31
|
+
async () => {
|
|
32
|
+
if (!selectedHookName) return null;
|
|
33
|
+
const data = await fetchWebhookDetail(selectedHookName);
|
|
34
|
+
return data.webhook || null;
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
enabled: !!selectedHookName,
|
|
38
|
+
maxAgeMs: 15000,
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
const agentsFetchState = useCachedFetch("/api/agents", fetchAgents, {
|
|
42
|
+
enabled: true,
|
|
43
|
+
maxAgeMs: 30000,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const agents = Array.isArray(agentsFetchState.data?.agents)
|
|
47
|
+
? agentsFetchState.data.agents
|
|
48
|
+
: [];
|
|
49
|
+
const agentNameById = useMemo(
|
|
50
|
+
() =>
|
|
51
|
+
new Map(
|
|
52
|
+
agents.map((agent) => [
|
|
53
|
+
String(agent?.id || "").trim(),
|
|
54
|
+
String(agent?.name || "").trim() || formatAgentFallbackName(agent?.id),
|
|
55
|
+
]),
|
|
56
|
+
),
|
|
57
|
+
[agents],
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const selectedWebhook = detailFetchState.data;
|
|
61
|
+
const isWebhookLoading = !!selectedHookName && detailFetchState.loading;
|
|
62
|
+
const webhookLoadError = detailFetchState.error;
|
|
63
|
+
const selectedWebhookManaged = Boolean(selectedWebhook?.managed);
|
|
64
|
+
const selectedDeliveryAgentId =
|
|
65
|
+
String(selectedWebhook?.agentId || "main").trim() || "main";
|
|
66
|
+
const selectedDeliveryAgentName =
|
|
67
|
+
agentNameById.get(selectedDeliveryAgentId) ||
|
|
68
|
+
formatAgentFallbackName(selectedDeliveryAgentId);
|
|
69
|
+
const selectedDeliveryChannel =
|
|
70
|
+
String(selectedWebhook?.channel || "last").trim() || "last";
|
|
71
|
+
|
|
72
|
+
const webhookUrl = selectedWebhook?.fullUrl || `.../hooks/${selectedHookName}`;
|
|
73
|
+
const oauthCallbackUrl = String(selectedWebhook?.oauthCallbackUrl || "").trim();
|
|
74
|
+
const hasOauthCallback = !!oauthCallbackUrl;
|
|
75
|
+
const webhookUrlWithQueryToken =
|
|
76
|
+
selectedWebhook?.queryStringUrl ||
|
|
77
|
+
`${webhookUrl}${webhookUrl.includes("?") ? "&" : "?"}token=<WEBHOOK_TOKEN>`;
|
|
78
|
+
|
|
79
|
+
const derivedTokenFromQuery = useMemo(() => {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(webhookUrlWithQueryToken);
|
|
82
|
+
return String(parsed.searchParams.get("token") || "").trim();
|
|
83
|
+
} catch {
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
}, [webhookUrlWithQueryToken]);
|
|
87
|
+
|
|
88
|
+
const authHeaderValue =
|
|
89
|
+
selectedWebhook?.authHeaderValue ||
|
|
90
|
+
(derivedTokenFromQuery
|
|
91
|
+
? `Authorization: Bearer ${derivedTokenFromQuery}`
|
|
92
|
+
: "Authorization: Bearer <WEBHOOK_TOKEN>");
|
|
93
|
+
const bearerTokenValue = authHeaderValue.startsWith("Authorization: ")
|
|
94
|
+
? authHeaderValue.slice("Authorization: ".length)
|
|
95
|
+
: authHeaderValue;
|
|
96
|
+
|
|
97
|
+
const webhookTestPayload = useMemo(() => {
|
|
98
|
+
if (
|
|
99
|
+
String(selectedHookName || "")
|
|
100
|
+
.trim()
|
|
101
|
+
.toLowerCase() === "gmail"
|
|
102
|
+
) {
|
|
103
|
+
return {
|
|
104
|
+
payload: {
|
|
105
|
+
account: "test@gmail.com",
|
|
106
|
+
messages: [
|
|
107
|
+
{
|
|
108
|
+
id: "test-message-1",
|
|
109
|
+
from: "alerts@example.com",
|
|
110
|
+
to: ["test@gmail.com"],
|
|
111
|
+
subject: "Test Gmail webhook event",
|
|
112
|
+
snippet:
|
|
113
|
+
"This is a simulated Gmail message payload for webhook testing.",
|
|
114
|
+
receivedAt: new Date().toISOString(),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
source: "manual-test",
|
|
122
|
+
message: `This is a test of the ${selectedHookName || "webhook"} webhook.`,
|
|
123
|
+
};
|
|
124
|
+
}, [selectedHookName]);
|
|
125
|
+
|
|
126
|
+
const webhookTestPayloadJson = JSON.stringify(webhookTestPayload);
|
|
127
|
+
const curlCommandHeaders =
|
|
128
|
+
`curl -X POST "${webhookUrl}" ` +
|
|
129
|
+
`-H "Content-Type: application/json" ` +
|
|
130
|
+
`-H "${authHeaderValue}" ` +
|
|
131
|
+
`-d '${webhookTestPayloadJson}'`;
|
|
132
|
+
const curlCommandQuery =
|
|
133
|
+
`curl -X POST "${webhookUrlWithQueryToken}" ` +
|
|
134
|
+
`-H "Content-Type: application/json" ` +
|
|
135
|
+
`-d '${webhookTestPayloadJson}'`;
|
|
136
|
+
|
|
137
|
+
const effectiveAuthMode = selectedWebhookManaged ? "headers" : authMode;
|
|
138
|
+
const activeCurlCommand =
|
|
139
|
+
effectiveAuthMode === "query" ? curlCommandQuery : curlCommandHeaders;
|
|
140
|
+
|
|
141
|
+
const refreshDetail = useCallback(() => {
|
|
142
|
+
detailFetchState.refresh({ force: true });
|
|
143
|
+
agentsFetchState.refresh({ force: true });
|
|
144
|
+
}, [agentsFetchState.refresh, detailFetchState.refresh]);
|
|
145
|
+
|
|
146
|
+
const handleSendTestWebhook = useCallback(async () => {
|
|
147
|
+
if (!selectedHookName || sendingTestWebhook) return;
|
|
148
|
+
setSendingTestWebhook(true);
|
|
149
|
+
const requestUrl =
|
|
150
|
+
effectiveAuthMode === "query" ? webhookUrlWithQueryToken : webhookUrl;
|
|
151
|
+
const headers = { "Content-Type": "application/json" };
|
|
152
|
+
if (effectiveAuthMode === "headers") {
|
|
153
|
+
headers.Authorization = bearerTokenValue;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetch(requestUrl, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers,
|
|
159
|
+
body: webhookTestPayloadJson,
|
|
160
|
+
});
|
|
161
|
+
const bodyText = await response.text();
|
|
162
|
+
let body = null;
|
|
163
|
+
try {
|
|
164
|
+
body = bodyText ? JSON.parse(bodyText) : null;
|
|
165
|
+
} catch {
|
|
166
|
+
body = null;
|
|
167
|
+
}
|
|
168
|
+
const errorMessage =
|
|
169
|
+
body?.ok === false
|
|
170
|
+
? body?.error || "Webhook rejected"
|
|
171
|
+
: !response.ok
|
|
172
|
+
? body?.error || bodyText || `HTTP ${response.status}`
|
|
173
|
+
: "";
|
|
174
|
+
if (errorMessage) {
|
|
175
|
+
showToast(`Test webhook failed: ${errorMessage}`, "error");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
showToast("Test webhook sent", "success");
|
|
179
|
+
} catch (err) {
|
|
180
|
+
showToast(err.message || "Could not send test webhook", "error");
|
|
181
|
+
} finally {
|
|
182
|
+
setSendingTestWebhook(false);
|
|
183
|
+
}
|
|
184
|
+
}, [
|
|
185
|
+
bearerTokenValue,
|
|
186
|
+
effectiveAuthMode,
|
|
187
|
+
selectedHookName,
|
|
188
|
+
sendingTestWebhook,
|
|
189
|
+
webhookTestPayloadJson,
|
|
190
|
+
webhookUrl,
|
|
191
|
+
webhookUrlWithQueryToken,
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const handleDeleteConfirmed = useCallback(async () => {
|
|
195
|
+
if (!selectedHookName || deleting) return;
|
|
196
|
+
setDeleting(true);
|
|
197
|
+
try {
|
|
198
|
+
const data = await deleteWebhook(selectedHookName, {
|
|
199
|
+
deleteTransformDir,
|
|
200
|
+
});
|
|
201
|
+
if (data.restartRequired) onRestartRequired(true);
|
|
202
|
+
onBackToList();
|
|
203
|
+
setShowDeleteConfirm(false);
|
|
204
|
+
setDeleteTransformDir(true);
|
|
205
|
+
showToast("Webhook removed", "success");
|
|
206
|
+
if (data.deletedTransformDir) {
|
|
207
|
+
showToast("Transform directory deleted", "success");
|
|
208
|
+
}
|
|
209
|
+
if (data.syncWarning) {
|
|
210
|
+
showToast(`Deleted, but git-sync failed: ${data.syncWarning}`, "warning");
|
|
211
|
+
}
|
|
212
|
+
refreshDetail();
|
|
213
|
+
} catch (err) {
|
|
214
|
+
showToast(err.message || "Could not delete webhook", "error");
|
|
215
|
+
} finally {
|
|
216
|
+
setDeleting(false);
|
|
217
|
+
}
|
|
218
|
+
}, [
|
|
219
|
+
deleteTransformDir,
|
|
220
|
+
deleting,
|
|
221
|
+
onBackToList,
|
|
222
|
+
onRestartRequired,
|
|
223
|
+
refreshDetail,
|
|
224
|
+
selectedHookName,
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const handleRotateOauthCallback = useCallback(async () => {
|
|
228
|
+
if (!selectedHookName || rotatingOauthCallback) return;
|
|
229
|
+
setRotatingOauthCallback(true);
|
|
230
|
+
try {
|
|
231
|
+
await rotateWebhookOauthCallback(selectedHookName);
|
|
232
|
+
showToast("OAuth callback rotated", "success");
|
|
233
|
+
setShowRotateOauthConfirm(false);
|
|
234
|
+
refreshDetail();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
showToast(err.message || "Could not rotate OAuth callback", "error");
|
|
237
|
+
} finally {
|
|
238
|
+
setRotatingOauthCallback(false);
|
|
239
|
+
}
|
|
240
|
+
}, [refreshDetail, rotatingOauthCallback, selectedHookName]);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
state: {
|
|
244
|
+
authMode,
|
|
245
|
+
selectedWebhook,
|
|
246
|
+
isWebhookLoading,
|
|
247
|
+
webhookLoadError,
|
|
248
|
+
selectedWebhookManaged,
|
|
249
|
+
selectedDeliveryAgentName,
|
|
250
|
+
selectedDeliveryChannel,
|
|
251
|
+
webhookUrl,
|
|
252
|
+
oauthCallbackUrl,
|
|
253
|
+
hasOauthCallback,
|
|
254
|
+
webhookUrlWithQueryToken,
|
|
255
|
+
authHeaderValue,
|
|
256
|
+
bearerTokenValue,
|
|
257
|
+
effectiveAuthMode,
|
|
258
|
+
activeCurlCommand,
|
|
259
|
+
deleting,
|
|
260
|
+
showDeleteConfirm,
|
|
261
|
+
deleteTransformDir,
|
|
262
|
+
rotatingOauthCallback,
|
|
263
|
+
showRotateOauthConfirm,
|
|
264
|
+
sendingTestWebhook,
|
|
265
|
+
},
|
|
266
|
+
actions: {
|
|
267
|
+
refreshDetail,
|
|
268
|
+
setAuthMode,
|
|
269
|
+
setShowDeleteConfirm,
|
|
270
|
+
setDeleteTransformDir,
|
|
271
|
+
setShowRotateOauthConfirm,
|
|
272
|
+
handleDeleteConfirmed,
|
|
273
|
+
handleRotateOauthCallback,
|
|
274
|
+
handleSendTestWebhook,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import htm from "https://esm.sh/htm";
|
|
3
|
+
import { Badge } from "../../badge.js";
|
|
4
|
+
import { formatLastReceived, healthClassName } from "../helpers.js";
|
|
5
|
+
import { useWebhookList } from "./use-webhook-list.js";
|
|
6
|
+
|
|
7
|
+
const html = htm.bind(h);
|
|
8
|
+
|
|
9
|
+
export const WebhookList = ({
|
|
10
|
+
onSelectHook = () => {},
|
|
11
|
+
}) => {
|
|
12
|
+
const { state, actions } = useWebhookList({ onSelectHook });
|
|
13
|
+
|
|
14
|
+
const { webhooks, isListLoading } = state;
|
|
15
|
+
|
|
16
|
+
return html`
|
|
17
|
+
<div class="bg-surface border border-border rounded-xl p-4 space-y-4">
|
|
18
|
+
${isListLoading
|
|
19
|
+
? html`<p class="text-xs text-gray-500">Loading webhooks...</p>`
|
|
20
|
+
: null}
|
|
21
|
+
${!isListLoading && webhooks.length === 0
|
|
22
|
+
? html`<p class="text-sm text-gray-500">
|
|
23
|
+
No webhooks configured yet. Create one to get started.
|
|
24
|
+
</p>`
|
|
25
|
+
: null}
|
|
26
|
+
${webhooks.length > 0
|
|
27
|
+
? html`
|
|
28
|
+
<div class="overflow-auto">
|
|
29
|
+
<table class="w-full text-sm">
|
|
30
|
+
<thead>
|
|
31
|
+
<tr class="text-left text-xs text-gray-500 border-b border-border">
|
|
32
|
+
<th class="pb-2 pr-3">Path</th>
|
|
33
|
+
<th class="pb-2 pr-3">Last received</th>
|
|
34
|
+
<th class="pb-2 pr-3">Errors</th>
|
|
35
|
+
<th class="pb-2 pr-3">Health</th>
|
|
36
|
+
<th class="pb-2 pr-3">Type</th>
|
|
37
|
+
</tr>
|
|
38
|
+
</thead>
|
|
39
|
+
<tbody>
|
|
40
|
+
<tr aria-hidden="true">
|
|
41
|
+
<td class="h-2 p-0" colspan="5"></td>
|
|
42
|
+
</tr>
|
|
43
|
+
${webhooks.map(
|
|
44
|
+
(item) => html`
|
|
45
|
+
<tr
|
|
46
|
+
class="group cursor-pointer"
|
|
47
|
+
onclick=${() => actions.handleSelectHook(item.name)}
|
|
48
|
+
>
|
|
49
|
+
<td
|
|
50
|
+
class="px-3 py-2.5 group-hover:bg-white/5 first:rounded-l-lg transition-colors"
|
|
51
|
+
>
|
|
52
|
+
<code>${item.path || `/hooks/${item.name}`}</code>
|
|
53
|
+
</td>
|
|
54
|
+
<td
|
|
55
|
+
class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
|
|
56
|
+
>
|
|
57
|
+
${formatLastReceived(item.lastReceived)}
|
|
58
|
+
</td>
|
|
59
|
+
<td
|
|
60
|
+
class="px-3 py-2.5 text-xs group-hover:bg-white/5 transition-colors"
|
|
61
|
+
>
|
|
62
|
+
${item.errorCount || 0}
|
|
63
|
+
</td>
|
|
64
|
+
<td
|
|
65
|
+
class="px-3 py-2.5 group-hover:bg-white/5 last:rounded-r-lg transition-colors"
|
|
66
|
+
>
|
|
67
|
+
<span
|
|
68
|
+
class="inline-block w-2.5 h-2.5 rounded-full ${healthClassName(
|
|
69
|
+
item.health,
|
|
70
|
+
)}"
|
|
71
|
+
title=${item.health}
|
|
72
|
+
/>
|
|
73
|
+
</td>
|
|
74
|
+
<td
|
|
75
|
+
class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
|
|
76
|
+
>
|
|
77
|
+
${item.managed
|
|
78
|
+
? html`<span
|
|
79
|
+
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] bg-cyan-500/10 text-cyan-200"
|
|
80
|
+
>Managed</span
|
|
81
|
+
>`
|
|
82
|
+
: item.oauthCallbackEnabled
|
|
83
|
+
? html`<${Badge} tone="neutral">OAuth</${Badge}>`
|
|
84
|
+
: html`<${Badge} tone="neutral">Custom</${Badge}>`}
|
|
85
|
+
</td>
|
|
86
|
+
</tr>
|
|
87
|
+
`,
|
|
88
|
+
)}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
</div>
|
|
92
|
+
`
|
|
93
|
+
: null}
|
|
94
|
+
</div>
|
|
95
|
+
`;
|
|
96
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useCallback } from "https://esm.sh/preact/hooks";
|
|
2
|
+
import { usePolling } from "../../../hooks/usePolling.js";
|
|
3
|
+
import { fetchWebhooks } from "../../../lib/api.js";
|
|
4
|
+
|
|
5
|
+
export const useWebhookList = ({
|
|
6
|
+
onSelectHook = () => {},
|
|
7
|
+
}) => {
|
|
8
|
+
const listPoll = usePolling(fetchWebhooks, 15000);
|
|
9
|
+
|
|
10
|
+
const webhooks = listPoll.data?.webhooks || [];
|
|
11
|
+
const isListLoading = !listPoll.data && !listPoll.error;
|
|
12
|
+
|
|
13
|
+
const handleSelectHook = useCallback(
|
|
14
|
+
(name) => {
|
|
15
|
+
onSelectHook(name);
|
|
16
|
+
},
|
|
17
|
+
[onSelectHook],
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
state: {
|
|
22
|
+
webhooks,
|
|
23
|
+
isListLoading,
|
|
24
|
+
},
|
|
25
|
+
actions: {
|
|
26
|
+
refreshList: listPoll.refresh,
|
|
27
|
+
handleSelectHook,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
};
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -1133,13 +1133,17 @@ export async function fetchWebhookDetail(name) {
|
|
|
1133
1133
|
return parseJsonOrThrow(res, "Could not load webhook detail");
|
|
1134
1134
|
}
|
|
1135
1135
|
|
|
1136
|
-
export async function createWebhook(
|
|
1136
|
+
export async function createWebhook(
|
|
1137
|
+
name,
|
|
1138
|
+
{ destination = null, oauthCallback = false } = {},
|
|
1139
|
+
) {
|
|
1137
1140
|
const res = await authFetch("/api/webhooks", {
|
|
1138
1141
|
method: "POST",
|
|
1139
1142
|
headers: { "Content-Type": "application/json" },
|
|
1140
1143
|
body: JSON.stringify({
|
|
1141
1144
|
name,
|
|
1142
1145
|
...(destination ? { destination } : {}),
|
|
1146
|
+
oauthCallback: !!oauthCallback,
|
|
1143
1147
|
}),
|
|
1144
1148
|
});
|
|
1145
1149
|
return parseJsonOrThrow(res, "Could not create webhook");
|
|
@@ -1154,6 +1158,36 @@ export async function deleteWebhook(name, { deleteTransformDir = false } = {}) {
|
|
|
1154
1158
|
return parseJsonOrThrow(res, "Could not delete webhook");
|
|
1155
1159
|
}
|
|
1156
1160
|
|
|
1161
|
+
export async function createWebhookOauthCallback(name) {
|
|
1162
|
+
const res = await authFetch(
|
|
1163
|
+
`/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,
|
|
1164
|
+
{
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
},
|
|
1167
|
+
);
|
|
1168
|
+
return parseJsonOrThrow(res, "Could not enable OAuth callback");
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export async function rotateWebhookOauthCallback(name) {
|
|
1172
|
+
const res = await authFetch(
|
|
1173
|
+
`/api/webhooks/${encodeURIComponent(name)}/oauth-callback/rotate`,
|
|
1174
|
+
{
|
|
1175
|
+
method: "POST",
|
|
1176
|
+
},
|
|
1177
|
+
);
|
|
1178
|
+
return parseJsonOrThrow(res, "Could not rotate OAuth callback");
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
export async function deleteWebhookOauthCallback(name) {
|
|
1182
|
+
const res = await authFetch(
|
|
1183
|
+
`/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,
|
|
1184
|
+
{
|
|
1185
|
+
method: "DELETE",
|
|
1186
|
+
},
|
|
1187
|
+
);
|
|
1188
|
+
return parseJsonOrThrow(res, "Could not delete OAuth callback");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1157
1191
|
export async function fetchWebhookRequests(
|
|
1158
1192
|
name,
|
|
1159
1193
|
{ limit = 50, offset = 0, status = "all" } = {},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
|
+
const crypto = require("crypto");
|
|
3
4
|
const { DatabaseSync } = require("node:sqlite");
|
|
4
5
|
const { createSchema } = require("./schema");
|
|
5
6
|
|
|
@@ -43,6 +44,19 @@ const parseJsonText = (value) => {
|
|
|
43
44
|
}
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
const generateOauthCallbackId = () => crypto.randomBytes(16).toString("hex");
|
|
48
|
+
|
|
49
|
+
const toOauthCallbackModel = (row) => {
|
|
50
|
+
if (!row) return null;
|
|
51
|
+
return {
|
|
52
|
+
callbackId: String(row.callback_id || ""),
|
|
53
|
+
hookName: String(row.hook_name || ""),
|
|
54
|
+
createdAt: row.created_at || null,
|
|
55
|
+
rotatedAt: row.rotated_at || null,
|
|
56
|
+
lastUsedAt: row.last_used_at || null,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
46
60
|
const toRequestModel = (row) => {
|
|
47
61
|
if (!row) return null;
|
|
48
62
|
return {
|
|
@@ -220,6 +234,130 @@ const deleteRequestsByHook = (hookName) => {
|
|
|
220
234
|
return Number(result.changes || 0);
|
|
221
235
|
};
|
|
222
236
|
|
|
237
|
+
const createOauthCallback = ({ hookName }) => {
|
|
238
|
+
const database = ensureDb();
|
|
239
|
+
const normalizedHookName = String(hookName || "").trim();
|
|
240
|
+
if (!normalizedHookName) throw new Error("hookName is required");
|
|
241
|
+
const callbackId = generateOauthCallbackId();
|
|
242
|
+
database
|
|
243
|
+
.prepare(`
|
|
244
|
+
INSERT INTO oauth_callbacks (
|
|
245
|
+
callback_id,
|
|
246
|
+
hook_name
|
|
247
|
+
) VALUES (
|
|
248
|
+
$callback_id,
|
|
249
|
+
$hook_name
|
|
250
|
+
)
|
|
251
|
+
`)
|
|
252
|
+
.run({
|
|
253
|
+
$callback_id: callbackId,
|
|
254
|
+
$hook_name: normalizedHookName,
|
|
255
|
+
});
|
|
256
|
+
const inserted = database
|
|
257
|
+
.prepare(`
|
|
258
|
+
SELECT
|
|
259
|
+
callback_id,
|
|
260
|
+
hook_name,
|
|
261
|
+
created_at,
|
|
262
|
+
rotated_at,
|
|
263
|
+
last_used_at
|
|
264
|
+
FROM oauth_callbacks
|
|
265
|
+
WHERE callback_id = $callback_id
|
|
266
|
+
LIMIT 1
|
|
267
|
+
`)
|
|
268
|
+
.get({ $callback_id: callbackId });
|
|
269
|
+
return toOauthCallbackModel(inserted);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const getOauthCallbackByHook = (hookName) => {
|
|
273
|
+
const database = ensureDb();
|
|
274
|
+
const normalizedHookName = String(hookName || "").trim();
|
|
275
|
+
if (!normalizedHookName) return null;
|
|
276
|
+
const row = database
|
|
277
|
+
.prepare(`
|
|
278
|
+
SELECT
|
|
279
|
+
callback_id,
|
|
280
|
+
hook_name,
|
|
281
|
+
created_at,
|
|
282
|
+
rotated_at,
|
|
283
|
+
last_used_at
|
|
284
|
+
FROM oauth_callbacks
|
|
285
|
+
WHERE hook_name = $hook_name
|
|
286
|
+
LIMIT 1
|
|
287
|
+
`)
|
|
288
|
+
.get({ $hook_name: normalizedHookName });
|
|
289
|
+
return toOauthCallbackModel(row);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const getOauthCallbackById = (callbackId) => {
|
|
293
|
+
const database = ensureDb();
|
|
294
|
+
const normalizedCallbackId = String(callbackId || "").trim();
|
|
295
|
+
if (!normalizedCallbackId) return null;
|
|
296
|
+
const row = database
|
|
297
|
+
.prepare(`
|
|
298
|
+
SELECT
|
|
299
|
+
callback_id,
|
|
300
|
+
hook_name,
|
|
301
|
+
created_at,
|
|
302
|
+
rotated_at,
|
|
303
|
+
last_used_at
|
|
304
|
+
FROM oauth_callbacks
|
|
305
|
+
WHERE callback_id = $callback_id
|
|
306
|
+
LIMIT 1
|
|
307
|
+
`)
|
|
308
|
+
.get({ $callback_id: normalizedCallbackId });
|
|
309
|
+
return toOauthCallbackModel(row);
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const rotateOauthCallback = (hookName) => {
|
|
313
|
+
const database = ensureDb();
|
|
314
|
+
const normalizedHookName = String(hookName || "").trim();
|
|
315
|
+
if (!normalizedHookName) throw new Error("hookName is required");
|
|
316
|
+
const existing = getOauthCallbackByHook(normalizedHookName);
|
|
317
|
+
if (!existing) throw new Error("OAuth callback not found");
|
|
318
|
+
const nextCallbackId = generateOauthCallbackId();
|
|
319
|
+
database
|
|
320
|
+
.prepare(`
|
|
321
|
+
UPDATE oauth_callbacks
|
|
322
|
+
SET
|
|
323
|
+
callback_id = $callback_id,
|
|
324
|
+
rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
325
|
+
WHERE hook_name = $hook_name
|
|
326
|
+
`)
|
|
327
|
+
.run({
|
|
328
|
+
$callback_id: nextCallbackId,
|
|
329
|
+
$hook_name: normalizedHookName,
|
|
330
|
+
});
|
|
331
|
+
return getOauthCallbackById(nextCallbackId);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const deleteOauthCallback = (hookName) => {
|
|
335
|
+
const database = ensureDb();
|
|
336
|
+
const normalizedHookName = String(hookName || "").trim();
|
|
337
|
+
if (!normalizedHookName) return 0;
|
|
338
|
+
const result = database
|
|
339
|
+
.prepare(`
|
|
340
|
+
DELETE FROM oauth_callbacks
|
|
341
|
+
WHERE hook_name = $hook_name
|
|
342
|
+
`)
|
|
343
|
+
.run({ $hook_name: normalizedHookName });
|
|
344
|
+
return Number(result.changes || 0);
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const markOauthCallbackUsed = (callbackId) => {
|
|
348
|
+
const database = ensureDb();
|
|
349
|
+
const normalizedCallbackId = String(callbackId || "").trim();
|
|
350
|
+
if (!normalizedCallbackId) return 0;
|
|
351
|
+
const result = database
|
|
352
|
+
.prepare(`
|
|
353
|
+
UPDATE oauth_callbacks
|
|
354
|
+
SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
|
355
|
+
WHERE callback_id = $callback_id
|
|
356
|
+
`)
|
|
357
|
+
.run({ $callback_id: normalizedCallbackId });
|
|
358
|
+
return Number(result.changes || 0);
|
|
359
|
+
};
|
|
360
|
+
|
|
223
361
|
const pruneOldEntries = (days = 30) => {
|
|
224
362
|
const database = ensureDb();
|
|
225
363
|
const safeDays = Math.max(1, Number.parseInt(String(days || 30), 10) || 30);
|
|
@@ -240,5 +378,11 @@ module.exports = {
|
|
|
240
378
|
getRequestById,
|
|
241
379
|
getHookSummaries,
|
|
242
380
|
deleteRequestsByHook,
|
|
381
|
+
createOauthCallback,
|
|
382
|
+
getOauthCallbackByHook,
|
|
383
|
+
getOauthCallbackById,
|
|
384
|
+
rotateOauthCallback,
|
|
385
|
+
deleteOauthCallback,
|
|
386
|
+
markOauthCallbackUsed,
|
|
243
387
|
pruneOldEntries,
|
|
244
388
|
};
|
|
@@ -18,6 +18,19 @@ const createSchema = (database) => {
|
|
|
18
18
|
CREATE INDEX IF NOT EXISTS idx_webhook_requests_hook_ts
|
|
19
19
|
ON webhook_requests(hook_name, created_at DESC);
|
|
20
20
|
`);
|
|
21
|
+
database.exec(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS oauth_callbacks (
|
|
23
|
+
callback_id TEXT PRIMARY KEY,
|
|
24
|
+
hook_name TEXT NOT NULL UNIQUE,
|
|
25
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
|
26
|
+
rotated_at TEXT,
|
|
27
|
+
last_used_at TEXT
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
database.exec(`
|
|
31
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_callbacks_hook_name
|
|
32
|
+
ON oauth_callbacks(hook_name);
|
|
33
|
+
`);
|
|
21
34
|
};
|
|
22
35
|
|
|
23
36
|
module.exports = {
|
|
@@ -17,6 +17,9 @@ const { registerDoctorRoutes } = require("../routes/doctor");
|
|
|
17
17
|
const { registerAgentRoutes } = require("../routes/agents");
|
|
18
18
|
const { registerCronRoutes } = require("../routes/cron");
|
|
19
19
|
const { registerNodeRoutes } = require("../routes/nodes");
|
|
20
|
+
const {
|
|
21
|
+
createOauthCallbackMiddleware,
|
|
22
|
+
} = require("../oauth-callback-middleware");
|
|
20
23
|
|
|
21
24
|
const registerServerRoutes = ({
|
|
22
25
|
app,
|
|
@@ -58,6 +61,12 @@ const registerServerRoutes = ({
|
|
|
58
61
|
getRequestById,
|
|
59
62
|
getHookSummaries,
|
|
60
63
|
deleteRequestsByHook,
|
|
64
|
+
createOauthCallback,
|
|
65
|
+
getOauthCallbackByHook,
|
|
66
|
+
getOauthCallbackById,
|
|
67
|
+
rotateOauthCallback,
|
|
68
|
+
deleteOauthCallback,
|
|
69
|
+
markOauthCallbackUsed,
|
|
61
70
|
watchdog,
|
|
62
71
|
getRecentEvents,
|
|
63
72
|
readLogTail,
|
|
@@ -187,9 +196,18 @@ const registerServerRoutes = ({
|
|
|
187
196
|
getRequestById,
|
|
188
197
|
getHookSummaries,
|
|
189
198
|
deleteRequestsByHook,
|
|
199
|
+
createOauthCallback,
|
|
200
|
+
getOauthCallbackByHook,
|
|
201
|
+
rotateOauthCallback,
|
|
202
|
+
deleteOauthCallback,
|
|
190
203
|
},
|
|
191
204
|
restartRequiredState,
|
|
192
205
|
});
|
|
206
|
+
const oauthCallbackMiddleware = createOauthCallbackMiddleware({
|
|
207
|
+
getOauthCallbackById,
|
|
208
|
+
markOauthCallbackUsed,
|
|
209
|
+
webhookMiddleware,
|
|
210
|
+
});
|
|
193
211
|
registerWatchdogRoutes({
|
|
194
212
|
app,
|
|
195
213
|
requireAuth,
|
|
@@ -235,6 +253,7 @@ const registerServerRoutes = ({
|
|
|
235
253
|
getGatewayUrl,
|
|
236
254
|
SETUP_API_PREFIXES,
|
|
237
255
|
requireAuth,
|
|
256
|
+
oauthCallbackMiddleware,
|
|
238
257
|
webhookMiddleware,
|
|
239
258
|
});
|
|
240
259
|
|