feat(webhook): create workspace sessions from GitHub events

This commit is contained in:
Tianyi Cui
2026-08-23 01:48:35 +08:00
parent c6c9426efb
commit 5f60e50d71
90 changed files with 3599 additions and 29 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/webhook/webhook-github/README.md
README.md: e79ff29f72eee5c9c48fbd96882a65f26ab88ca3
README.zh.md: 97fb162da3633dd971ba832cc603dd8390bc97ee
+51
View File
@@ -0,0 +1,51 @@
# @deepseek-ai/dsh-webhook-github
English | [中文](README.zh.md)
`dsh-webhook-github` registers one exact HTTP route on the injected `ctx.webServer`. It bounds and verifies GitHub's raw JSON body, projects a provider-neutral delivery, calls `ctx.webhookRuntime.dispatch()`, and returns `202` without waiting for rules or Sessions.
## Configuration
| Key | Meaning |
|---|---|
| `source` | Non-empty adapter instance carried to rules, such as `primary-github`. |
| `path` | Exact non-root pathname without trailing slash, query, or fragment. |
| `secretEnv` | Credential reference containing the GitHub webhook secret. |
| `maxBodyBytes` | Positive safe-integer ceiling for the untouched request body. |
All fields are required. The secret reference is resolved for every request, so rotation affects the next delivery without reloading the plugin.
## HTTP contract
Only `POST application/json` is accepted. The adapter reads a bounded UTF-8 body, requires `X-Hub-Signature-256`, `X-GitHub-Delivery`, and `X-GitHub-Event`, resolves the secret, verifies HMAC before JSON parsing, and requires a top-level lossless-JSON object. It never logs the secret, signature, or payload.
| Status | Meaning |
|---|---|
| `202` | Verified JSON was dispatched in memory. |
| `400` | Required header, UTF-8, JSON, or top-level object was invalid. |
| `401` | Signature was missing or invalid. |
| `405` | Method was not `POST`. |
| `413` | Declared or streamed body exceeded `maxBodyBytes`. |
| `415` | Media type was not `application/json`. |
| `503` | Credential or webhook runtime was unavailable. |
`202` does not state that any rule matched or that a Session was created. GitHub event-specific field validation belongs to each rule; the adapter guarantees only authenticated generic JSON.
## Dedicated listener composition
The normal Web profile already owns `ctx.webServer`. Mount another `dsh-host-webserver` and this adapter inside a group that isolates only `webServer`; the adapter still inherits credentials and `webhookRuntime`. The [GitHub review example](../../../examples/web-github-review/README.md) uses `127.0.0.1:3081/github` behind a TLS reverse proxy while the UI remains on port 3080.
## Model Experience
Indirectly, through `dsh-webhook`: this adapter contributes no prompt or tool schema; a matching rule owns the Session request and model-visible text.
#### KV Cache effect
Independent. Authentication and HTTP dispatch do not touch a model request; any new Session prefix belongs to the consuming rule and runtime.
## Known Limitations and Deferred Work
- **No TLS** — the injected development WebServer is normally loopback-only behind a TLS reverse proxy or tunnel.
- **Generic payload validation only** — rules own validation of the GitHub event fields they consume.
- **No provider acknowledgement of downstream work** — `202` precedes arbitrary rule calls and Session creation.
- **No form encoding** — GitHub must send `application/json`; `application/x-www-form-urlencoded` is rejected.
@@ -0,0 +1,51 @@
# @deepseek-ai/dsh-webhook-github
[English](README.md) | 中文
`dsh-webhook-github` 会在注入的 `ctx.webServer` 上注册一条精确 HTTP 路由。它限制并验证 GitHub 原始 JSON body,投影提供方无关的交付,调用 `ctx.webhookRuntime.dispatch()`,并在不等待规则或 Session 的情况下返回 `202`
## 配置
| Key | 含义 |
|---|---|
| `source` | 携带给规则的非空适配器实例,例如 `primary-github`。 |
| `path` | 不带尾随斜杠、查询或片段的精确非根路径。 |
| `secretEnv` | 包含 GitHub webhook 密钥的凭据引用。 |
| `maxBodyBytes` | 未改动请求 body 的正安全整数上限。 |
所有字段均为必填。每次请求都会重新解析密钥引用,因此轮换会在下一次交付生效,而无需重新加载插件。
## HTTP 约定
只接受 `POST application/json`。适配器读取有界 UTF-8 body,要求 `X-Hub-Signature-256``X-GitHub-Delivery``X-GitHub-Event`,解析密钥,在 JSON 解析前验证 HMAC,并要求顶层是无损 JSON 对象。它绝不记录密钥、签名或 payload。
| 状态 | 含义 |
|---|---|
| `202` | 已验证 JSON 已在内存中分发。 |
| `400` | 必需 header、UTF-8、JSON 或顶层对象无效。 |
| `401` | 签名缺失或无效。 |
| `405` | 方法不是 `POST`。 |
| `413` | 声明或流式 body 超过 `maxBodyBytes`。 |
| `415` | media type 不是 `application/json`。 |
| `503` | 凭据或 webhook runtime 不可用。 |
`202` 不表示任何规则已经匹配,也不表示已创建 Session。GitHub 事件特定字段的验证属于各规则;适配器只保证通过身份验证的通用 JSON。
## 专用监听器组合
普通 Web profile 已经拥有 `ctx.webServer`。把另一个 `dsh-host-webserver` 和此适配器挂载到仅隔离 `webServer` 的 group 内;适配器仍会继承凭据与 `webhookRuntime`。[GitHub 评审示例](../../../examples/web-github-review/README.zh.md)在 TLS 反向代理后使用 `127.0.0.1:3081/github`,而 UI 继续位于端口 3080。
## Model Experience
通过 `dsh-webhook` 间接产生影响:此适配器不贡献提示词或工具 schema;匹配规则拥有 Session 请求与模型可见文本。
#### KV Cache effect
相互独立。身份验证与 HTTP 分发不触碰模型请求;任何新 Session 前缀都属于消费它的规则与 runtime。
## Known Limitations and Deferred Work
- **无 TLS** — 注入的开发 WebServer 通常只监听 loopback,并位于 TLS 反向代理或 tunnel 后。
- **仅通用 payload 验证** — 规则负责验证自己消费的 GitHub 事件字段。
- **不向提供方确认下游工作** — `202` 先于任意规则调用与 Session 创建。
- **不支持表单编码** — GitHub 必须发送 `application/json``application/x-www-form-urlencoded` 会被拒绝。
@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-webhook-github",
"description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime",
"version": "0.1.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/webhook/webhook-github"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-webhook": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"@octokit/webhooks": "^14.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-webhook": "workspace:^"
}
}
@@ -0,0 +1,69 @@
/** Bounded raw HTTP body intake for GitHub signature verification. */
import type { IncomingMessage } from 'node:http'
/** HTTP refusal whose message is safe to return without request data. */
export class WebhookHttpError extends Error {
override readonly name = 'WebhookHttpError'
constructor(
readonly status: 400 | 401 | 405 | 413 | 415 | 503,
message: string,
) {
super(message)
}
}
/** Parse a decimal Content-Length or reject an ambiguous header. */
function contentLength(request: IncomingMessage): number | undefined {
const value = request.headers['content-length']
if (value === undefined) return undefined
if (!/^(0|[1-9]\d*)$/.test(value)) {
throw new WebhookHttpError(400, 'invalid Content-Length')
}
const length = Number(value)
if (!Number.isSafeInteger(length)) throw new WebhookHttpError(413, 'request body is too large')
return length
}
/**
* Read one request body as exact, bounded UTF-8 text.
* @param request - incoming request before any parser consumes it.
* @param maxBodyBytes - positive byte ceiling.
* @returns the decoded body after EOF.
* @throws {WebhookHttpError} for invalid length, excessive bytes, invalid UTF-8, or an aborted stream.
*/
export async function readBoundedUtf8Body(
request: IncomingMessage,
maxBodyBytes: number,
): Promise<string> {
const declared = contentLength(request)
if (declared !== undefined && declared > maxBodyBytes) {
request.resume()
throw new WebhookHttpError(413, 'request body is too large')
}
const chunks: Buffer[] = []
let size = 0
try {
for await (const raw of request) {
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw as string)
size += chunk.byteLength
if (size > maxBodyBytes) {
request.resume()
throw new WebhookHttpError(413, 'request body is too large')
}
chunks.push(chunk)
}
} catch (error: unknown) {
if (error instanceof WebhookHttpError) throw error
throw new WebhookHttpError(400, 'request body was aborted')
}
if (!request.complete) throw new WebhookHttpError(400, 'request body was aborted')
try {
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks, size))
} catch {
// TextDecoder is the only statement in the try; GitHub JSON must be valid UTF-8.
throw new WebhookHttpError(400, 'request body is not valid UTF-8')
}
}
@@ -0,0 +1,130 @@
/** 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')
}
}
}
@@ -0,0 +1,60 @@
/** Signed GitHub HTTP adapter for the provider-neutral webhook runtime. */
import type { Context } from '@deepseek-ai/cordis'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type {} from '@deepseek-ai/dsh-host-webserver'
import z from '@deepseek-ai/schemastery'
import { createGitHubWebhookHandler } from './handler.ts'
/** Cordis function-plugin name. */
export const name = 'webhook-github'
/** Host services required before the exact route can register. */
export const inject = ['webServer', 'webhookRuntime', 'credentials']
/** Required GitHub ingress configuration. */
export interface Config {
/** Adapter instance name carried to rules. */
readonly source: string
/** Exact absolute route path. */
readonly path: string
/** Credential reference containing the shared webhook secret. */
readonly secretEnv: string
/** Positive raw body ceiling in bytes. */
readonly maxBodyBytes: number
}
export const Config: z<Config> = z.object({
source: z.string().required(),
path: z.string().required(),
secretEnv: z.string().role('credential-ref').required(),
maxBodyBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).required(),
})
/** Validate route and source facts that Schemastery cannot express. */
function assertConfig(config: Config): void {
if (config.source.trim() !== config.source || config.source === '') {
throw new Error('webhook-github source must be a non-empty trimmed string')
}
if (!config.path.startsWith('/') || config.path === '/' || config.path.endsWith('/')
|| config.path.includes('?') || config.path.includes('#')) {
throw new Error('webhook-github path must be an absolute non-root pathname without a trailing slash, query, or fragment')
}
}
/** Register one signed GitHub endpoint on the injected WebServer. */
export function apply(ctx: Context, config: Config): void {
assertConfig(config)
const route = {
kind: 'exact' as const,
path: config.path,
handler: createGitHubWebhookHandler(ctx, {
source: config.source,
secretEnv: credentialRef(config.secretEnv),
maxBodyBytes: config.maxBodyBytes,
}),
}
ctx.effect(
() => ctx.webServer.register(route),
`webhook-github: ${config.path}`,
)
}
@@ -0,0 +1,25 @@
/** Package-owned invariant companion for the GitHub webhook adapter. */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-webhook-github'
/** Cordis invariant-companion plugin name. */
export const name = 'webhook-github-invariant'
/** Registry required before reserving this package's invariant ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: authentication and input validation occur at the exact
* HTTP operation; dsh-host-webserver owns route/disposer symmetry.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's explained empty invariant.
* @param ctx - Cordis context carrying the invariant registry.
* @returns the invariant registration disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
@@ -0,0 +1,22 @@
/** GitHub event values projected after signature verification. */
import type { JsonValue } from '@deepseek-ai/dsh-session'
/** Signed GitHub JSON object. Event-specific field validation belongs to each rule. */
export type GitHubJsonObject = { readonly [key: string]: JsonValue }
/** Provider event supplied to `WebhookRule<'github'>`. */
export interface GitHubWebhookEvent {
/** Raw `X-GitHub-Event` name such as `pull_request`. */
readonly name: string
/** Signed JSON object exactly as parsed from the request body. */
readonly payload: GitHubJsonObject
}
declare module '@deepseek-ai/dsh-webhook' {
interface WebhookEventMap {
github: GitHubWebhookEvent
}
}
export type { EmitterWebhookEvent, EmitterWebhookEventName } from '@octokit/webhooks'
@@ -0,0 +1,57 @@
import type { IncomingMessage } from 'node:http'
import { describe, expect, it, vi } from 'vitest'
import { readBoundedUtf8Body } from '../src/body.ts'
/** Minimal async-iterable request for byte-level branches Node fetch cannot construct. */
function request(options: {
chunks?: Array<Buffer | string>
contentLength?: string
complete?: boolean
error?: unknown
} = {}): IncomingMessage & { resume: ReturnType<typeof vi.fn> } {
const resume = vi.fn()
return {
headers: {
...(options.contentLength === undefined ? {} : { 'content-length': options.contentLength }),
},
complete: options.complete ?? true,
resume,
async * [Symbol.asyncIterator]() {
for (const chunk of options.chunks ?? []) yield chunk
if (options.error !== undefined) throw options.error
},
} as unknown as IncomingMessage & { resume: ReturnType<typeof vi.fn> }
}
describe('bounded webhook body intake', () => {
it('accepts an absent length and both Buffer and string chunks', async () => {
await expect(readBoundedUtf8Body(request({ chunks: [Buffer.from('{'), '}'] }), 2)).resolves.toBe('{}')
})
it('rejects malformed, unsafe, and oversized declared lengths', async () => {
await expect(readBoundedUtf8Body(request({ contentLength: '01' }), 10)).rejects.toMatchObject({ status: 400 })
await expect(readBoundedUtf8Body(request({ contentLength: '999999999999999999999' }), Number.MAX_SAFE_INTEGER))
.rejects.toMatchObject({ status: 413 })
const oversized = request({ contentLength: '3' })
await expect(readBoundedUtf8Body(oversized, 2)).rejects.toMatchObject({ status: 413 })
expect(oversized.resume).toHaveBeenCalledOnce()
})
it('rejects a chunked body at the first byte beyond the cap', async () => {
const streamed = request({ chunks: [Buffer.from('ab'), Buffer.from('c')] })
await expect(readBoundedUtf8Body(streamed, 2)).rejects.toMatchObject({ status: 413 })
expect(streamed.resume).toHaveBeenCalledOnce()
})
it('normalizes stream failure and incomplete EOF as an aborted body', async () => {
await expect(readBoundedUtf8Body(request({ error: new Error('socket') }), 10))
.rejects.toMatchObject({ status: 400, message: 'request body was aborted' })
await expect(readBoundedUtf8Body(request({ complete: false }), 10))
.rejects.toMatchObject({ status: 400, message: 'request body was aborted' })
})
it('rejects invalid UTF-8 after a complete bounded read', async () => {
await expect(readBoundedUtf8Body(request({ chunks: [Buffer.from([0xff])] }), 1))
.rejects.toMatchObject({ status: 400, message: 'request body is not valid UTF-8' })
})
})
@@ -0,0 +1,53 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apply, type Config } from '../src/index.ts'
const contexts: Context[] = []
afterEach(async () => {
await Promise.allSettled(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
/** Context with only the services direct apply reads. */
function harness(): { ctx: Context; register: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> } {
const ctx = new Context()
contexts.push(ctx)
const remove = vi.fn()
const register = vi.fn(() => remove)
ctx.provide('webServer', { register } as never)
ctx.provide('webhookRuntime', {} as never)
ctx.provide('credentials', {} as never)
return { ctx, register, remove }
}
const valid = {
source: 'primary',
path: '/github',
secretEnv: 'DSH_GITHUB_WEBHOOK_SECRET',
maxBodyBytes: 1024,
} satisfies Config
describe('GitHub webhook plugin config', () => {
it('registers one exact route and removes it with the plugin fiber', async () => {
const test = harness()
apply(test.ctx, valid)
expect(test.register).toHaveBeenCalledWith(expect.objectContaining({ kind: 'exact', path: '/github' }))
await test.ctx.fiber.dispose()
expect(test.remove).toHaveBeenCalledOnce()
})
it.each([
[{ ...valid, source: '' }, /source/],
[{ ...valid, source: ' primary' }, /source/],
[{ ...valid, path: 'github' }, /path/],
[{ ...valid, path: '/' }, /path/],
[{ ...valid, path: '/github/' }, /path/],
[{ ...valid, path: '/github?q=1' }, /path/],
[{ ...valid, path: '/github#x' }, /path/],
[{ ...valid, secretEnv: 'not valid' }, /credential ref/],
] as const)('rejects invalid config %# before route registration', (config, message) => {
const test = harness()
expect(() => { apply(test.ctx, config) }).toThrow(message)
expect(test.register).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,216 @@
import { createHmac } from 'node:crypto'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import { createGitHubWebhookHandler } from '../src/handler.ts'
const servers: Server[] = []
afterEach(async () => {
await Promise.all(servers.splice(0).map(server => new Promise<void>(resolve => server.close(() => { resolve() }))))
})
/** One mutable fake for credential rotation and dispatch observation. */
function fakeContext(secret = 'fixture-secret'): {
ctx: Context
dispatch: ReturnType<typeof vi.fn>
setSecret(value: string | undefined): void
warnings: ReturnType<typeof vi.fn>
} {
let current = secret as string | undefined
const dispatch = vi.fn()
const warnings = vi.fn()
return {
ctx: {
credentials: {
resolve: async () => current === undefined ? undefined : { value: current, source: 'environment' },
},
webhookRuntime: { dispatch },
logger: { warn: warnings },
} as unknown as Context,
dispatch,
setSecret(value) { current = value },
warnings,
}
}
/** Start a real Node server around the package-owned route handler. */
async function serve(ctx: Context, maxBodyBytes = 1024): Promise<string> {
const handler = createGitHubWebhookHandler(ctx, {
source: 'primary',
secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
maxBodyBytes,
})
const server = createServer((request, response) => { void handler(request, response) })
servers.push(server)
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const port = (server.address() as AddressInfo).port
return `http://127.0.0.1:${String(port)}`
}
/** HMAC header for one exact UTF-8 body. */
function signature(secret: string, body: string): string {
return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
}
/** Send one GitHub-shaped request. */
async function post(
base: string,
body: string,
options: {
secret?: string
signature?: string
event?: string
delivery?: string
contentType?: string
method?: string
} = {},
): Promise<Response> {
const secret = options.secret ?? 'fixture-secret'
return await fetch(base, {
method: options.method ?? 'POST',
headers: {
'content-type': options.contentType ?? 'application/json',
'x-hub-signature-256': options.signature ?? signature(secret, body),
'x-github-event': options.event ?? 'pull_request',
'x-github-delivery': options.delivery ?? 'delivery-1',
},
...(options.method === 'GET' ? {} : { body }),
})
}
describe('GitHub webhook HTTP handler', () => {
it('verifies, projects, dispatches, and answers 202', async () => {
const fake = fakeContext()
const base = await serve(fake.ctx)
const body = JSON.stringify({ action: 'ready_for_review', number: 1 })
const response = await post(base, body, { contentType: 'application/json; charset=utf-8' })
expect(response.status).toBe(202)
expect(await response.text()).toBe('')
expect(fake.dispatch).toHaveBeenCalledOnce()
const dispatched: unknown = fake.dispatch.mock.calls[0]?.[0]
expect(dispatched).toMatchObject({
kind: 'github',
source: 'primary',
deliveryId: 'delivery-1',
event: { name: 'pull_request', payload: { action: 'ready_for_review', number: 1 } },
})
expect(typeof (dispatched as { receivedAt?: unknown }).receivedAt).toBe('number')
})
it('resolves the secret for each request so rotation takes effect immediately', async () => {
const fake = fakeContext('first')
const base = await serve(fake.ctx)
const body = JSON.stringify({ ping: true })
expect((await post(base, body, { secret: 'first', delivery: 'first' })).status).toBe(202)
fake.setSecret('second')
expect((await post(base, body, { secret: 'first', delivery: 'stale' })).status).toBe(401)
expect((await post(base, body, { secret: 'second', delivery: 'second' })).status).toBe(202)
expect(fake.dispatch).toHaveBeenCalledTimes(2)
})
it.each([
['method', { method: 'GET' }, 405],
['content type', { contentType: 'text/plain' }, 415],
['content type parameter', { contentType: 'application/json; boundary=x' }, 415],
['content type parameters', { contentType: 'application/json; charset=utf-8; boundary=x' }, 415],
['signature', { signature: 'sha256=bad' }, 401],
['event header', { event: '' }, 400],
['delivery header', { delivery: '' }, 400],
] as const)('rejects an invalid %s before dispatch', async (_label, options, status) => {
const fake = fakeContext()
const base = await serve(fake.ctx)
const response = await post(base, '{}', options)
expect(response.status).toBe(status)
if (status === 405) expect(response.headers.get('allow')).toBe('POST')
expect(fake.dispatch).not.toHaveBeenCalled()
})
it('rejects a missing Content-Type before body processing', async () => {
const fake = fakeContext()
const handler = createGitHubWebhookHandler(fake.ctx, {
source: 'primary',
secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
maxBodyBytes: 1024,
})
const request = { method: 'POST', headers: {}, headersDistinct: {} } as unknown as IncomingMessage
const writeHead = vi.fn()
const response = { setHeader: vi.fn(), writeHead, end: vi.fn() } as unknown as ServerResponse
await handler(request, response)
expect(writeHead).toHaveBeenCalledWith(415, expect.any(Object))
expect(fake.dispatch).not.toHaveBeenCalled()
})
it('rejects duplicate required headers', async () => {
const fake = fakeContext()
const handler = createGitHubWebhookHandler(fake.ctx, {
source: 'primary',
secretEnv: credentialRef('DSH_GITHUB_WEBHOOK_SECRET'),
maxBodyBytes: 1024,
})
const request = {
method: 'POST',
headers: { 'content-type': 'application/json' },
headersDistinct: {
'x-hub-signature-256': ['sha256=unused'],
'x-github-delivery': ['delivery-1'],
'x-github-event': ['pull_request', 'ping'],
},
complete: true,
async * [Symbol.asyncIterator]() { yield Buffer.from('{}') },
} as unknown as IncomingMessage
const writeHead = vi.fn()
const response = { setHeader: vi.fn(), writeHead, end: vi.fn() } as unknown as ServerResponse
await handler(request, response)
expect(writeHead).toHaveBeenCalledWith(400, expect.any(Object))
expect(fake.dispatch).not.toHaveBeenCalled()
})
it.each([
['not JSON', '{', 400],
['array', '[]', 400],
['non-lossless number', '{"value":1e400}', 400],
] as const)('rejects a signed %s body', async (_label, body, status) => {
const fake = fakeContext()
const base = await serve(fake.ctx)
const response = await post(base, body)
expect(response.status).toBe(status)
expect(fake.dispatch).not.toHaveBeenCalled()
})
it('rejects declared and streamed bodies over the configured cap', async () => {
const fake = fakeContext()
const base = await serve(fake.ctx, 2)
const response = await post(base, '{} ')
expect(response.status).toBe(413)
expect(fake.dispatch).not.toHaveBeenCalled()
})
it('answers 503 when the credential or runtime is unavailable', async () => {
const missing = fakeContext()
missing.setSecret(undefined)
const missingBase = await serve(missing.ctx)
expect((await post(missingBase, '{}')).status).toBe(503)
const closing = fakeContext()
closing.dispatch.mockImplementation(() => { throw new Error('closing') })
const closingBase = await serve(closing.ctx)
expect((await post(closingBase, '{}')).status).toBe(503)
expect(closing.warnings).toHaveBeenCalledTimes(1)
})
it('does not leak the signed payload or secret in an infrastructure diagnostic', async () => {
const fake = fakeContext('super-secret')
;(fake.ctx.credentials.resolve as ReturnType<typeof vi.fn> | undefined) = vi.fn(async () => {
throw new Error('credential store unavailable')
}) as never
const base = await serve(fake.ctx)
const body = JSON.stringify({ private: 'payload-secret' })
expect((await post(base, body, { secret: 'super-secret' })).status).toBe(503)
const diagnostics = JSON.stringify(fake.warnings.mock.calls)
expect(diagnostics).not.toContain('super-secret')
expect(diagnostics).not.toContain('payload-secret')
})
})
@@ -0,0 +1,13 @@
import { Context } from '@deepseek-ai/cordis'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import { describe, expect, it } from 'vitest'
import * as GitHubInvariant from '../src/invariant.ts'
describe('GitHub webhook invariant companion', () => {
it('registers its explained empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantRegistry)
await expect(ctx.plugin(GitHubInvariant)).resolves.toBeDefined()
await ctx.fiber.dispose()
})
})
@@ -0,0 +1,90 @@
import { createHmac } from 'node:crypto'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Include from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import WebServer from '@deepseek-ai/dsh-host-webserver'
import { afterEach, describe, expect, it, vi } from 'vitest'
import * as GitHubAdapter from '../src/index.ts'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('real Loader composition', () => {
it('registers on a real WebServer and dispatches a signed request', { timeout: 60_000 }, async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-webhook-github-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
'- name: fixture-dependencies',
"- name: '@deepseek-ai/dsh-host-webserver'",
' config:',
" host: '127.0.0.1'",
' port: 0',
"- name: '@deepseek-ai/dsh-webhook-github'",
' config:',
' source: loader',
' path: /github',
' secretEnv: DSH_GITHUB_WEBHOOK_SECRET',
' maxBodyBytes: 1024',
'',
].join('\n'))
const dispatch = vi.fn()
const dependencies = {
name: 'fixture-dependencies',
apply(ctx: Context) {
ctx.provide('webhookRuntime', { dispatch } as never)
ctx.provide('credentials', {
resolve: async () => ({ value: 'loader-secret', source: 'environment' }),
} as never)
},
}
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['fixture-dependencies', dependencies],
['@deepseek-ai/dsh-host-webserver', WebServer],
['@deepseek-ai/dsh-webhook-github', GitHubAdapter],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect([...context.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)).toEqual([])
const body = JSON.stringify({ action: 'ready_for_review' })
const signature = `sha256=${createHmac('sha256', 'loader-secret').update(body).digest('hex')}`
const response = await fetch(`http://127.0.0.1:${String(context.webServer.port)}/github`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-hub-signature-256': signature,
'x-github-event': 'pull_request',
'x-github-delivery': 'loader-delivery',
},
body,
})
expect(response.status).toBe(202)
expect(dispatch).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/include"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../credentials/credentials"
},
{
"path": "../../core/session"
},
{
"path": "../../host/webserver"
},
{
"path": "../webhook"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}