返回源码地图

packages/webhook/webhook-github/src/handler.ts

main snapshot · da00f7f5358f · 正文引用章节 17;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/** GitHub HTTP authentication, parsing, and fire-and-forget dispatch. */
2
3import type { Context } from '@deepseek-ai/cordis'
4import type { IncomingMessage, ServerResponse } from 'node:http'
5import { Webhooks } from '@octokit/webhooks'
6import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
7import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values'
8import {
9 WebhookDeliveryId,
10 WebhookSourceId,
11 type VerifiedWebhookDelivery,
12} from '@deepseek-ai/dsh-webhook'
13import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
14import { readBoundedUtf8Body, WebhookHttpError } from './body.ts'
15import type { GitHubJsonObject } from './types.ts'
16
17/** Handler values validated once at plugin load. */
18export interface GitHubWebhookHandlerConfig {
19 readonly source: string
20 readonly secretEnv: CredentialRef
21 readonly maxBodyBytes: number
22}
23
24/** Require one unambiguous non-empty request header. */
25function 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 value
32}
33
34/** Whether Content-Type names JSON with at most one UTF-8 charset parameter. */
35function isJsonContentType(value: string | undefined): boolean {
36 if (value === undefined) return false
37 const parts = value.split(';').map(part => part.trim())
38 const [mediaType, parameter, ...extra] = parts
39 if (mediaType?.toLowerCase() !== 'application/json') return false
40 if (parameter === undefined) return true
41 return extra.length === 0 && /^charset=(?:utf-8|"utf-8")$/i.test(parameter)
42}
43
44/** Send one empty or plain-text response exactly once. */
45function respond(response: ServerResponse, status: number, message?: string): void {
46 if (message === undefined) {
47 response.writeHead(status)
48 response.end()
49 return
50 }
51 response.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
52 response.end(message)
53}
54
55/** Convert a parsed value into the adapter's generic signed-object guarantee. */
56function parsePayload(body: string): GitHubJsonObject {
57 let parsed: unknown
58 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 GitHubJsonObject
70}
71
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 */
78export 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 = false
100 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 return
125 }
126 ctx.logger.warn('webhook-github: request failed')
127 respond(response, 503, 'webhook ingress is unavailable')
128 }
129 }
130}