Skip to content
File

Blob: src/lib/webhooks.ts

typescript237 lines
1import type { WebhookProvider, WebhookProviderConfig } from "@/contracts";
2 
3export type WebhookConfigMode = "forbidden" | "optional" | "required";
4export type WebhookVerificationKind = "hmac-sha256" | "shared-secret";
5export type WebhookQueueFullResponseStatus = 200 | 503;
6 
7export interface WebhookProviderCatalogEntry {
8 provider: WebhookProvider;
9 displayName: string;
10 docsUrl: string;
11 requiredHttpMethod: "POST";
12 expectedContentType: "application/json";
13 expectedSecretField: string;
14 requiredHeaders: readonly string[];
15 deliveryIdHeaders: readonly string[];
16 eventHeader: string;
17 verificationHeader: string;
18 verificationKind: WebhookVerificationKind;
19 verificationPrefix: string | null;
20 configMode: WebhookConfigMode;
21 defaultInstanceUrl: string | null;
22 queueFullResponseStatus: WebhookQueueFullResponseStatus;
23 queueFullRetryAfterSeconds: string | null;
24 setupInstructions: readonly string[];
25}
26 
27export const inferWebhookInstanceUrl = (provider: WebhookProvider, repoUrl: string): string => {
28 try {
29 const url = new URL(repoUrl);
30 if (provider === "gitea") {
31 const segments = url.pathname.replace(/\/$/u, "").split("/").filter(Boolean);
32 if (segments.length >= 2) {
33 url.pathname = segments.slice(0, -2).join("/") || "/";
34 }
35 return url.origin + (url.pathname === "/" ? "" : url.pathname.replace(/\/$/u, ""));
36 }
37 
38 return url.origin;
39 } catch {
40 return "";
41 }
42};
43 
44export const webhookProviderCatalog = {
45 github: {
46 provider: "github",
47 displayName: "GitHub",
48 docsUrl: "https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks",
49 requiredHttpMethod: "POST",
50 expectedContentType: "application/json",
51 expectedSecretField: "Secret",
52 requiredHeaders: ["X-Hub-Signature-256", "X-GitHub-Event"],
53 deliveryIdHeaders: ["X-GitHub-Delivery"],
54 eventHeader: "X-GitHub-Event",
55 verificationHeader: "X-Hub-Signature-256",
56 verificationKind: "hmac-sha256",
57 verificationPrefix: "sha256=",
58 configMode: "forbidden",
59 defaultInstanceUrl: null,
60 queueFullResponseStatus: 503,
61 queueFullRetryAfterSeconds: "60",
62 setupInstructions: [
63 "Repository Settings > Webhooks > Add webhook",
64 "Set Content type to application/json",
65 "Subscribe to push events",
66 "Save the secret exactly as shown once",
67 ],
68 },
69 gitlab: {
70 provider: "gitlab",
71 displayName: "GitLab",
72 docsUrl: "https://docs.gitlab.com/user/project/integrations/webhooks/",
73 requiredHttpMethod: "POST",
74 expectedContentType: "application/json",
75 expectedSecretField: "Secret token",
76 requiredHeaders: ["X-Gitlab-Token", "X-Gitlab-Event"],
77 // GitLab 17.4+ sends Idempotency-Key and preserves it across retries of the same delivery.
78 // Manual redelivery gets a new X-Gitlab-Event-UUID, and older GitLab versions only send that UUID,
79 // so keep Idempotency-Key first and X-Gitlab-Event-UUID as the ordered fallback.
80 deliveryIdHeaders: ["Idempotency-Key", "X-Gitlab-Event-UUID"],
81 eventHeader: "X-Gitlab-Event",
82 verificationHeader: "X-Gitlab-Token",
83 verificationKind: "shared-secret",
84 verificationPrefix: null,
85 configMode: "optional",
86 defaultInstanceUrl: "https://gitlab.com",
87 // GitLab counts 4xx/5xx receiver responses as delivery failures, so keep
88 // queue pressure as an internal audit outcome instead of returning 503.
89 queueFullResponseStatus: 200,
90 queueFullRetryAfterSeconds: null,
91 setupInstructions: [
92 "Project Settings > Webhooks > Add new webhook",
93 "Enable push events",
94 "Leave custom webhook templates disabled in v1",
95 "Use the generated secret token exactly once when creating the hook",
96 ],
97 },
98 gitea: {
99 provider: "gitea",
100 displayName: "Gitea",
101 docsUrl: "https://docs.gitea.com/usage/repository/webhooks",
102 requiredHttpMethod: "POST",
103 expectedContentType: "application/json",
104 expectedSecretField: "Secret",
105 requiredHeaders: ["X-Gitea-Signature", "X-Gitea-Event"],
106 deliveryIdHeaders: ["X-Gitea-Delivery"],
107 eventHeader: "X-Gitea-Event",
108 verificationHeader: "X-Gitea-Signature",
109 verificationKind: "hmac-sha256",
110 verificationPrefix: null,
111 configMode: "required",
112 defaultInstanceUrl: null,
113 queueFullResponseStatus: 503,
114 queueFullRetryAfterSeconds: "60",
115 setupInstructions: [
116 "Repository Settings > Webhooks > Gitea",
117 "Set POST Content Type to application/json",
118 "Trigger on push events",
119 "Use the generated secret exactly as shown once",
120 ],
121 },
122} as const satisfies Record<WebhookProvider, WebhookProviderCatalogEntry>;
123 
124export const webhookProviderCatalogList = Object.values(webhookProviderCatalog);
125 
126export const getWebhookProviderCatalogEntry = (provider: WebhookProvider): WebhookProviderCatalogEntry =>
127 webhookProviderCatalog[provider];
128 
129const normalizeContentType = (value: string | null): string | null =>
130 value?.split(";")[0]?.trim().toLowerCase() ?? null;
131 
132export const matchesWebhookRequestMethod = (
133 catalog: Pick<WebhookProviderCatalogEntry, "requiredHttpMethod">,
134 method: string,
135): boolean => method.toUpperCase() === catalog.requiredHttpMethod;
136 
137export const matchesWebhookRequestContentType = (
138 catalog: Pick<WebhookProviderCatalogEntry, "expectedContentType">,
139 contentType: string | null,
140): boolean => normalizeContentType(contentType) === catalog.expectedContentType;
141 
142export type WebhookConfigValidationResult =
143 | { ok: true; config: WebhookProviderConfig | null }
144 | { ok: false; status: number; code: string; message: string };
145 
146export const isRepositoryUrlWithinInstance = (repositoryUrl: string, instanceUrl: string): boolean =>
147 repositoryUrl.startsWith(`${instanceUrl}/`);
148 
149export const validateWebhookConfig = (input: {
150 provider: WebhookProvider;
151 config: WebhookProviderConfig | null;
152 projectRepoUrl: string;
153}): WebhookConfigValidationResult => {
154 const providerEntry = getWebhookProviderCatalogEntry(input.provider);
155 const defaultInstanceUrl = providerEntry.defaultInstanceUrl;
156 
157 if (input.config === null) {
158 switch (providerEntry.configMode) {
159 case "forbidden": {
160 if (input.provider === "github") {
161 if (!isRepositoryUrlWithinInstance(input.projectRepoUrl, "https://github.com")) {
162 return {
163 ok: false,
164 status: 400,
165 code: "invalid_webhook_provider_config",
166 message: `${providerEntry.displayName} webhooks require a repository URL served by https://github.com.`,
167 };
168 }
169 }
170 return { ok: true, config: null };
171 }
172 case "optional": {
173 if (!defaultInstanceUrl) {
174 return {
175 ok: false,
176 status: 500,
177 code: "invalid_webhook_provider_config",
178 message: `${providerEntry.displayName} default instance URL is not configured.`,
179 };
180 }
181 if (!isRepositoryUrlWithinInstance(input.projectRepoUrl, defaultInstanceUrl)) {
182 return {
183 ok: false,
184 status: 400,
185 code: "invalid_webhook_provider_config",
186 message: `${providerEntry.displayName} webhooks require a repository URL served by ${defaultInstanceUrl}.`,
187 };
188 }
189 return { ok: true, config: null };
190 }
191 case "required":
192 return {
193 ok: false,
194 status: 400,
195 code: "invalid_webhook_provider_config",
196 message: `${providerEntry.displayName} webhooks require instanceUrl in v1.`,
197 };
198 }
199 }
200 
201 if (providerEntry.configMode === "forbidden") {
202 return {
203 ok: false,
204 status: 400,
205 code: "invalid_webhook_provider_config",
206 message: `${providerEntry.displayName} webhooks do not support a custom instanceUrl in v1.`,
207 };
208 }
209 
210 if (!isRepositoryUrlWithinInstance(input.projectRepoUrl, input.config.instanceUrl)) {
211 return {
212 ok: false,
213 status: 400,
214 code: "invalid_webhook_provider_config",
215 message: `${providerEntry.displayName} webhooks require a repository URL served by ${input.config.instanceUrl}.`,
216 };
217 }
218 
219 if (providerEntry.configMode === "optional" && input.config.instanceUrl === defaultInstanceUrl) {
220 return { ok: true, config: null };
221 }
222 
223 return { ok: true, config: { instanceUrl: input.config.instanceUrl } };
224};
225 
226export const validateWebhookConfigForUpsert = (input: {
227 provider: WebhookProvider;
228 projectRepoUrl: string;
229 incomingConfig: WebhookProviderConfig | null | undefined;
230 existingConfig: WebhookProviderConfig | null;
231 creating: boolean;
232}): WebhookConfigValidationResult => {
233 const config =
234 input.incomingConfig === undefined ? (input.creating ? null : input.existingConfig) : input.incomingConfig;
235 return validateWebhookConfig({ provider: input.provider, config, projectRepoUrl: input.projectRepoUrl });
236};