outbound-webhooks.ts220 lines · main
| 1 | import { Hono } from 'hono'; |
| 2 | import { z } from 'zod'; |
| 3 | |
| 4 | import { ValidationError } from '@briven/shared'; |
| 5 | |
| 6 | import { requireAuth } from '../middleware/session.js'; |
| 7 | import { assertProjectRole } from '../services/access.js'; |
| 8 | import { audit, hashIp } from '../services/audit.js'; |
| 9 | import { |
| 10 | createSubscriber, |
| 11 | deleteSubscriber, |
| 12 | KNOWN_EVENT_TYPES, |
| 13 | listOutboundDeliveries, |
| 14 | listSubscribers, |
| 15 | replayDelivery, |
| 16 | rotateSubscriberSecret, |
| 17 | updateSubscriber, |
| 18 | } from '../services/outbound-webhooks.js'; |
| 19 | import type { AppEnv } from '../types/app-env.js'; |
| 20 | |
| 21 | export const outboundWebhooksRouter = new Hono<AppEnv>(); |
| 22 | |
| 23 | outboundWebhooksRouter.use('/v1/projects/:id/outbound-webhooks', requireAuth()); |
| 24 | outboundWebhooksRouter.use('/v1/projects/:id/outbound-webhooks/*', requireAuth()); |
| 25 | |
| 26 | const NAME_SCHEMA = z.string().min(1).max(64); |
| 27 | const URL_SCHEMA = z.string().url().max(2000); |
| 28 | const EVENT_TYPES_SCHEMA = z.string().min(1).max(500); |
| 29 | |
| 30 | const createSchema = z.object({ |
| 31 | name: NAME_SCHEMA, |
| 32 | targetUrl: URL_SCHEMA, |
| 33 | eventTypes: EVENT_TYPES_SCHEMA.optional(), |
| 34 | enabled: z.boolean().optional(), |
| 35 | allowedIps: z.string().max(500).optional(), |
| 36 | }); |
| 37 | |
| 38 | const updateSchema = z.object({ |
| 39 | name: NAME_SCHEMA.optional(), |
| 40 | targetUrl: URL_SCHEMA.optional(), |
| 41 | eventTypes: EVENT_TYPES_SCHEMA.optional(), |
| 42 | enabled: z.boolean().optional(), |
| 43 | allowedIps: z.string().max(500).optional(), |
| 44 | }); |
| 45 | |
| 46 | const DELIVERY_STATUS_VALUES = ['pending', 'ok', 'failed', 'cancelled'] as const; |
| 47 | |
| 48 | function validationResponse(issues: unknown) { |
| 49 | return { code: 'validation_failed' as const, message: 'invalid request body', issues }; |
| 50 | } |
| 51 | |
| 52 | outboundWebhooksRouter.get('/v1/projects/:id/outbound-webhooks', async (c) => { |
| 53 | const user = c.get('user')!; |
| 54 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 55 | try { |
| 56 | const subscribers = await listSubscribers(project.id); |
| 57 | return c.json({ subscribers, knownEventTypes: KNOWN_EVENT_TYPES }); |
| 58 | } catch (err) { |
| 59 | // Missing migration / schema drift must not 500 the Auth → Webhooks page |
| 60 | // (and the general webhooks panel). Return empty list + types so the UI |
| 61 | // still renders; create can surface a clearer error if the table is gone. |
| 62 | const message = err instanceof Error ? err.message : String(err); |
| 63 | // Lazy import to avoid circular log deps at module load in tests. |
| 64 | const { log } = await import('../lib/logger.js'); |
| 65 | log.error('outbound_webhooks_list_failed', { |
| 66 | projectId: project.id, |
| 67 | message, |
| 68 | }); |
| 69 | return c.json({ |
| 70 | subscribers: [], |
| 71 | knownEventTypes: KNOWN_EVENT_TYPES, |
| 72 | warning: 'list_failed', |
| 73 | }); |
| 74 | } |
| 75 | }); |
| 76 | |
| 77 | outboundWebhooksRouter.post('/v1/projects/:id/outbound-webhooks', async (c) => { |
| 78 | const user = c.get('user')!; |
| 79 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 80 | const body = await c.req.json().catch(() => null); |
| 81 | const parsed = createSchema.safeParse(body); |
| 82 | if (!parsed.success) return c.json(validationResponse(parsed.error.issues), 400); |
| 83 | try { |
| 84 | const result = await createSubscriber({ |
| 85 | projectId: project.id, |
| 86 | name: parsed.data.name, |
| 87 | targetUrl: parsed.data.targetUrl, |
| 88 | eventTypes: parsed.data.eventTypes, |
| 89 | enabled: parsed.data.enabled, |
| 90 | allowedIps: parsed.data.allowedIps, |
| 91 | createdBy: user.id, |
| 92 | }); |
| 93 | await audit({ |
| 94 | actorId: user.id, |
| 95 | projectId: project.id, |
| 96 | action: 'outbound-webhook.create', |
| 97 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 98 | userAgent: c.req.header('user-agent') ?? null, |
| 99 | metadata: { |
| 100 | subscriberId: result.subscriber.id, |
| 101 | name: result.subscriber.name, |
| 102 | eventTypes: result.subscriber.eventTypes, |
| 103 | }, |
| 104 | }); |
| 105 | return c.json( |
| 106 | { subscriber: result.subscriber, plaintextSecret: result.plaintextSecret }, |
| 107 | 201, |
| 108 | ); |
| 109 | } catch (err) { |
| 110 | if (err instanceof ValidationError) { |
| 111 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 112 | } |
| 113 | throw err; |
| 114 | } |
| 115 | }); |
| 116 | |
| 117 | outboundWebhooksRouter.patch( |
| 118 | '/v1/projects/:id/outbound-webhooks/:subscriberId', |
| 119 | async (c) => { |
| 120 | const user = c.get('user')!; |
| 121 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 122 | const subscriberId = c.req.param('subscriberId'); |
| 123 | const body = await c.req.json().catch(() => null); |
| 124 | const parsed = updateSchema.safeParse(body); |
| 125 | if (!parsed.success) return c.json(validationResponse(parsed.error.issues), 400); |
| 126 | try { |
| 127 | const subscriber = await updateSubscriber(subscriberId, project.id, parsed.data); |
| 128 | await audit({ |
| 129 | actorId: user.id, |
| 130 | projectId: project.id, |
| 131 | action: 'outbound-webhook.update', |
| 132 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 133 | userAgent: c.req.header('user-agent') ?? null, |
| 134 | metadata: { subscriberId, fields: Object.keys(parsed.data) }, |
| 135 | }); |
| 136 | return c.json({ subscriber }); |
| 137 | } catch (err) { |
| 138 | if (err instanceof ValidationError) { |
| 139 | return c.json({ code: 'validation_failed', message: err.message }, 400); |
| 140 | } |
| 141 | throw err; |
| 142 | } |
| 143 | }, |
| 144 | ); |
| 145 | |
| 146 | outboundWebhooksRouter.post( |
| 147 | '/v1/projects/:id/outbound-webhooks/:subscriberId/rotate-secret', |
| 148 | async (c) => { |
| 149 | const user = c.get('user')!; |
| 150 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 151 | const subscriberId = c.req.param('subscriberId'); |
| 152 | const result = await rotateSubscriberSecret(subscriberId, project.id); |
| 153 | await audit({ |
| 154 | actorId: user.id, |
| 155 | projectId: project.id, |
| 156 | action: 'outbound-webhook.rotate-secret', |
| 157 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 158 | userAgent: c.req.header('user-agent') ?? null, |
| 159 | metadata: { subscriberId }, |
| 160 | }); |
| 161 | return c.json({ subscriber: result.subscriber, plaintextSecret: result.plaintextSecret }); |
| 162 | }, |
| 163 | ); |
| 164 | |
| 165 | outboundWebhooksRouter.delete( |
| 166 | '/v1/projects/:id/outbound-webhooks/:subscriberId', |
| 167 | async (c) => { |
| 168 | const user = c.get('user')!; |
| 169 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 170 | const subscriberId = c.req.param('subscriberId'); |
| 171 | await deleteSubscriber(subscriberId, project.id); |
| 172 | await audit({ |
| 173 | actorId: user.id, |
| 174 | projectId: project.id, |
| 175 | action: 'outbound-webhook.delete', |
| 176 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 177 | userAgent: c.req.header('user-agent') ?? null, |
| 178 | metadata: { subscriberId }, |
| 179 | }); |
| 180 | return c.json({ ok: true }); |
| 181 | }, |
| 182 | ); |
| 183 | |
| 184 | outboundWebhooksRouter.get( |
| 185 | '/v1/projects/:id/outbound-webhooks/:subscriberId/deliveries', |
| 186 | async (c) => { |
| 187 | const user = c.get('user')!; |
| 188 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'viewer'); |
| 189 | const subscriberId = c.req.param('subscriberId'); |
| 190 | const statusParam = c.req.query('status'); |
| 191 | const status = |
| 192 | statusParam && (DELIVERY_STATUS_VALUES as readonly string[]).includes(statusParam) |
| 193 | ? (statusParam as (typeof DELIVERY_STATUS_VALUES)[number]) |
| 194 | : undefined; |
| 195 | const deliveries = await listOutboundDeliveries(subscriberId, project.id, { |
| 196 | limit: 100, |
| 197 | status, |
| 198 | }); |
| 199 | return c.json({ deliveries }); |
| 200 | }, |
| 201 | ); |
| 202 | |
| 203 | outboundWebhooksRouter.post( |
| 204 | '/v1/projects/:id/outbound-webhooks/:subscriberId/deliveries/:deliveryId/replay', |
| 205 | async (c) => { |
| 206 | const user = c.get('user')!; |
| 207 | const { project } = await assertProjectRole(c.req.param('id'), user.id, 'admin'); |
| 208 | const deliveryId = c.req.param('deliveryId'); |
| 209 | await replayDelivery(deliveryId, project.id); |
| 210 | await audit({ |
| 211 | actorId: user.id, |
| 212 | projectId: project.id, |
| 213 | action: 'outbound-webhook.replay', |
| 214 | ipHash: hashIp(c.req.raw.headers.get('x-forwarded-for')), |
| 215 | userAgent: c.req.header('user-agent') ?? null, |
| 216 | metadata: { deliveryId }, |
| 217 | }); |
| 218 | return c.json({ ok: true }); |
| 219 | }, |
| 220 | ); |