diff --git a/docs/github-app.md b/docs/github-app.md new file mode 100644 index 0000000..0c5678a --- /dev/null +++ b/docs/github-app.md @@ -0,0 +1,81 @@ +# GitHub App integration + +FlowForge includes a small GitHub integration foundation for verified webhooks and GitHub REST API calls made with a GitHub App installation token. + +## Security model + +- Verify every webhook against the **raw request body** with `verifyGitHubWebhookSignature` before parsing JSON or starting a workflow. +- Keep the GitHub App private key, webhook secret, and installation tokens outside source control. +- Supply short-lived installation tokens to `GitHubApiClient`; the client never writes tokens into request bodies or error messages. +- The API client accepts HTTPS endpoints only and constrains requests to relative GitHub API paths. +- Grant the GitHub App only the repository permissions required by the workflows you enable. + +## Register the GitHub App + +Create a GitHub App named **Karzoun FlowForge Bridge** (or another unique name) in GitHub Developer settings. + +Recommended starting configuration: + +- Homepage URL: `https://github.com/mkarson1997/karzoun-flowforge` +- Webhook: enabled +- Webhook URL: your public FlowForge bridge endpoint, for example `https://YOUR-HOST/webhooks/github` +- Webhook secret: a randomly generated secret stored only in your deployment secret manager +- Installation scope: only the repositories where FlowForge workflows should run + +Start with least privilege. A repository-health or workflow-trigger integration can usually begin with: + +- Metadata: Read-only (GitHub requires metadata access) +- Actions: Read-only when reading workflow-run state +- Contents: Read-only when workflow definitions or repository files are read +- Issues: Read-only if issue events become triggers +- Pull requests: Read-only if pull request events become triggers + +Subscribe only to events you actually handle, such as `pull_request`, `issues`, or `workflow_run`. + +## Webhook verification + +```ts +import { verifyGitHubWebhookSignature } from "@karzoun/flowforge"; + +const valid = verifyGitHubWebhookSignature( + process.env.GITHUB_WEBHOOK_SECRET ?? "", + rawRequestBody, + request.headers["x-hub-signature-256"], +); + +if (!valid) { + throw new Error("Invalid GitHub webhook signature"); +} +``` + +Do not reconstruct or re-stringify JSON before verification. GitHub signs the exact raw bytes delivered to the webhook endpoint. + +## GitHub REST API + +Obtain a short-lived installation token through GitHub App authentication, then inject it into the client: + +```ts +import { GitHubApiClient } from "@karzoun/flowforge"; + +const github = new GitHubApiClient({ + token: process.env.GITHUB_INSTALLATION_TOKEN ?? "", +}); + +const repository = await github.getRepository("mkarson1997", "karzoun-flowforge"); +const runs = await github.listWorkflowRuns("mkarson1997", "karzoun-flowforge", repository.default_branch); +``` + +A completed FlowForge execution can also emit an explicit `repository_dispatch` event when the installed app has the required permission: + +```ts +await github.createRepositoryDispatch( + "mkarson1997", + "karzoun-flowforge", + "flowforge.completed", + { executionId: "run-123" }, +); +``` + +## Production boundary + +This module deliberately does not generate GitHub App JWTs or persist private keys. App authentication and token minting belong at the deployment boundary or in a dedicated secrets-aware adapter. That keeps FlowForge's core runtime free of long-lived GitHub credentials. diff --git a/src/github-app.ts b/src/github-app.ts new file mode 100644 index 0000000..1a5d6d6 --- /dev/null +++ b/src/github-app.ts @@ -0,0 +1,166 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +export interface GitHubApiClientOptions { + token: string; + apiBaseUrl?: string; + userAgent?: string; + fetchImpl?: typeof fetch; +} + +export interface GitHubRepositorySummary { + id: number; + name: string; + full_name: string; + private: boolean; + default_branch: string; + html_url: string; +} + +export interface GitHubWorkflowRunSummary { + id: number; + name: string; + status: string | null; + conclusion: string | null; + html_url: string; + head_sha: string; +} + +export interface GitHubWorkflowRunsResponse { + total_count: number; + workflow_runs: GitHubWorkflowRunSummary[]; +} + +export class GitHubApiError extends Error { + constructor( + readonly status: number, + readonly method: string, + readonly path: string, + ) { + super(`GitHub API request failed: ${method} ${path} returned HTTP ${status}`); + this.name = "GitHubApiError"; + } +} + +export function verifyGitHubWebhookSignature( + secret: string, + rawBody: string | Uint8Array, + signatureHeader: string | null | undefined, +): boolean { + if (secret.length === 0 || !signatureHeader?.startsWith("sha256=")) { + return false; + } + + const suppliedHex = signatureHeader.slice("sha256=".length); + if (!/^[0-9a-f]{64}$/i.test(suppliedHex)) { + return false; + } + + const expected = createHmac("sha256", secret).update(rawBody).digest(); + const supplied = Buffer.from(suppliedHex, "hex"); + return expected.length === supplied.length && timingSafeEqual(expected, supplied); +} + +export class GitHubApiClient { + private readonly token: string; + private readonly apiBaseUrl: URL; + private readonly userAgent: string; + private readonly fetchImpl: typeof fetch; + + constructor(options: GitHubApiClientOptions) { + if (options.token.trim().length === 0) { + throw new Error("GitHub installation token must not be empty"); + } + + const apiBaseUrl = new URL(options.apiBaseUrl ?? "https://api.github.com/"); + if (apiBaseUrl.protocol !== "https:") { + throw new Error("GitHub API base URL must use HTTPS"); + } + if (apiBaseUrl.username || apiBaseUrl.password || apiBaseUrl.search || apiBaseUrl.hash) { + throw new Error("GitHub API base URL must not contain credentials, a query, or a fragment"); + } + if (!apiBaseUrl.pathname.endsWith("/")) { + apiBaseUrl.pathname += "/"; + } + + this.token = options.token; + this.apiBaseUrl = apiBaseUrl; + this.userAgent = options.userAgent ?? "karzoun-flowforge"; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch; + } + + async getRepository(owner: string, repo: string): Promise { + return this.request( + "GET", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, + ); + } + + async listWorkflowRuns( + owner: string, + repo: string, + branch?: string, + ): Promise { + const query = branch ? `?branch=${encodeURIComponent(branch)}` : ""; + return this.request( + "GET", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs${query}`, + ); + } + + async createRepositoryDispatch( + owner: string, + repo: string, + eventType: string, + clientPayload: Record = {}, + ): Promise { + if (!/^[A-Za-z0-9._-]{1,100}$/.test(eventType)) { + throw new Error("GitHub repository_dispatch event type is invalid"); + } + + await this.request( + "POST", + `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/dispatches`, + { + event_type: eventType, + client_payload: clientPayload, + }, + ); + } + + private buildUrl(path: string): URL { + if (!path.startsWith("/") || path.startsWith("//") || path.includes("..")) { + throw new Error("GitHub API path must be an absolute API path without traversal"); + } + + const url = new URL(path.slice(1), this.apiBaseUrl); + if (url.origin !== this.apiBaseUrl.origin) { + throw new Error("GitHub API request escaped the configured origin"); + } + return url; + } + + private async request(method: "GET" | "POST", path: string, body?: unknown): Promise { + const headers: Record = { + accept: "application/vnd.github+json", + authorization: `Bearer ${this.token}`, + "user-agent": this.userAgent, + "x-github-api-version": "2022-11-28", + }; + + const init: RequestInit = { method, headers }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + + const response = await this.fetchImpl(this.buildUrl(path), init); + if (!response.ok) { + throw new GitHubApiError(response.status, method, path); + } + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; + } +} diff --git a/src/index.ts b/src/index.ts index d450930..2068843 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,15 @@ export { } from "./durable-worker.js"; export { FlowForge, type FlowForgeOptions } from "./engine.js"; export { StepTimeoutError, WorkflowValidationError } from "./errors.js"; +export { + GitHubApiClient, + GitHubApiError, + type GitHubApiClientOptions, + type GitHubRepositorySummary, + type GitHubWorkflowRunSummary, + type GitHubWorkflowRunsResponse, + verifyGitHubWebhookSignature, +} from "./github-app.js"; export { topologicalLayers, topologicalOrder } from "./graph.js"; export { createOperationalHandler, type OperationalHandlerOptions, type ReadinessCheck } from "./operations.js"; export { INITIAL_MIGRATION_SQL, WORKER_MIGRATION_SQL } from "./postgres-migrations.js"; diff --git a/test/github-app.test.ts b/test/github-app.test.ts new file mode 100644 index 0000000..0f44b7d --- /dev/null +++ b/test/github-app.test.ts @@ -0,0 +1,92 @@ +import { createHmac } from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { + GitHubApiClient, + GitHubApiError, + verifyGitHubWebhookSignature, +} from "../src/github-app.js"; + +describe("GitHub webhook verification", () => { + it("accepts a valid sha256 signature", () => { + const secret = "test-webhook-secret"; + const body = Buffer.from('{"action":"opened"}', "utf8"); + const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; + + expect(verifyGitHubWebhookSignature(secret, body, signature)).toBe(true); + }); + + it("rejects malformed or incorrect signatures", () => { + const body = '{"action":"opened"}'; + expect(verifyGitHubWebhookSignature("secret", body, "sha1=abc")).toBe(false); + expect(verifyGitHubWebhookSignature("secret", body, "sha256=xyz")).toBe(false); + expect(verifyGitHubWebhookSignature("secret", body, `sha256=${"0".repeat(64)}`)).toBe(false); + }); +}); + +describe("GitHubApiClient", () => { + it("uses installation-token authentication and safely encodes repository names", async () => { + let requestUrl = ""; + let requestInit: RequestInit | undefined; + const fetchImpl: typeof fetch = async (input, init) => { + requestUrl = String(input); + requestInit = init; + return new Response( + JSON.stringify({ + id: 42, + name: "repo/name", + full_name: "owner space/repo/name", + private: false, + default_branch: "main", + html_url: "https://github.com/owner-space/repo-name", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const client = new GitHubApiClient({ token: "installation-token", fetchImpl }); + const repository = await client.getRepository("owner space", "repo/name"); + + expect(repository.id).toBe(42); + expect(requestUrl).toBe("https://api.github.com/repos/owner%20space/repo%2Fname"); + const headers = new Headers(requestInit?.headers); + expect(headers.get("authorization")).toBe("Bearer installation-token"); + expect(headers.get("x-github-api-version")).toBe("2022-11-28"); + }); + + it("creates repository_dispatch events without exposing the token in the body", async () => { + let body = ""; + const fetchImpl: typeof fetch = async (_input, init) => { + body = String(init?.body ?? ""); + return new Response(null, { status: 204 }); + }; + + const client = new GitHubApiClient({ token: "secret-token", fetchImpl }); + await client.createRepositoryDispatch("owner", "repo", "flowforge.completed", { + executionId: "run-123", + }); + + expect(JSON.parse(body)).toEqual({ + event_type: "flowforge.completed", + client_payload: { executionId: "run-123" }, + }); + expect(body).not.toContain("secret-token"); + }); + + it("surfaces status and endpoint without copying an API response body into errors", async () => { + const fetchImpl: typeof fetch = async () => + new Response('{"message":"private diagnostic"}', { status: 403 }); + const client = new GitHubApiClient({ token: "installation-token", fetchImpl }); + + const request = client.getRepository("owner", "repo"); + await expect(request).rejects.toBeInstanceOf(GitHubApiError); + await expect(request).rejects.not.toThrow(/private diagnostic/); + }); + + it("requires HTTPS for GitHub API endpoints", () => { + expect( + () => new GitHubApiClient({ token: "token", apiBaseUrl: "http://example.test/api/v3/" }), + ).toThrow(/HTTPS/); + }); +});