webhooks-admin.ts264 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { createHmac } from 'node:crypto'; |
| 5 | |
| 6 | import { ValidationError } from '@briven/shared'; |
| 7 | |
| 8 | import { env } from '../env.js'; |
| 9 | import { requireAuth } from '../middleware/session.js'; |
| 10 | import { assertProjectRole } from '../services/access.js'; |
| 11 | import { audit, hashIp } from '../services/audit.js'; |
| 12 | import { |
| 13 | createWebhook, |
| 14 | decryptEndpointSecret, |
| 15 | deleteWebhook, |
| 16 | getWebhookRaw, |
| 17 | listDeliveries, |
| 18 | listWebhooks, |
| 19 | rotateWebhookSecret, |
| 20 | updateWebhook, |
| 21 | } from '../services/webhooks.js'; |
| 22 | import type { AppEnv } from '../types/app-env.js'; |
| 23 | |
| 24 | export const webhooksAdminRouter = new Hono<AppEnv>(); |
| 25 | |
| 26 | webhooksAdminRouter.use('/v1/projects/:id/webhooks', requireAuth()); |
| 27 | webhooksAdminRouter.use('/v1/projects/:id/webhooks/*', requireAuth()); |
| 28 | |
| 29 | const NAME_SCHEMA = z.string().min(1).max(64); |
| 30 | const FN_SCHEMA = z.string().min(1).max(128); |
| 31 | |
| 32 | const createSchema = z.object({ |
| 33 | name: NAME_SCHEMA, |
| 34 | functionName: FN_SCHEMA, |
| 35 | enabled: z.boolean().optional(), |
| 36 | }); |
| 37 | |
| 38 | const updateSchema = z.object({ |
| 39 | name: NAME_SCHEMA.optional(), |
| 40 | functionName: FN_SCHEMA.optional(), |
| 41 | enabled: z.boolean().optional(), |
| 42 | }); |
| 43 | |
| 44 | function validationResponse(issues: unknown) { |
| 45 | return { code: 'validation_failed' as const, message: 'invalid request body', issues }; |
| 46 | } |
| 47 | |
| 48 | webhooksAdminRouter.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 | |
| 55 | webhooksAdminRouter.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 | |
| 100 | webhooksAdminRouter.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 | |
| 128 | webhooksAdminRouter.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 | |
| 147 | webhooksAdminRouter.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 | |
| 163 | const 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 | */ |
| 183 | webhooksAdminRouter.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 | |
| 250 | webhooksAdminRouter.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 | ); |