File
Blob: src/lib/webhooks.ts
| 1 | import type { WebhookProvider, WebhookProviderConfig } from "@/contracts"; |
| 2 | |
| 3 | export type WebhookConfigMode = "forbidden" | "optional" | "required"; |
| 4 | export type WebhookVerificationKind = "hmac-sha256" | "shared-secret"; |
| 5 | export type WebhookQueueFullResponseStatus = 200 | 503; |
| 6 | |
| 7 | export 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 | |
| 27 | export 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 | |
| 44 | export 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 | |
| 124 | export const webhookProviderCatalogList = Object.values(webhookProviderCatalog); |
| 125 | |
| 126 | export const getWebhookProviderCatalogEntry = (provider: WebhookProvider): WebhookProviderCatalogEntry => |
| 127 | webhookProviderCatalog[provider]; |
| 128 | |
| 129 | const normalizeContentType = (value: string | null): string | null => |
| 130 | value?.split(";")[0]?.trim().toLowerCase() ?? null; |
| 131 | |
| 132 | export const matchesWebhookRequestMethod = ( |
| 133 | catalog: Pick<WebhookProviderCatalogEntry, "requiredHttpMethod">, |
| 134 | method: string, |
| 135 | ): boolean => method.toUpperCase() === catalog.requiredHttpMethod; |
| 136 | |
| 137 | export const matchesWebhookRequestContentType = ( |
| 138 | catalog: Pick<WebhookProviderCatalogEntry, "expectedContentType">, |
| 139 | contentType: string | null, |
| 140 | ): boolean => normalizeContentType(contentType) === catalog.expectedContentType; |
| 141 | |
| 142 | export type WebhookConfigValidationResult = |
| 143 | | { ok: true; config: WebhookProviderConfig | null } |
| 144 | | { ok: false; status: number; code: string; message: string }; |
| 145 | |
| 146 | export const isRepositoryUrlWithinInstance = (repositoryUrl: string, instanceUrl: string): boolean => |
| 147 | repositoryUrl.startsWith(`${instanceUrl}/`); |
| 148 | |
| 149 | export 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 | |
| 226 | export 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 | }; |