webhooks-admin.ts264 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { createHmac } from 'node:crypto';
5
6import { ValidationError } from '@briven/shared';
7
8import { env } from '../env.js';
9import { requireAuth } from '../middleware/session.js';
10import { assertProjectRole } from '../services/access.js';
11import { audit, hashIp } from '../services/audit.js';
12import {
13 createWebhook,
14 decryptEndpointSecret,
15 deleteWebhook,
16 getWebhookRaw,
17 listDeliveries,
18 listWebhooks,
19 rotateWebhookSecret,
20 updateWebhook,
21} from '../services/webhooks.js';
22import type { AppEnv } from '../types/app-env.js';
23
24export const webhooksAdminRouter = new Hono<AppEnv>();
25
26webhooksAdminRouter.use('/v1/projects/:id/webhooks', requireAuth());
27webhooksAdminRouter.use('/v1/projects/:id/webhooks/*', requireAuth());
28
29const NAME_SCHEMA = z.string().min(1).max(64);
30const FN_SCHEMA = z.string().min(1).max(128);
31
32const createSchema = z.object({
33 name: NAME_SCHEMA,
34 functionName: FN_SCHEMA,
35 enabled: z.boolean().optional(),
36});
37
38const updateSchema = z.object({
39 name: NAME_SCHEMA.optional(),
40 functionName: FN_SCHEMA.optional(),
41 enabled: z.boolean().optional(),
42});
43
44function validationResponse(issues: unknown) {
45 return { code: 'validation_failed' as const, message: 'invalid request body', issues };
46}
47
48webhooksAdminRouter.get('/v1/projects/:id/webhooks', async (c) => {
49 const user = c.get('user')!;
50 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer');
51 const endpoints = await listWebhooks(project.id);
52 return c.json({ endpoints });
53});
54
55webhooksAdminRouter.post('/v1/projects/:id/webhooks', async (c) => {
56 const user = c.get('user')!;
57 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
58 const body = await c.req.json().catch(() => null);
59 const parsed = createSchema.safeParse(body);
60 if (!parsed.success) return c.json(validationResponse(parsed.error.issues), 400);
61
62 try {
63 const result = await createWebhook({
64 projectId: project.id,
65 name: parsed.data.name,
66 functionName: parsed.data.functionName,
67 enabled: parsed.data.enabled,
68 createdBy: user.id,
69 });
70 await audit({
71 actorId: user.id,
72 projectId: project.id,
73 action: 'webhook.create',
74 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
75 userAgent: c.req.header('user-agent') ?? null,
76 metadata: {
77 endpointId: result.endpoint.id,
78 name: result.endpoint.name,
79 functionName: result.endpoint.functionName,
80 },
81 });
82 return c.json(
83 {
84 endpoint: result.endpoint,
85 // Per CLAUDE.md §5.4 — plaintext returned once. The caller must
86 // store it immediately; we never log it and the API never
87 // surfaces it again.
88 plaintextSecret: result.plaintextSecret,
89 },
90 201,
91 );
92 } catch (err) {
93 if (err instanceof ValidationError) {
94 return c.json({ code: 'validation_failed', message: err.message }, 400);
95 }
96 throw err;
97 }
98});
99
100webhooksAdminRouter.patch('/v1/projects/:id/webhooks/:endpointId', async (c) => {
101 const user = c.get('user')!;
102 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
103 const endpointId = c.req.param('endpointId');
104
105 const body = await c.req.json().catch(() => null);
106 const parsed = updateSchema.safeParse(body);
107 if (!parsed.success) return c.json(validationResponse(parsed.error.issues), 400);
108
109 try {
110 const endpoint = await updateWebhook(endpointId, project.id, parsed.data);
111 await audit({
112 actorId: user.id,
113 projectId: project.id,
114 action: 'webhook.update',
115 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
116 userAgent: c.req.header('user-agent') ?? null,
117 metadata: { endpointId, fields: Object.keys(parsed.data) },
118 });
119 return c.json({ endpoint });
120 } catch (err) {
121 if (err instanceof ValidationError) {
122 return c.json({ code: 'validation_failed', message: err.message }, 400);
123 }
124 throw err;
125 }
126});
127
128webhooksAdminRouter.post(
129 '/v1/projects/:id/webhooks/:endpointId/rotate-secret',
130 async (c) => {
131 const user = c.get('user')!;
132 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
133 const endpointId = c.req.param('endpointId');
134 const result = await rotateWebhookSecret(endpointId, project.id);
135 await audit({
136 actorId: user.id,
137 projectId: project.id,
138 action: 'webhook.rotate-secret',
139 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
140 userAgent: c.req.header('user-agent') ?? null,
141 metadata: { endpointId },
142 });
143 return c.json({ endpoint: result.endpoint, plaintextSecret: result.plaintextSecret });
144 },
145);
146
147webhooksAdminRouter.delete('/v1/projects/:id/webhooks/:endpointId', async (c) => {
148 const user = c.get('user')!;
149 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
150 const endpointId = c.req.param('endpointId');
151 await deleteWebhook(endpointId, project.id);
152 await audit({
153 actorId: user.id,
154 projectId: project.id,
155 action: 'webhook.delete',
156 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
157 userAgent: c.req.header('user-agent') ?? null,
158 metadata: { endpointId },
159 });
160 return c.json({ ok: true });
161});
162
163const DELIVERY_STATUS_VALUES = [
164 'ok',
165 'rejected_signature',
166 'rejected_replay',
167 'invoke_error',
168 'disabled',
169] as const;
170
171/**
172 * Test-fire — mints a sample payload, signs it with the endpoint's
173 * stored secret, and POSTs to the endpoint's own public URL. The
174 * round-trip exercises the same path an external caller takes:
175 * signature verification + delivery recording + function invocation.
176 *
177 * The result is returned inline so the dashboard can show "200 / 401 /
178 * 410 / 502" + duration without forcing the admin to refresh the
179 * deliveries log. A delivery row is still recorded by the public route
180 * during the round-trip — that's the truth-source if the dashboard
181 * response is missed.
182 */
183webhooksAdminRouter.post(
184 '/v1/projects/:id/webhooks/:endpointId/test-fire',
185 async (c) => {
186 const user = c.get('user')!;
187 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin');
188 const endpointId = c.req.param('endpointId');
189 const endpoint = await getWebhookRaw(endpointId, project.id);
190 const secret = decryptEndpointSecret(endpoint);
191
192 const timestamp = String(Date.now());
193 const body = JSON.stringify({
194 test: true,
195 sentAt: new Date().toISOString(),
196 triggeredBy: user.id,
197 endpoint: { id: endpoint.id, name: endpoint.name },
198 });
199 const signature = `v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
200
201 const url = `${env.BRIVEN_API_ORIGIN}/webhooks/${project.id}/${endpoint.id}`;
202 const t0 = Date.now();
203 let status: number | null = null;
204 let responseBody: string | null = null;
205 let networkError: string | null = null;
206 try {
207 const res = await fetch(url, {
208 method: 'POST',
209 headers: {
210 'content-type': 'application/json',
211 'x-briven-signature': signature,
212 'x-briven-timestamp': timestamp,
213 },
214 body,
215 // The api host signs its own request to itself — should resolve
216 // fast, but cap to keep the dashboard responsive when the
217 // function itself is slow or hung.
218 signal: AbortSignal.timeout(15_000),
219 });
220 status = res.status;
221 const raw = await res.text().catch(() => '');
222 // Truncate so a runaway function body can't blow up the dashboard
223 // JSON payload — first 4 KiB is plenty to see what went wrong.
224 responseBody = raw.length > 4096 ? `${raw.slice(0, 4096)}…` : raw;
225 } catch (err) {
226 networkError = err instanceof Error ? err.message : 'fetch failed';
227 }
228 const durationMs = Date.now() - t0;
229
230 await audit({
231 actorId: user.id,
232 projectId: project.id,
233 action: 'webhook.test-fire',
234 ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')),
235 userAgent: c.req.header('user-agent') ?? null,
236 metadata: { endpointId, status, durationMs },
237 });
238
239 return c.json({
240 ok: status !== null && status >= 200 && status < 300,
241 status,
242 durationMs,
243 url,
244 responseBody,
245 networkError,
246 });
247 },
248);
249
250webhooksAdminRouter.get(
251 '/v1/projects/:id/webhooks/:endpointId/deliveries',
252 async (c) => {
253 const user = c.get('user')!;
254 const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer');
255 const endpointId = c.req.param('endpointId');
256 const statusParam = c.req.query('status');
257 const status =
258 statusParam && (DELIVERY_STATUS_VALUES as readonly string[]).includes(statusParam)
259 ? (statusParam as (typeof DELIVERY_STATUS_VALUES)[number])
260 : undefined;
261 const deliveries = await listDeliveries(endpointId, project.id, { limit: 100, status });
262 return c.json({ deliveries });
263 },
264);