webhooks-public.ts166 lines · main
1import { Hono } from 'hono';
2
3import { hashIp } from '../services/audit.js';
4import { invoke } from '../services/invoke.js';
5import {
6 decryptEndpointSecret,
7 getWebhookRaw,
8 recordDelivery,
9 verifyWebhookSignature,
10} from '../services/webhooks.js';
11import type { AppEnv } from '../types/app-env.js';
12
13export const webhooksPublicRouter = new Hono<AppEnv>();
14
15const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB — generous for most webhook payloads
16const MAX_REQUEST_ID_LEN = 64;
17
18/**
19 * Public webhook ingestion. Authenticated via HMAC-SHA256 signature only
20 * — no Better Auth session, no api key, no project-role check. The
21 * signature IS the authorisation.
22 *
23 * POST /webhooks/:projectId/:endpointId
24 * X-Briven-Signature: v1=<hex>
25 * X-Briven-Timestamp: <unix-milliseconds>
26 * content-type: application/json
27 *
28 * Always inserts one row into webhook_deliveries — accepted, rejected,
29 * disabled, function 500 — so the operator can see every inbound attempt
30 * in the dashboard delivery log. Returns are conservative: signature
31 * failures get 401, replay failures 401, missing endpoint 404, disabled
32 * 410, function errors 502, success 200.
33 */
34webhooksPublicRouter.post('/webhooks/:projectId/:endpointId', async (c) => {
35 const projectId = c.req.param('projectId');
36 const endpointId = c.req.param('endpointId');
37 const sourceIpHash = hashIp(
38 c.req.raw.headers.get('cf-connecting-ip') ?? c.req.raw.headers.get('x-forwarded-for'),
39 );
40
41 // Body cap. Read as text first so we can HMAC-verify the EXACT bytes
42 // that arrived — JSON parsing happens after verification on the way
43 // into the function invoke.
44 const rawBody = await c.req.text();
45 if (rawBody.length > MAX_BODY_BYTES) {
46 // Don't even try to record a delivery row — we don't know which
47 // endpoint this was for in a useful sense, and storing the rejected
48 // body opens a DoS vector. Just refuse.
49 return c.json({ code: 'body_too_large', message: 'body exceeds 1 MiB cap' }, 413);
50 }
51
52 let endpoint;
53 try {
54 endpoint = await getWebhookRaw(endpointId, projectId);
55 } catch {
56 // Don't differentiate "wrong project" from "wrong endpoint" — both
57 // 404 so a probe can't enumerate live endpoints.
58 return c.json({ code: 'not_found' }, 404);
59 }
60
61 if (!endpoint.enabled) {
62 await recordDelivery({
63 endpointId: endpoint.id,
64 projectId: endpoint.projectId,
65 status: 'disabled',
66 sourceIpHash,
67 functionName: endpoint.functionName,
68 durationMs: null,
69 errorMessage: null,
70 });
71 return c.json({ code: 'disabled', message: 'endpoint is disabled' }, 410);
72 }
73
74 const signatureHeader = c.req.header('x-briven-signature') ?? null;
75 const timestampHeader = c.req.header('x-briven-timestamp') ?? null;
76 const verify = verifyWebhookSignature({
77 rawBody,
78 signatureHeader,
79 timestampHeader,
80 plaintextSecret: decryptEndpointSecret(endpoint),
81 now: new Date(),
82 });
83
84 if (!verify.ok) {
85 await recordDelivery({
86 endpointId: endpoint.id,
87 projectId: endpoint.projectId,
88 status: verify.status,
89 sourceIpHash,
90 functionName: endpoint.functionName,
91 durationMs: null,
92 errorMessage: verify.reason,
93 });
94 // 401 for either rejection — don't leak which one to the source.
95 return c.json({ code: verify.status }, 401);
96 }
97
98 // Body is verified. Parse and dispatch to the function. We pass the
99 // body straight through as `args` — the customer's function decides
100 // what to do with it. Headers aren't forwarded (Phase 3 work).
101 let args: unknown;
102 try {
103 args = rawBody.length === 0 ? {} : JSON.parse(rawBody);
104 } catch {
105 await recordDelivery({
106 endpointId: endpoint.id,
107 projectId: endpoint.projectId,
108 status: 'invoke_error',
109 sourceIpHash,
110 functionName: endpoint.functionName,
111 durationMs: null,
112 errorMessage: 'request body is not valid json',
113 });
114 return c.json({ code: 'invalid_json', message: 'body must be json' }, 400);
115 }
116
117 const requestId = (c.get('requestId') ?? '').slice(0, MAX_REQUEST_ID_LEN);
118 const t0 = Date.now();
119 try {
120 const result = await invoke({
121 projectId: endpoint.projectId,
122 functionName: endpoint.functionName,
123 args,
124 requestId,
125 // Webhook deliveries carry no human identity. The runtime treats
126 // this the same as a scheduled invocation: no session, no api key.
127 auth: null,
128 });
129 const durationMs = Date.now() - t0;
130 if (!result.ok) {
131 await recordDelivery({
132 endpointId: endpoint.id,
133 projectId: endpoint.projectId,
134 status: 'invoke_error',
135 sourceIpHash,
136 functionName: endpoint.functionName,
137 durationMs,
138 errorMessage: `${result.code}: ${result.message}`.slice(0, 500),
139 });
140 return c.json({ code: 'invoke_failed', message: result.message }, 502);
141 }
142 await recordDelivery({
143 endpointId: endpoint.id,
144 projectId: endpoint.projectId,
145 status: 'ok',
146 sourceIpHash,
147 functionName: endpoint.functionName,
148 durationMs,
149 errorMessage: null,
150 });
151 return c.json({ ok: true });
152 } catch (err) {
153 const durationMs = Date.now() - t0;
154 const errorMessage = (err instanceof Error ? err.message : String(err)).slice(0, 500);
155 await recordDelivery({
156 endpointId: endpoint.id,
157 projectId: endpoint.projectId,
158 status: 'invoke_error',
159 sourceIpHash,
160 functionName: endpoint.functionName,
161 durationMs,
162 errorMessage,
163 });
164 return c.json({ code: 'internal_error', message: 'invoke threw' }, 500);
165 }
166});