mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-30 04:40:37 +00:00
131 lines
5.1 KiB
TypeScript
131 lines
5.1 KiB
TypeScript
/** GitHub HTTP authentication, parsing, and fire-and-forget dispatch. */
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
import { Webhooks } from '@octokit/webhooks'
|
|
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
|
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
|
import {
|
|
WebhookDeliveryId,
|
|
WebhookSourceId,
|
|
type VerifiedWebhookDelivery,
|
|
} from '@deepseek-ai/dsh-webhook'
|
|
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
import { readBoundedUtf8Body, WebhookHttpError } from './body.ts'
|
|
import type { GitHubJsonObject } from './types.ts'
|
|
|
|
/** Handler values validated once at plugin load. */
|
|
export interface GitHubWebhookHandlerConfig {
|
|
readonly source: string
|
|
readonly secretEnv: CredentialRef
|
|
readonly maxBodyBytes: number
|
|
}
|
|
|
|
/** Require one unambiguous non-empty request header. */
|
|
function requiredHeader(request: IncomingMessage, name: string): string {
|
|
const values = request.headersDistinct[name]
|
|
const value = values?.[0]
|
|
if (values?.length !== 1 || value === undefined || value.trim() === '') {
|
|
throw new WebhookHttpError(400, `missing ${name} header`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
/** Whether Content-Type names JSON with at most one UTF-8 charset parameter. */
|
|
function isJsonContentType(value: string | undefined): boolean {
|
|
if (value === undefined) return false
|
|
const parts = value.split(';').map(part => part.trim())
|
|
const [mediaType, parameter, ...extra] = parts
|
|
if (mediaType?.toLowerCase() !== 'application/json') return false
|
|
if (parameter === undefined) return true
|
|
return extra.length === 0 && /^charset=(?:utf-8|"utf-8")$/i.test(parameter)
|
|
}
|
|
|
|
/** Send one empty or plain-text response exactly once. */
|
|
function respond(response: ServerResponse, status: number, message?: string): void {
|
|
if (message === undefined) {
|
|
response.writeHead(status)
|
|
response.end()
|
|
return
|
|
}
|
|
response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
|
|
response.end(message)
|
|
}
|
|
|
|
/** Convert a parsed value into the adapter's generic signed-object guarantee. */
|
|
function parsePayload(body: string): GitHubJsonObject {
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(body)
|
|
} catch {
|
|
// JSON.parse is the only statement in the try; no other failure is normalized.
|
|
throw new WebhookHttpError(400, 'request body is not valid JSON')
|
|
}
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new WebhookHttpError(400, 'GitHub webhook payload must be a JSON object')
|
|
}
|
|
const snapshot = snapshotJsonValue(parsed)
|
|
if (snapshot === undefined) throw new WebhookHttpError(400, 'GitHub webhook payload is not lossless JSON')
|
|
return snapshot as GitHubJsonObject
|
|
}
|
|
|
|
/**
|
|
* Create one exact-route GitHub handler.
|
|
* @param ctx - adapter context carrying credentials and webhook runtime.
|
|
* @param config - validated source, credential reference, and body ceiling.
|
|
* @returns an HTTP handler that answers after in-memory dispatch, never rule settlement.
|
|
*/
|
|
export function createGitHubWebhookHandler(
|
|
ctx: Context,
|
|
config: GitHubWebhookHandlerConfig,
|
|
): WebRoute['handler'] {
|
|
return async (request, response) => {
|
|
try {
|
|
if (request.method !== 'POST') {
|
|
response.setHeader('allow', 'POST')
|
|
throw new WebhookHttpError(405, 'method not allowed')
|
|
}
|
|
if (!isJsonContentType(request.headers['content-type'])) {
|
|
throw new WebhookHttpError(415, 'content type must be application/json')
|
|
}
|
|
const body = await readBoundedUtf8Body(request, config.maxBodyBytes)
|
|
const signature = requiredHeader(request, 'x-hub-signature-256')
|
|
const deliveryId = requiredHeader(request, 'x-github-delivery')
|
|
const eventName = requiredHeader(request, 'x-github-event')
|
|
const credential = await ctx.credentials.resolve(config.secretEnv)
|
|
if (credential === undefined || credential.value === '') {
|
|
throw new WebhookHttpError(503, 'GitHub webhook secret is unavailable')
|
|
}
|
|
let verified = false
|
|
try {
|
|
verified = await new Webhooks({ secret: credential.value }).verify(body, signature)
|
|
} catch {
|
|
// Octokit verification errors carry no response detail safe or useful to the sender.
|
|
}
|
|
if (!verified) throw new WebhookHttpError(401, 'invalid webhook signature')
|
|
const payload = parsePayload(body)
|
|
const delivery: VerifiedWebhookDelivery<'github'> = {
|
|
kind: 'github',
|
|
source: WebhookSourceId(config.source),
|
|
deliveryId: WebhookDeliveryId(deliveryId),
|
|
event: { name: eventName, payload },
|
|
receivedAt: Date.now(),
|
|
}
|
|
try {
|
|
ctx.webhookRuntime.dispatch(delivery)
|
|
} catch {
|
|
ctx.logger.warn('webhook-github: dispatch unavailable')
|
|
throw new WebhookHttpError(503, 'webhook runtime is unavailable')
|
|
}
|
|
respond(response, 202)
|
|
} catch (error: unknown) {
|
|
if (error instanceof WebhookHttpError) {
|
|
respond(response, error.status, error.message)
|
|
return
|
|
}
|
|
ctx.logger.warn('webhook-github: request failed')
|
|
respond(response, 503, 'webhook ingress is unavailable')
|
|
}
|
|
}
|
|
}
|