outbound-webhooks.ts220 lines · main
1import { Hono } from 'hono';
2import { z } from 'zod';
3
4import { ValidationError } from '@briven/shared';
5
6import { requireAuth } from '../middleware/session.js';
7import { assertProjectRole } from '../services/access.js';
8import { audit, hashIp } from '../services/audit.js';
9import {
10 createSubscriber,
11 deleteSubscriber,
12 KNOWN_EVENT_TYPES,
13 listOutboundDeliveries,
14 listSubscribers,
15 replayDelivery,
16 rotateSubscriberSecret,
17 updateSubscriber,
18} from '../services/outbound-webhooks.js';
19import type { AppEnv } from '../types/app-env.js';
20
21export const outboundWebhooksRouter = new Hono<AppEnv>();
22
23outboundWebhooksRouter.use('/v1/projects/:id/outbound-webhooks', requireAuth());
24outboundWebhooksRouter.use('/v1/projects/:id/outbound-webhooks/*', requireAuth());
25
26const NAME_SCHEMA = z.string().min(1).max(64);
27const URL_SCHEMA = z.string().url().max(2000);
28const EVENT_TYPES_SCHEMA = z.string().min(1).max(500);
29
30const 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
38const 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
46const DELIVERY_STATUS_VALUES = ['pending', 'ok', 'failed', 'cancelled'] as const;
47
48function validationResponse(issues: unknown) {
49 return { code: 'validation_failed' as const, message: 'invalid request body', issues };
50}
51
52outboundWebhooksRouter.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
77outboundWebhooksRouter.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
117outboundWebhooksRouter.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
146outboundWebhooksRouter.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
165outboundWebhooksRouter.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
184outboundWebhooksRouter.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
203outboundWebhooksRouter.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);