@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.
@@ -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
+ };
@@ -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(name, { destination = null } = {}) {
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