1
/** GitHub HTTP authentication, parsing, and fire-and-forget dispatch. */3
import type { Context } from '@deepseek-ai/cordis'4
import type { IncomingMessage, ServerResponse } from 'node:http'5
import { Webhooks } from '@octokit/webhooks'6
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'7
import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'8
import {9
WebhookDeliveryId,10
WebhookSourceId,11
type VerifiedWebhookDelivery,12
} from '@deepseek-ai/dsh-webhook'13
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'14
import { readBoundedUtf8Body, WebhookHttpError } from './body.ts'15
import type { GitHubJsonObject } from './types.ts'17
/** Handler values validated once at plugin load. */18
export interface GitHubWebhookHandlerConfig {19
readonly source: string20
readonly secretEnv: CredentialRef21
readonly maxBodyBytes: number22
}24
/** Require one unambiguous non-empty request header. */25
function requiredHeader(request: IncomingMessage, name: string): string {26
const values = request.headersDistinct[name]27
const value = values?.[0]28
if (values?.length !== 1 || value === undefined || value.trim() === '') {29
throw new WebhookHttpError(400, `missing ${name} header`)30
}31
return value32
}34
/** Whether Content-Type names JSON with at most one UTF-8 charset parameter. */35
function isJsonContentType(value: string | undefined): boolean {36
if (value === undefined) return false37
const parts = value.split(';').map(part => part.trim())38
const [mediaType, parameter, ...extra] = parts39
if (mediaType?.toLowerCase() !== 'application/json') return false40
if (parameter === undefined) return true41
return extra.length === 0 && /^charset=(?:utf-8|"utf-8")$/i.test(parameter)42
}44
/** Send one empty or plain-text response exactly once. */45
function respond(response: ServerResponse, status: number, message?: string): void {46
if (message === undefined) {47
response.writeHead(status)48
response.end()49
return50
}51
response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })52
response.end(message)53
}55
/** Convert a parsed value into the adapter's generic signed-object guarantee. */56
function parsePayload(body: string): GitHubJsonObject {57
let parsed: unknown58
try {59
parsed = JSON.parse(body)60
} catch {61
// JSON.parse is the only statement in the try; no other failure is normalized.62
throw new WebhookHttpError(400, 'request body is not valid JSON')63
}64
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {65
throw new WebhookHttpError(400, 'GitHub webhook payload must be a JSON object')66
}67
const snapshot = snapshotJsonValue(parsed)68
if (snapshot === undefined) throw new WebhookHttpError(400, 'GitHub webhook payload is not lossless JSON')69
return snapshot as GitHubJsonObject70
}72
/**73
* Create one exact-route GitHub handler.74
* @param ctx - adapter context carrying credentials and webhook runtime.75
* @param config - validated source, credential reference, and body ceiling.76
* @returns an HTTP handler that answers after in-memory dispatch, never rule settlement.77
*/78
export function createGitHubWebhookHandler(79
ctx: Context,80
config: GitHubWebhookHandlerConfig,81
): WebRoute['handler'] {82
return async (request, response) => {83
try {84
if (request.method !== 'POST') {85
response.setHeader('allow', 'POST')86
throw new WebhookHttpError(405, 'method not allowed')87
}88
if (!isJsonContentType(request.headers['content-type'])) {89
throw new WebhookHttpError(415, 'content type must be application/json')90
}91
const body = await readBoundedUtf8Body(request, config.maxBodyBytes)92
const signature = requiredHeader(request, 'x-hub-signature-256')93
const deliveryId = requiredHeader(request, 'x-github-delivery')94
const eventName = requiredHeader(request, 'x-github-event')95
const credential = await ctx.credentials.resolve(config.secretEnv)96
if (credential === undefined || credential.value === '') {97
throw new WebhookHttpError(503, 'GitHub webhook secret is unavailable')98
}99
let verified = false100
try {101
verified = await new Webhooks({ secret: credential.value }).verify(body, signature)102
} catch {103
// Octokit verification errors carry no response detail safe or useful to the sender.104
}105
if (!verified) throw new WebhookHttpError(401, 'invalid webhook signature')106
const payload = parsePayload(body)107
const delivery: VerifiedWebhookDelivery<'github'> = {108
kind: 'github',109
source: WebhookSourceId(config.source),110
deliveryId: WebhookDeliveryId(deliveryId),111
event: { name: eventName, payload },112
receivedAt: Date.now(),113
}114
try {115
ctx.webhookRuntime.dispatch(delivery)116
} catch {117
ctx.logger.warn('webhook-github: dispatch unavailable')118
throw new WebhookHttpError(503, 'webhook runtime is unavailable')119
}120
respond(response, 202)121
} catch (error: unknown) {122
if (error instanceof WebhookHttpError) {123
respond(response, error.status, error.message)124
return125
}126
ctx.logger.warn('webhook-github: request failed')127
respond(response, 503, 'webhook ingress is unavailable')128
}129
}130
}