From 9635ad76c6e4d200f450c41656d316ec181e916c Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Thu, 24 Sep 2026 17:26:11 -0400 Subject: [PATCH 1/3] refactor(http): share constant-time comparison between webhook checks Extracts the length-checked timingSafeEqual comparison into timingSafeCompare so the Telegram secret check and the GitHub signature check use one implementation. --- src/adapters/crypto/timing-safe-compare.ts | 14 +++ src/index.ts | 89 ++++++++++++++++--- .../crypto/timing-safe-compare.test.ts | 33 +++++++ 3 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 src/adapters/crypto/timing-safe-compare.ts create mode 100644 test/adapters/crypto/timing-safe-compare.test.ts diff --git a/src/adapters/crypto/timing-safe-compare.ts b/src/adapters/crypto/timing-safe-compare.ts new file mode 100644 index 0000000..1153948 --- /dev/null +++ b/src/adapters/crypto/timing-safe-compare.ts @@ -0,0 +1,14 @@ +// READ-001 correction: shared constant-time string comparison, previously +// duplicated as src/index.ts's isValidSecret (Telegram) and +// src/adapters/github/signature.ts's inline compare (GitHub). Length is +// checked BEFORE crypto.subtle.timingSafeEqual — that primitive requires +// equal-length inputs, and skipping the length check would either throw or +// (worse) leak length information through an exception instead of a clean +// `false`. +export function timingSafeCompare(provided: string, expected: string): boolean { + if (!provided) return false; + const providedBytes = new TextEncoder().encode(provided); + const expectedBytes = new TextEncoder().encode(expected); + if (providedBytes.byteLength !== expectedBytes.byteLength) return false; + return crypto.subtle.timingSafeEqual(providedBytes, expectedBytes); +} diff --git a/src/index.ts b/src/index.ts index 65928d0..76692cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import { Hono } from "hono"; import type { Update } from "grammy/types"; import { createSafeLogger } from "./adapters/log/safe-logger"; +import { verifyGithubSignature } from "./adapters/github/signature"; +import { timingSafeCompare } from "./adapters/crypto/timing-safe-compare"; import { buildBot } from "./composition"; import { ConfigError } from "./config-error"; import type { Env } from "./env"; @@ -12,19 +14,12 @@ const logger = createSafeLogger(); app.get("/health", (c) => c.json({ status: "ok" })); -// design.md "Webhook auth": constant-time comparison, length checked -// first, before the body is ever parsed. -function isValidSecret(provided: string | undefined, expected: string): boolean { - if (!provided) return false; - const providedBytes = new TextEncoder().encode(provided); - const expectedBytes = new TextEncoder().encode(expected); - if (providedBytes.byteLength !== expectedBytes.byteLength) return false; - return crypto.subtle.timingSafeEqual(providedBytes, expectedBytes); -} - app.post("/telegram/webhook", async (c) => { const provided = c.req.header("X-Telegram-Bot-Api-Secret-Token"); - if (!isValidSecret(provided, c.env.WEBHOOK_SECRET)) { + // design.md "Webhook auth": constant-time comparison, length checked + // first, before the body is ever parsed (READ-001: shared with the + // GitHub route's signature check via timingSafeCompare). + if (!provided || !timingSafeCompare(provided, c.env.WEBHOOK_SECRET)) { return c.text("Unauthorized", 401); } @@ -91,4 +86,76 @@ app.post("/telegram/webhook", async (c) => { return c.text("ok"); }); +// design.md "GitHub route status policy" — route skeleton only (PR3). The +// mapper, org/repo routing and Telegram delivery land in Phase 4; until +// then every signature-verified, well-formed, non-ping event is +// acknowledged and dropped (the same 2xx the design table gives an +// unsupported event/action, since nothing is wired to support one yet). +app.post("/github/webhook", async (c) => { + // RES-001, corrected: an unreadable raw body is a transient, transport- + // level failure (e.g. a broken/aborted request stream) on a request that + // was never authenticated — it is NOT the "redelivering the same bytes + // can never succeed" case (that rationale applies to malformed content + // that WAS fully read, like the JSON.parse/non-object branches below). + // design.md's status policy treats transient/unexpected failures as 500, + // so the delivery shows as failed in GitHub and can be redelivered. + let rawBody: ArrayBuffer; + try { + rawBody = await c.req.arrayBuffer(); + } catch (err) { + logger.log({ + event: "github-webhook", + outcome: "error", + errorCode: err instanceof Error ? err.name : "UnknownError", + }); + return c.text("Internal Server Error", 500); + } + const signatureHeader = c.req.header("X-Hub-Signature-256"); + + let verified: boolean; + try { + verified = await verifyGithubSignature(rawBody, signatureHeader, c.env.GITHUB_WEBHOOK_SECRET); + } catch (err) { + // GITHUB_WEBHOOK_SECRET unset/empty (design.md: 500, logged as a + // ConfigError reason — never a silent accept, never a crash). + logger.log({ + event: "github-webhook", + outcome: "error", + errorCode: err instanceof Error ? err.name : "UnknownError", + ...(err instanceof ConfigError ? { reason: err.message } : {}), + }); + return c.text("Internal Server Error", 500); + } + if (!verified) { + return c.text("Unauthorized", 401); + } + + let payload: unknown; + try { + payload = JSON.parse(new TextDecoder().decode(rawBody)); + } catch (err) { + logger.log({ + event: "github-webhook", + outcome: "error", + errorCode: err instanceof Error ? err.name : "UnknownError", + }); + return c.text("ok", 200); + } + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + logger.log({ event: "github-webhook", outcome: "error", errorCode: "MalformedPayload" }); + return c.text("ok", 200); + } + + if (c.req.header("X-GitHub-Event") === "ping") { + return c.text("ok", 200); + } + + // Placeholder until Phase 4 wires the mapper/router: acknowledge, drop, + // and log (RES-002 / design.md:27 "unsupported event or action: 200, + // logged"). `reason` is a fixed, non-sensitive string per the logging + // allowlist — never the event type, action, or any payload field. + logger.log({ event: "github-webhook", outcome: "ok", reason: "ignored:not-yet-routed" }); + return c.text("ok", 200); +}); + export default app; diff --git a/test/adapters/crypto/timing-safe-compare.test.ts b/test/adapters/crypto/timing-safe-compare.test.ts new file mode 100644 index 0000000..f037775 --- /dev/null +++ b/test/adapters/crypto/timing-safe-compare.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { timingSafeCompare } from "../../../src/adapters/crypto/timing-safe-compare"; + +// READ-001 correction: this is the constant-time comparison previously +// duplicated in src/index.ts's isValidSecret and +// src/adapters/github/signature.ts's verifyGithubSignature — extracted here +// so both call one implementation. Length is checked before +// crypto.subtle.timingSafeEqual (which requires equal-length inputs), same +// as the two call sites it replaces. + +describe("timingSafeCompare", () => { + it("returns true for two strings with identical bytes", () => { + expect(timingSafeCompare("webhook-secret-value", "webhook-secret-value")).toBe(true); + }); + + it("returns false for two same-length strings with different bytes", () => { + expect(timingSafeCompare("webhook-secret-value", "webhook-secret-diff!")).toBe(false); + }); + + it("returns false when the provided string is shorter than expected (length-checked before compare)", () => { + expect(timingSafeCompare("short", "webhook-secret-value")).toBe(false); + }); + + it("returns false when the provided string is longer than expected", () => { + expect(timingSafeCompare("webhook-secret-value-and-then-some", "webhook-secret-value")).toBe( + false, + ); + }); + + it("returns false for an empty provided value against a non-empty expected value", () => { + expect(timingSafeCompare("", "webhook-secret-value")).toBe(false); + }); +}); From ec805e86bf3ee141e7559bf5458bf88892ca3d57 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Thu, 24 Sep 2026 17:26:11 -0400 Subject: [PATCH 2/3] feat(http): add HMAC-verified GitHub webhook route POST /github/webhook verifies X-Hub-Signature-256 over the raw body before parsing. It fails closed with 500 when GITHUB_WEBHOOK_SECRET is unset or the body cannot be read, returns 401 on a bad signature, and acknowledges ping, malformed payloads and not-yet-routed events with a logged 200. --- .dev.vars.example | 4 + src/adapters/github/signature.ts | 46 +++++ src/env.ts | 4 + test/adapters/github/signature.test.ts | 66 +++++++ test/http/github-webhook.test.ts | 240 +++++++++++++++++++++++++ test/support/github-hmac.ts | 16 ++ vitest.config.ts | 1 + 7 files changed, 377 insertions(+) create mode 100644 src/adapters/github/signature.ts create mode 100644 test/adapters/github/signature.test.ts create mode 100644 test/http/github-webhook.test.ts create mode 100644 test/support/github-hmac.ts diff --git a/.dev.vars.example b/.dev.vars.example index a667f3c..4aa5cfe 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,6 +1,10 @@ # Copy to .dev.vars and fill in local values. Never commit .dev.vars. BOT_TOKEN=000000000:REPLACE_WITH_TELEGRAM_BOT_TOKEN WEBHOOK_SECRET=REPLACE_WITH_RANDOM_SECRET +# GitHub webhook HMAC secret (openssl rand -hex 32), same value configured +# on the org webhook. Missing/empty makes the /github/webhook route fail +# closed with 500 on every request. +GITHUB_WEBHOOK_SECRET=REPLACE_WITH_RANDOM_SECRET # JSON keyring: {"active":1,"keys":{"1":""}} PII_KEYRING={"active":1,"keys":{"1":"REPLACE_WITH_BASE64_32B_KEY"}} # grammY botInfo JSON, avoids a getMe call per request (see docs/key-backup.md) diff --git a/src/adapters/github/signature.ts b/src/adapters/github/signature.ts new file mode 100644 index 0000000..36190c8 --- /dev/null +++ b/src/adapters/github/signature.ts @@ -0,0 +1,46 @@ +import { ConfigError } from "../../config-error"; +import { timingSafeCompare } from "../crypto/timing-safe-compare"; + +// design.md "Signature check": validate the header shape, HMAC-SHA256 over +// the raw body bytes with the global secret, then a constant-time compare — +// all before JSON.parse (src/index.ts calls this before ever parsing the +// body). An empty or missing secret is a config error, never a verify +// against an empty key (that would make every signature "wrong" for the +// wrong reason and could mask a deploy-before-secret-is-set state as a +// generic 401 instead of a loud, redeliverable 500). +const SIGNATURE_PATTERN = /^sha256=[0-9a-f]{64}$/; + +export async function verifyGithubSignature( + rawBody: ArrayBuffer, + signatureHeader: string | null | undefined, + secret: string | null | undefined, +): Promise { + if (!secret) { + throw new ConfigError("GITHUB_WEBHOOK_SECRET is not configured"); + } + if (!signatureHeader || !SIGNATURE_PATTERN.test(signatureHeader)) { + return false; + } + + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = await crypto.subtle.sign("HMAC", key, rawBody); + const expectedHeader = `sha256=${toHex(digest)}`; + + // READ-001: shared with the Telegram route's secret check + // (src/index.ts) via timingSafeCompare — length-checked before the + // constant-time compare. The regex above already guarantees + // signatureHeader is exactly 7 + 64 chars when it got this far, so the + // length check inside timingSafeCompare is a defensive re-check, not a + // behavior the regex could actually violate. + return timingSafeCompare(signatureHeader, expectedHeader); +} + +function toHex(digest: ArrayBuffer): string { + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/env.ts b/src/env.ts index 5750ad0..26a1997 100644 --- a/src/env.ts +++ b/src/env.ts @@ -6,4 +6,8 @@ export interface Env { WEBHOOK_SECRET: string; PII_KEYRING: string; BOT_INFO: string; + // GitHub webhook HMAC secret (design.md "Secret scope" — one global + // secret). Missing/empty is a config error, never a default (see + // src/adapters/github/signature.ts). + GITHUB_WEBHOOK_SECRET: string; } diff --git a/test/adapters/github/signature.test.ts b/test/adapters/github/signature.test.ts new file mode 100644 index 0000000..7cb9fdf --- /dev/null +++ b/test/adapters/github/signature.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { ConfigError } from "../../../src/config-error"; +import { verifyGithubSignature } from "../../../src/adapters/github/signature"; +import { signHex } from "../../support/github-hmac"; + +// github-webhook spec: "HMAC Signature Verification Over Raw Body" — HMAC-SHA256 +// over the raw bytes, constant-time compare, secret misconfiguration is a +// ConfigError (never a verify-against-empty-key), all before JSON.parse. + +const SECRET = "github-webhook-secret-value"; + +function bodyBytes(body: string): ArrayBuffer { + return new TextEncoder().encode(body).buffer as ArrayBuffer; +} + +describe("verifyGithubSignature", () => { + const body = JSON.stringify({ zen: "test", hook_id: 1 }); + + it("rejects when the signature header is missing", async () => { + await expect( + verifyGithubSignature(bodyBytes(body), undefined, SECRET), + ).resolves.toBe(false); + }); + + it("rejects a signature computed from a different secret", async () => { + const wrongSig = `sha256=${await signHex(body, "a-different-secret-entirely")}`; + await expect(verifyGithubSignature(bodyBytes(body), wrongSig, SECRET)).resolves.toBe( + false, + ); + }); + + it("rejects a same-length-but-wrong-content signature (constant-time compare path)", async () => { + const rightHex = await signHex(body, SECRET); + const flippedHex = (rightHex[0] === "0" ? "1" : "0") + rightHex.slice(1); + await expect( + verifyGithubSignature(bodyBytes(body), `sha256=${flippedHex}`, SECRET), + ).resolves.toBe(false); + }); + + it("rejects a malformed header (wrong prefix/shape, never even hashed)", async () => { + await expect( + verifyGithubSignature(bodyBytes(body), "sha1=deadbeef", SECRET), + ).resolves.toBe(false); + }); + + it("accepts a signature matching the raw body under the configured secret", async () => { + const sig = `sha256=${await signHex(body, SECRET)}`; + await expect(verifyGithubSignature(bodyBytes(body), sig, SECRET)).resolves.toBe(true); + }); + + it("throws ConfigError instead of verifying when the secret is an empty string", async () => { + await expect( + verifyGithubSignature(bodyBytes(body), "sha256=anything", ""), + ).rejects.toBeInstanceOf(ConfigError); + }); + + it("throws ConfigError instead of verifying when the secret is undefined", async () => { + await expect( + verifyGithubSignature( + bodyBytes(body), + "sha256=anything", + undefined as unknown as string, + ), + ).rejects.toBeInstanceOf(ConfigError); + }); +}); diff --git a/test/http/github-webhook.test.ts b/test/http/github-webhook.test.ts new file mode 100644 index 0000000..b276381 --- /dev/null +++ b/test/http/github-webhook.test.ts @@ -0,0 +1,240 @@ +import { env } from "cloudflare:test"; +import { describe, expect, it, vi } from "vitest"; +import app from "../../src/index"; +import type { Env } from "../../src/index"; +import { signHex } from "../support/github-hmac"; + +// github-webhook spec: HMAC gate, ping/malformed/unsupported status policy, +// and the production-safety requirement that a missing/empty +// GITHUB_WEBHOOK_SECRET fails every request closed (500), never open. +// Event mapping/routing/Telegram delivery are out of scope for this PR +// (Phase 4) — every signature-verified, well-formed, non-ping event is +// acknowledged with 200 as an "unsupported for now" placeholder. + +const GITHUB_WEBHOOK_SECRET = (env as unknown as { GITHUB_WEBHOOK_SECRET: string }) + .GITHUB_WEBHOOK_SECRET; + +function post(body: string, headers: Record = {}, envOverride: unknown = env) { + return app.request( + "/github/webhook", + { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + }, + envOverride as Env, + ); +} + +async function signedPost( + body: string, + githubEvent: string, + envOverride: unknown = env, + secret: string = GITHUB_WEBHOOK_SECRET, +) { + const signature = `sha256=${await signHex(body, secret)}`; + return post( + body, + { "X-Hub-Signature-256": signature, "X-GitHub-Event": githubEvent }, + envOverride, + ); +} + +describe("POST /github/webhook — signature gate", () => { + it("rejects a request with no signature header", async () => { + const res = await post(JSON.stringify({ zen: "z" }), { "X-GitHub-Event": "ping" }); + expect(res.status).toBe(401); + }); + + it("rejects a request signed with a different secret", async () => { + const res = await signedPost( + JSON.stringify({ zen: "z" }), + "ping", + env, + "a-totally-different-secret", + ); + expect(res.status).toBe(401); + }); + + it("rejects a same-length-but-wrong-content signature", async () => { + const body = JSON.stringify({ zen: "z" }); + const rightHex = await signHex(body, GITHUB_WEBHOOK_SECRET); + const flippedHex = (rightHex[0] === "0" ? "1" : "0") + rightHex.slice(1); + const res = await post(body, { + "X-Hub-Signature-256": `sha256=${flippedHex}`, + "X-GitHub-Event": "ping", + }); + expect(res.status).toBe(401); + }); +}); + +describe("POST /github/webhook — status policy", () => { + it("returns 200 for a signature-verified ping without routing", async () => { + const res = await signedPost(JSON.stringify({ zen: "z", hook_id: 1 }), "ping"); + expect(res.status).toBe(200); + }); + + it("returns 200 for a signature-verified but malformed JSON body", async () => { + const body = "{not json"; + const signature = `sha256=${await signHex(body, GITHUB_WEBHOOK_SECRET)}`; + const res = await post(body, { + "X-Hub-Signature-256": signature, + "X-GitHub-Event": "ping", + }); + expect(res.status).toBe(200); + }); + + it("returns 200 for a signature-verified non-object JSON body (e.g. an array)", async () => { + const res = await signedPost(JSON.stringify([1, 2, 3]), "ping"); + expect(res.status).toBe(200); + }); + + it("returns 200 for a signature-verified event outside the (not yet wired) supported set", async () => { + const res = await signedPost( + JSON.stringify({ action: "opened", repository: { full_name: "o/r" } }), + "pull_request", + ); + expect(res.status).toBe(200); + }); + + it("logs the not-yet-routed event through the safe logger (RES-002, design.md:27 'unsupported event: 200, logged')", async () => { + const logs: string[] = []; + const consoleSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + logs.push(String(msg)); + }); + + const res = await signedPost( + JSON.stringify({ action: "opened", repository: { full_name: "o/r" } }), + "pull_request", + ); + + expect(res.status).toBe(200); + const entry = logs.map((l) => JSON.parse(l)).find((e) => e.event === "github-webhook"); + expect(entry).toEqual({ + event: "github-webhook", + outcome: "ok", + reason: "ignored:not-yet-routed", + }); + + consoleSpy.mockRestore(); + }); +}); + +describe("POST /github/webhook — unreadable raw body (RES-001, correction 2)", () => { + it("returns 500 (transient/unauthenticated transport failure, not a malformed-content case) and logs only allowlisted fields", async () => { + const logs: string[] = []; + const consoleSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + logs.push(String(msg)); + }); + + const brokenStream = new ReadableStream({ + start(controller) { + controller.error(new Error("stream broken — should never appear in logs")); + }, + }); + const req = new Request("https://example.com/github/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: brokenStream, + duplex: "half", + } as RequestInit); + + const res = await app.request(req, undefined, env as unknown as Env); + + expect(res.status).toBe(500); + const entry = logs.map((l) => JSON.parse(l)).find((e) => e.event === "github-webhook"); + expect(entry).toEqual({ + event: "github-webhook", + outcome: "error", + errorCode: "Error", + }); + expect(logs.join("\n")).not.toContain("stream broken"); + + consoleSpy.mockRestore(); + }); +}); + +describe("POST /github/webhook — fails closed when GITHUB_WEBHOOK_SECRET is unset", () => { + it("returns 500, never 200 or 401, for an unsigned request", async () => { + const res = await post( + JSON.stringify({ zen: "z" }), + { "X-GitHub-Event": "ping" }, + { ...env, GITHUB_WEBHOOK_SECRET: "" }, + ); + expect(res.status).toBe(500); + }); + + it("returns 500 even when the request carries a well-formed signature header", async () => { + // A well-formed sha256=<64 hex> header, signed under the real test + // secret — the point is that the ConfigError check on the unset/empty + // secret must fire before any signature comparison, so this must still + // be 500, never a 401 (an empty secret can never make a signature + // "valid" and get treated as authenticated). + const body = JSON.stringify({ zen: "z" }); + const signature = `sha256=${await signHex(body, GITHUB_WEBHOOK_SECRET)}`; + const res = await post( + body, + { "X-Hub-Signature-256": signature, "X-GitHub-Event": "ping" }, + { ...env, GITHUB_WEBHOOK_SECRET: "" }, + ); + expect(res.status).toBe(500); + }); + + it("never crashes: always returns a well-formed HTTP response", async () => { + const res = await post( + JSON.stringify({ zen: "z" }), + {}, + { ...env, GITHUB_WEBHOOK_SECRET: "" }, + ); + expect(res.status).toBe(500); + await expect(res.text()).resolves.toBeTypeOf("string"); + }); + + it("does not affect the Telegram webhook route", async () => { + const res = await app.request( + "/telegram/webhook", + { + method: "POST", + headers: { + "content-type": "application/json", + "X-Telegram-Bot-Api-Secret-Token": (env as unknown as { WEBHOOK_SECRET: string }) + .WEBHOOK_SECRET, + }, + body: JSON.stringify({ update_id: 1 }), + }, + { ...env, GITHUB_WEBHOOK_SECRET: "" } as Env, + ); + expect(res.status).toBe(200); + }); + + it("does not affect /health", async () => { + const res = await app.request( + "/health", + { method: "GET" }, + { ...env, GITHUB_WEBHOOK_SECRET: "" } as Env, + ); + expect(res.status).toBe(200); + }); +}); + +describe("POST /github/webhook — never logs payload, signature or secret", () => { + it("logs no line containing the raw body, the signature header, or the secret", async () => { + const logs: string[] = []; + const consoleSpy = vi.spyOn(console, "log").mockImplementation((msg) => { + logs.push(String(msg)); + }); + + const body = JSON.stringify({ zen: "leak-marker-zen-value", hook_id: 999 }); + const signature = `sha256=${await signHex(body, GITHUB_WEBHOOK_SECRET)}`; + await post(body, { "X-Hub-Signature-256": signature, "X-GitHub-Event": "push" }); + await post("{not json", { "X-Hub-Signature-256": signature, "X-GitHub-Event": "ping" }); + await post(body, { "X-Hub-Signature-256": `sha256=${"0".repeat(64)}` }); + + const logged = logs.join("\n"); + expect(logged).not.toContain("leak-marker-zen-value"); + expect(logged).not.toContain(signature); + expect(logged).not.toContain(GITHUB_WEBHOOK_SECRET); + + consoleSpy.mockRestore(); + }); +}); diff --git a/test/support/github-hmac.ts b/test/support/github-hmac.ts new file mode 100644 index 0000000..8356300 --- /dev/null +++ b/test/support/github-hmac.ts @@ -0,0 +1,16 @@ +// Shared test helper (READ-002 correction): both +// test/adapters/github/signature.test.ts and test/http/github-webhook.test.ts +// need to compute the exact `X-Hub-Signature-256` hex digest a real GitHub +// delivery would send, so the duplicate was extracted here instead of +// copy-pasted in each file. +export async function signHex(body: string, secret: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(body)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/vitest.config.ts b/vitest.config.ts index 38e7c29..bf59971 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,6 +21,7 @@ export default defineConfig(async () => { // .dev.vars.example's documented shapes. BOT_TOKEN: "000000000:TEST-TOKEN-NOT-REAL", WEBHOOK_SECRET: "test-webhook-secret-value", + GITHUB_WEBHOOK_SECRET: "test-github-webhook-secret-value", PII_KEYRING: JSON.stringify({ active: 1, keys: { "1": Buffer.alloc(32, 9).toString("base64") }, From 2ec4c2045edb2c868f8f1fb8eb84edb9d7ab6867 Mon Sep 17 00:00:00 2001 From: TOMOKI977 Date: Thu, 24 Sep 2026 17:26:12 -0400 Subject: [PATCH 3/3] docs(openspec): mark github-alerts phase 3 complete --- .../changes/github-alerts/apply-progress.md | 167 ++++++++++++++++++ openspec/changes/github-alerts/tasks.md | 8 +- 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/openspec/changes/github-alerts/apply-progress.md b/openspec/changes/github-alerts/apply-progress.md index 397876c..c274cb8 100644 --- a/openspec/changes/github-alerts/apply-progress.md +++ b/openspec/changes/github-alerts/apply-progress.md @@ -243,3 +243,170 @@ Applied on `feat/github-alerts-d1`, still no commit/push, `.codegraph/` untouche ### Status (after correction) All 4 confirmed PR2 review findings addressed. Full suite: `npx vitest run` → 260/260 pass. `npx tsc --noEmit` → clean, no errors. Two genuine bugs found and fixed (the `upsert` tenant-scoping bug in production code, and the case-matching divergence in the fake); the remaining findings were untested-but-correct behavior, now pinned down by tests. No commit/push made; `.codegraph/` untouched; task 6.3 left checked as instructed. + +## PR3 — Signature and Route Skeleton (Phase 3) + +**Mode**: Strict TDD, RED → GREEN per module (signature.ts first, then the route). Scope explicitly limited to `signature.ts` + the `POST /github/webhook` route skeleton (401/500/ping/malformed-JSON) + the `GITHUB_WEBHOOK_SECRET` binding — no event mapping, org/repo routing, or Telegram delivery (Phase 4). Still on `feat/github-alerts-route` (from `main` at `5b6d4f4`, includes PR1 + PR2). No commit made — working tree only, per instruction. `.codegraph/` untouched. + +### Completed Tasks + +- [x] 3.1 RED: `test/adapters/github/signature.test.ts` — missing header, wrong secret, right-length-wrong-content (constant-time path), malformed header shape, valid signature, empty secret, undefined secret. +- [x] 3.2 GREEN: `src/adapters/github/signature.ts` — `verifyGithubSignature(rawBody, signatureHeader, secret)`. Secret emptiness checked first (throws `ConfigError`, never verifies against an empty key); header validated against `^sha256=[0-9a-f]{64}$`; HMAC-SHA256 via `crypto.subtle.importKey`/`sign` over the raw `ArrayBuffer`; length-checked, then `crypto.subtle.timingSafeEqual` (same WebCrypto primitive already proven available by `test/runtime-assumptions/timing-safe-equal.test.ts`, same length-check-then-compare pattern as `src/index.ts`'s `isValidSecret`). +- [x] 3.3 RED: `test/http/github-webhook.test.ts` — signature gate (missing/wrong/same-length-wrong → 401), status policy (ping → 200, malformed JSON → 200, non-object JSON → 200, unsupported/not-yet-wired event → 200), production-safety fail-closed suite (empty secret → 500 for unsigned, well-formed-signed, and header-less requests; never crashes; Telegram route and `/health` unaffected), and a no-leak suite (payload/signature/secret never appear in `console.log` output). D1-failure-500 scenario from the tasks.md task-3.3 wording is deferred — see Deviations below. +- [x] 3.4 GREEN: `src/index.ts` — `app.post("/github/webhook", ...)`; `src/env.ts` — `GITHUB_WEBHOOK_SECRET: string`; `.dev.vars.example` — documented placeholder; `vitest.config.ts` — `GITHUB_WEBHOOK_SECRET: "test-github-webhook-secret-value"` miniflare binding. + +### Files Changed + +| File | Action | What Was Done | +|------|--------|---------------| +| `src/adapters/github/signature.ts` | Created | `verifyGithubSignature` — HMAC-SHA256 over raw bytes, constant-time compare, `ConfigError` on empty/missing secret | +| `test/adapters/github/signature.test.ts` | Created | 7 tests covering every HMAC Signature Verification scenario in `specs/github-webhook/spec.md` plus the two secret-misconfiguration cases | +| `src/index.ts` | Modified | Added `POST /github/webhook`: reads raw `arrayBuffer()`, verifies signature before any `JSON.parse`, 401 on failed verification, 500 + `ConfigError`-reason log on unset/empty secret, 200 for `ping`/malformed JSON/non-object payload/any other event (placeholder pending Phase 4) | +| `src/env.ts` | Modified | Added `GITHUB_WEBHOOK_SECRET: string` to `Env` | +| `.dev.vars.example` | Modified | Added `GITHUB_WEBHOOK_SECRET` placeholder with a one-line rollout note | +| `vitest.config.ts` | Modified | Added `GITHUB_WEBHOOK_SECRET` miniflare test binding, mirroring `WEBHOOK_SECRET`'s existing test-only-value pattern | +| `test/http/github-webhook.test.ts` | Created | 13 tests: signature gate, status policy, fail-closed-on-unset-secret (including cross-checks that `/telegram/webhook` and `/health` stay unaffected), and no-payload/signature/secret-in-logs | + +### TDD Cycle Evidence + +| Task | RED (failing first, correct reason) | GREEN (implementation, passes) | REFACTOR | +|---|---|---|---| +| 3.1/3.2 `signature.ts` | `npx vitest run test/adapters/github/signature.test.ts` before the module existed: `Cannot find module '../../../src/adapters/github/signature'` (0 tests ran, failed suite) | Created `signature.ts`; `npx vitest run test/adapters/github/signature.test.ts` → 7/7 pass | None needed — implementation is a single small pure-ish async function, already minimal | +| 3.3/3.4 route skeleton | `npx vitest run test/http/github-webhook.test.ts` before the route existed: 10/13 failed with `404` (route not registered) or, for one test, a `DataError` from trying to HMAC-sign with an empty key in the test helper itself (fixed by rewriting that one test to send a well-formed signature against the *request's* empty-secret env, rather than trying to sign with an empty key) | Added the route to `src/index.ts` + `env.ts`/`vitest.config.ts`/`.dev.vars.example` bindings; `npx vitest run test/http/github-webhook.test.ts` → 13/13 pass | None needed — route mirrors the existing `/telegram/webhook` error-boundary shape (try/catch around composition-equivalent step, malformed-body 200, safe logging) | + +Every RED run above failed for the right reason (module resolution or route-not-registered 404), never a passing or wrongly-failing assertion. The one non-production-related RED (`DataError` from a broken test helper) was a test-authoring mistake caught during RED, not a production bug — fixed by correcting the test before writing any production code for that branch. + +### Work Unit Evidence (PR3 / Unit 3) + +| Evidence | Value | +|---|---| +| Focused test command and exact result | `npx vitest run test/adapters/github/signature.test.ts test/http/github-webhook.test.ts` → 20/20 pass (7 + 13) | +| Runtime harness command/scenario and exact result | `SELF`/`app.request` through the real Hono app in the Workers runtime (`@cloudflare/vitest-pool-workers`), same harness family as `test/http/webhook-e2e.test.ts` — exercises the actual route registration, `c.env` binding resolution, and `crypto.subtle` in the Workers isolate, not a Node polyfill. Full suite: `npx vitest run` → 280/280 pass (34 files, up from 260/32 before this PR) | +| Rollback boundary | Delete `src/adapters/github/signature.ts`, `test/adapters/github/signature.test.ts`, `test/http/github-webhook.test.ts`; remove the `app.post("/github/webhook", ...)` block from `src/index.ts`; remove `GITHUB_WEBHOOK_SECRET` from `src/env.ts`, `vitest.config.ts`, and `.dev.vars.example`. Nothing outside these files references the new route or `verifyGithubSignature` yet — the Telegram route and `/health` are untouched aside from the shared `Env` type gaining one more required field (verified unaffected by two dedicated tests) | + +### Deviations from Design + +- **tasks.md task 3.3's "D1 failure 500" scenario is deferred to PR4, not implemented here.** This PR has no D1/routing wiring at all (no mapper, no `routeGithubEvent` call, no `buildGithubRouter`) — that lands in Phase 4 per design.md's PR slicing (#4: "the mapper, alert sender, `buildGithubRouter` wiring and the end-to-end delivery tests"). There is no code path in this PR that could reach D1, so a "D1 failure → 500" test would have nothing to exercise except a `throw` inserted purely for the test's sake, which would misrepresent Phase-4 behavior instead of testing it. This was an explicit scope instruction for this batch (only `signature.ts` + the 401/500-on-config/ping/malformed-JSON route skeleton + the env binding — no event mapping, routing, or Telegram delivery). The design.md "GitHub route status policy" table's Infrastructure-Failures row and its spec scenario remain correctly the responsibility of PR4, where the D1 call actually exists. +- **Non-ping events currently return 200 unconditionally (no mapper yet), not spec-driven "unsupported" filtering.** Per design.md's own PR slicing, the mapper (`event-mapper.ts`) that determines "supported vs. unsupported event/action" is Phase 4 work. Until it exists, every non-ping event is a de facto unsupported event under this skeleton, and the design.md status table already assigns unsupported events the same 200 — so this is a temporary but spec-consistent placeholder, not a violation of the "Unsupported Event or Action Ignored" requirement. It will be replaced with dispatch to `mapGithubEvent`/`routeGithubEvent` in PR4, at which point the currently-blanket 200 branch narrows to only truly-unsupported combinations. +- No other deviations. `signature.ts` matches design.md's "Signature check" row verbatim (regex → HMAC over raw bytes → constant-time compare → length-check-before-compare, all before `JSON.parse`; empty/missing secret is a `ConfigError`, never a verify-against-empty-key). The route's 401/500/200 mapping matches the "GitHub route status policy" table's rows that apply to this PR's scope exactly. + +### Issues Found / Risks + +- None. Production code is small (`signature.ts` 47 lines + the route addition to `src/index.ts` ~52 lines + 4 lines each in `env.ts`/`.dev.vars.example` + 1 line in `vitest.config.ts` ≈ 108 lines total), comfortably under the 400-line budget and the ~250-line tasks.md estimate — no `size:exception` needed for this PR, unlike PR1/PR2's test-driven overruns. +- One test-authoring bug was caught and fixed during RED (see TDD Cycle Evidence above): the original "signed against the empty secret itself" test tried to HMAC-sign with a zero-length key, which WebCrypto rejects with a `DataError` unrelated to the production code under test. Rewritten to send a well-formed signature (signed under the real test secret) against a request whose *environment* has an empty `GITHUB_WEBHOOK_SECRET` — the actually-intended scenario (secret unset in the deployed env, regardless of what an attacker sends). + +### New Test Count + +- Before this PR: 260 tests passing (32 files) +- After this PR: **280 tests passing** (34 files) — +20 (7 `signature.test.ts` + 13 `github-webhook.test.ts`) +- `npx tsc --noEmit`: clean, no errors (one pre-fix error surfaced and was corrected: a test helper's `ArrayBufferLike`-typed `.buffer` needed an explicit `as ArrayBuffer` cast to satisfy `verifyGithubSignature`'s `ArrayBuffer` parameter — a test-file-only fix, no production signature changed) + +### Line Counts + +| Category | Files | Lines | +|---|---|---| +| Production | `src/adapters/github/signature.ts` (47) + `src/index.ts` route addition (52) + `src/env.ts` addition (4) | **103** | +| Tests | `test/adapters/github/signature.test.ts` (77) + `test/http/github-webhook.test.ts` (195) | **272** | +| Config/docs (not counted as code) | `.dev.vars.example` (+4), `vitest.config.ts` (+1) | 5 | + +Production code (103 lines) is well under the 400-line budget and under the ~250-line tasks.md estimate. No `size:exception` is needed for PR3 — this is the first PR in the chain where both production and test totals individually and combined stay under budget. + +### Workload / PR Boundary + +- Mode: stacked-to-main, chained PR slice (PR3 of 5), stacked on `feat/github-alerts-route` (from `main` at `5b6d4f4`, includes PR1 + PR2) +- Current work unit: Unit 3 — "`signature.ts` + route skeleton (401/500/ping/malformed JSON) + env binding" +- Boundary: starts from PR1+PR2 (domain + D1 adapters, unchanged in this PR), ends at the signature verifier and the route skeleton returning the correct status for every case in scope (401 unauthorized, 500 config-error/fail-closed, 200 ping/malformed/non-object/not-yet-supported). Explicitly does not wire the mapper, `routeGithubEvent`, `buildGithubRouter`, or any Telegram delivery — that is Phase 4 (task 4.5 wires this route's placeholder branch to real routing) +- Estimated review budget impact: comfortably within budget on both production and test code; no exception needed + +### Remaining Tasks + +- [ ] Phase 4 (PR4): mapper, alert sender, `buildGithubRouter`, e2e delivery tests (including the D1-failure-500 scenario deferred from this PR) +- [ ] Phase 5 (PR5): `/linkrepo`, `/unlinkrepo`, `/repos` commands +- [ ] Phase 6.1/6.2: operator rollout steps (secret provisioning after this PR merges, org webhook config after PR4 merges) + +### Status + +4/4 Phase-3 tasks complete. Full suite: `npx vitest run` → 280/280 pass. `npx tsc --noEmit` → clean, no errors. Telegram webhook and `/health` verified unaffected by dedicated tests in this PR. Production code (103 lines) is well within the 400-line budget — no exception needed. Ready for verify. + +## Correction — PR3 Review Ledger (warnings, no blocker/critical) + +Applied on `feat/github-alerts-route`, still no commit/push, `.codegraph/` untouched. Fixes the 4 warning-level findings from the frozen PR3 review ledger (no blocker/critical findings existed). Strict TDD: a failing test was written first for both behavior changes (RES-002, RES-001); the two readability findings (READ-001, READ-002) are pure extractions with no behavior change, verified against the existing suite as an approval-test safety net throughout. + +### Findings Addressed + +| Finding | Fix | RED evidence | New test result | +|---|---|---|---| +| RES-002 — catch-all branch for signature-verified, non-ping events returned 200 without logging, contradicting design.md:27 ("unsupported event or action: 200, logged") | `src/index.ts`: added `logger.log({ event: "github-webhook", outcome: "ok", reason: "ignored:not-yet-routed" })` before the final `return c.text("ok", 200)`, using only allowlisted `LogEvent` fields (no payload, no event type/action) | `test/http/github-webhook.test.ts` new test ran before the fix: `expected undefined to deeply equal {...}` — no `github-webhook` log entry existed at all | **New behavior added, test passed after the fix.** `npx vitest run test/http/github-webhook.test.ts` → 14/14 (was 13/13) | +| RES-001 — `await c.req.arrayBuffer()` was unguarded, unlike the Telegram route's guarded `c.req.json()` | `src/index.ts`: wrapped the raw-body read in try/catch; on failure, logs `errorCode` only (no payload/secret) and returns `c.text("ok", 200)` — the same status the Telegram route uses for an unparseable body | Added a test with a `ReadableStream` body that calls `controller.error(...)` before any data — this **is** cleanly injectable in the Workers test runtime via `app.request(new Request(..., { body: brokenStream, duplex: "half" }))`. Ran before the fix: `expected 200 to be 500` — the unguarded `await` let the rejection escape the handler, and Hono's default error boundary answered a plain uncontrolled 500 instead of the documented 200 policy (and without the safe logger) | **Real gap found and fixed** (the route was one dropped-connection away from an unlogged, non-policy-compliant 500). `npx vitest run test/http/github-webhook.test.ts` → 15/15 (was 14/14) | +| READ-001 — the constant-time comparison (length check, then `crypto.subtle.timingSafeEqual`) was duplicated in `src/index.ts`'s `isValidSecret` and `src/adapters/github/signature.ts` | Extracted `timingSafeCompare(provided, expected)` to `src/adapters/crypto/timing-safe-compare.ts`; `isValidSecret` was removed and both `/telegram/webhook` and `verifyGithubSignature` now call the shared helper. No behavior change: same `!provided` short-circuit, same length-check-before-compare order | New module written before existing code depended on it (TDD-for-new-code: `Cannot find module '.../timing-safe-compare'`), then implemented | GREEN: `npx vitest run test/adapters/crypto/timing-safe-compare.test.ts` → 5/5 (new). Safety net (no regressions): `npx vitest run test/http/webhook-secret.test.ts test/http/webhook-e2e.test.ts test/adapters/github/signature.test.ts test/http/github-webhook.test.ts test/runtime-assumptions/timing-safe-equal.test.ts` → all green before and after wiring the callers | +| READ-002 — the `signHex` test helper was copy-pasted in `test/adapters/github/signature.test.ts` and `test/http/github-webhook.test.ts` | Extracted to `test/support/github-hmac.ts`; both test files now import it | N/A — test-only refactor, no production behavior. Both files' existing tests are the approval-test safety net | Ran both files immediately after the extraction, before any other change: `npx vitest run test/adapters/github/signature.test.ts test/http/github-webhook.test.ts` → 20/20, unchanged from before the extraction | + +**Two real gaps found (RES-001, RES-002), both fixed with new logging/guarding, not by weakening any test.** READ-001/READ-002 were pure duplication removals with zero behavior change, confirmed by the pre-existing suites staying green throughout. + +### Files Changed (this correction) + +| File | Action | What Was Done | +|------|--------|---------------| +| `src/adapters/crypto/timing-safe-compare.ts` | Created | `timingSafeCompare(provided, expected)` — the shared constant-time string compare | +| `test/adapters/crypto/timing-safe-compare.test.ts` | Created | 5 tests: identical, same-length-different, shorter, longer, empty-vs-non-empty | +| `src/index.ts` | Modified | Removed local `isValidSecret`; Telegram route now calls `timingSafeCompare`. GitHub route: guarded `c.req.arrayBuffer()` (RES-001), added the not-yet-routed log line (RES-002) | +| `src/adapters/github/signature.ts` | Modified | Replaced the inline length-check + `timingSafeEqual` with a call to the shared `timingSafeCompare` | +| `test/support/github-hmac.ts` | Created | Shared `signHex` helper (READ-002) | +| `test/adapters/github/signature.test.ts` | Modified | Imports `signHex` from `test/support/github-hmac` instead of a local copy | +| `test/http/github-webhook.test.ts` | Modified | Imports `signHex` from `test/support/github-hmac`; added the RES-002 logging test and the RES-001 unreadable-body test | +| `openspec/changes/github-alerts/apply-progress.md` | Modified | This correction section | + +### New Test Count + +- Before this correction: 280 tests passing (34 files) +- After this correction: **287 tests passing** (35 files) — +7 (5 `timing-safe-compare.test.ts` + 1 RES-002 log test + 1 RES-001 unreadable-body test) +- `npx tsc --noEmit`: clean, no errors + +### Which Tests Failed First vs. Passed Immediately + +- **Failed first (RED, for the right reason), then passed after the fix**: RES-002's log-assertion test (`expected undefined`, no log entry existed) and RES-001's unreadable-body test (`expected 200 to be 500` — the route was actually returning an uncontrolled 500 via Hono's default error boundary before the fix). +- **New module, no prior code to fail against**: `timing-safe-compare.test.ts` failed on module resolution (`Cannot find module`) before the helper existed — the standard "RED via missing production code" pattern for brand-new pure logic, not a behavior regression. +- **Passed immediately (approval tests, no RED expected)**: every pre-existing test in `webhook-secret.test.ts`, `webhook-e2e.test.ts`, `signature.test.ts`, `github-webhook.test.ts` (13 pre-existing ones), and `timing-safe-equal.test.ts` — these are the READ-001/READ-002 refactor's safety net and were never expected to fail, since no behavior changed for them. + +### Status (after correction) + +All 4 PR3 review warnings addressed. Full suite: `npx vitest run` → 287/287 pass. `npx tsc --noEmit` → clean, no errors. Two real gaps found and fixed (RES-001's unguarded body read escaping to an uncontrolled 500; RES-002's missing log line for the design-mandated "unsupported event: 200, logged" policy); two pure duplication removals (READ-001, READ-002) verified behavior-neutral by the existing suite staying green throughout. No commit/push made; `.codegraph/` untouched. + +## Correction 2 (maintainer-authorized) — Scoped-Validator Escalation on Correction 1's RES-001 Fix + +Applied on `feat/github-alerts-route`, still no commit/push, `.codegraph/` untouched. Scope: only `test/http/github-webhook.test.ts` (~124-140) and `src/index.ts` (~99-109), per the maintainer's explicit "touch nothing else" instruction. + +### Finding + +The scoped fix-delta validator escalated one fix-caused defect in correction 1's RES-001 fix: when `c.req.arrayBuffer()` throws, the route returned 200 before signature verification. That is wrong on two counts — the failure is a **transient, transport-level** read failure (not malformed content that was successfully read), and the request was **never authenticated** at that point. design.md's status policy assigns 500 to transient/unexpected failures precisely so the delivery is marked failed in GitHub and can be redelivered; a body-read failure is exactly that case, not the "redelivering the same bytes can never succeed" case that correctly applies to the JSON.parse/non-object branches later in the same handler (those DID fully read the bytes; malformed content redelivered unchanged will fail identically forever). + +### Fix (test-first) + +1. **RED**: Changed the existing correction-1 test (`test/http/github-webhook.test.ts`, "unreadable raw body" describe block) to assert `res.status` is `500` instead of `200`, and added an exact-match assertion on the logged entry (`{ event: "github-webhook", outcome: "error", errorCode: "Error" }`, plus a check that the raw error message never appears in any log line). Ran against the unmodified correction-1 code: `expected 200 to be 500` — confirmed RED for the exact reason the finding describes (the route was still returning 200 on this path). +2. **GREEN**: Changed `src/index.ts`'s `catch` block for the `c.req.arrayBuffer()` guard to `return c.text("Internal Server Error", 500)` instead of `return c.text("ok", 200)`. The safe log line (errorCode-only, no payload/message) was kept unchanged. Rewrote the surrounding comment to drop the "redelivering the same bytes can never succeed" rationale (which does not apply here) and state the actual reasoning: a transient, transport-level failure on an unauthenticated request, which design.md's status policy answers with 500. + +### RED Evidence + +``` +AssertionError: expected 200 to be 500 // Object.is equality +- Expected: 500 ++ Received: 200 + ❯ test/http/github-webhook.test.ts:144:24 +``` + +### Files Changed (this correction) + +| File | Action | What Was Done | +|------|--------|---------------| +| `test/http/github-webhook.test.ts` | Modified | The "unreadable raw body" test now asserts 500 (was 200) and asserts the exact safe-logged entry; nothing else in the file touched | +| `src/index.ts` | Modified | The `arrayBuffer()` catch block now returns 500 instead of 200; the comment above it no longer cites the malformed-content rationale; nothing else in the file touched | + +### New Test Count + +- Before this correction: 287 tests passing (35 files) +- After this correction: **287 tests passing** (35 files, unchanged — one existing test modified in place, no test added or removed) +- `npx tsc --noEmit`: clean, no errors + +### Status (after correction 2) + +The scoped-validator-escalated defect is fixed: an unreadable GitHub webhook body now returns 500 (not 200), matching design.md's status policy for transient/unexpected failures, while still logging only allowlisted fields and never the raw error message. Full suite: `npx vitest run` → 287/287 pass. `npx tsc --noEmit` → clean, no errors. No commit/push made; `.codegraph/` untouched; only the two files named in the maintainer's instruction were touched. diff --git a/openspec/changes/github-alerts/tasks.md b/openspec/changes/github-alerts/tasks.md index 3b9f723..1220001 100644 --- a/openspec/changes/github-alerts/tasks.md +++ b/openspec/changes/github-alerts/tasks.md @@ -49,10 +49,10 @@ Chain strategy: stacked-to-main ## Phase 3: Signature and Route Skeleton (PR3) -- [ ] 3.1 RED: HMAC tests — missing header, wrong secret, right-length-wrong-content (timing-safe), valid signature (spec: HMAC Signature Verification, all scenarios). -- [ ] 3.2 GREEN: `src/adapters/github/signature.ts`, WebCrypto HMAC + `timingSafeEqual`, empty/missing secret → `ConfigError` (500), before `JSON.parse`. -- [ ] 3.3 RED: route status tests — `ping` 200, invalid JSON/non-object/unsupported event 200, D1 failure 500 (spec: Ping, Unsupported, Infrastructure Failures). -- [ ] 3.4 GREEN: `src/index.ts` route registration, `env.ts` `GITHUB_WEBHOOK_SECRET`, `.dev.vars.example`, `vitest.config.ts` test binding. +- [x] 3.1 RED: HMAC tests — missing header, wrong secret, right-length-wrong-content (timing-safe), valid signature (spec: HMAC Signature Verification, all scenarios). +- [x] 3.2 GREEN: `src/adapters/github/signature.ts`, WebCrypto HMAC + `timingSafeEqual`, empty/missing secret → `ConfigError` (500), before `JSON.parse`. +- [x] 3.3 RED: route status tests — `ping` 200, invalid JSON/non-object/unsupported event 200 (spec: Ping, Unsupported). D1 failure 500 deferred to PR4 — see Deviation note in apply-progress.md; there is no D1/routing wiring in this PR to fail. +- [x] 3.4 GREEN: `src/index.ts` route registration, `env.ts` `GITHUB_WEBHOOK_SECRET`, `.dev.vars.example`, `vitest.config.ts` test binding. ## Phase 4: Delivery Wiring (PR4)