Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions docs/github-app.md
Original file line number Diff line number Diff line change
@@ -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.
166 changes: 166 additions & 0 deletions src/github-app.ts
Original file line number Diff line number Diff line change
@@ -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=")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Passing a Node.js IncomingMessage header directly, as shown in the documentation, supplies string | string[] | undefined; when GitHub's signature header is represented as an array, signatureHeader.startsWith(...) raises TypeError instead of rejecting the request. The documented usage also fails TypeScript checking because the function does not accept string[].

Triggers: When the webhook framework exposes duplicate or multi-valued x-hub-signature-256 headers.

Suggested fix: Accept only a string at runtime and return false for arrays, or have the integration adapter explicitly reject and normalize multi-valued headers before calling the verifier.

Suggested change
if (secret.length === 0 || !signatureHeader?.startsWith("sha256=")) {
if (secret.length === 0 || typeof signatureHeader !== "string" || !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<GitHubRepositorySummary> {
return this.request<GitHubRepositorySummary>(
"GET",
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`,
);
}

async listWorkflowRuns(
owner: string,
repo: string,
branch?: string,
): Promise<GitHubWorkflowRunsResponse> {
const query = branch ? `?branch=${encodeURIComponent(branch)}` : "";
return this.request<GitHubWorkflowRunsResponse>(
"GET",
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/runs${query}`,
);
}

async createRepositoryDispatch(
owner: string,
repo: string,
eventType: string,
clientPayload: Record<string, unknown> = {},
): Promise<void> {
if (!/^[A-Za-z0-9._-]{1,100}$/.test(eventType)) {
throw new Error("GitHub repository_dispatch event type is invalid");
Comment on lines +116 to +117

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow all valid repository-dispatch event types

When a workflow listens for a valid custom event type containing characters outside this private allowlist, such as deploy:completed, the client throws locally even though GitHub's event_type contract only imposes a 100-character maximum. Since the value is JSON-encoded rather than interpolated into a URL, this restriction unnecessarily prevents dispatching events supported by the API.

Useful? React with 👍 / 👎.

}

await this.request<void>(
"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("..")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check traversal by complete path segment

For repositories whose valid GitHub name contains consecutive periods (for example release..archive), encodeURIComponent preserves the periods and this substring check rejects the generated path before any request is sent. Such periods are harmless unless the complete segment is ..; validate path segments rather than searching the entire URL so all three repository helpers remain usable for these repositories.

Useful? React with 👍 / 👎.

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<T>(method: "GET" | "POST", path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = {
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;
}
}
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
92 changes: 92 additions & 0 deletions test/github-app.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading