handler.ts 5.1 KB

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