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
15 changes: 10 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ LNBITS_WALLET_ID=
LNBITS_ADMIN_KEY=
LNBITS_INVOICE_KEY=

# GitHub App (optional) — lets bounties post a status comment on the GitHub
# issue they fund and mark it paid on payout. Register a GitHub App, install it
# on the target repos, and supply the App ID + private key (PEM). Newlines in
# the key may be escaped as \n on a single line. If unset, the comment feature
# is silently skipped.
# GitHub (optional) — lets bounties post a status comment on the GitHub issue
# they fund and mark it paid on payout. Two auth modes, token takes precedence:
#
# 1. GITHUB_TOKEN — a PAT or `gh auth token`. Simplest: it already covers every
# repo you can write to (no per-repo install). Comments post as that user.
# 2. GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY — a GitHub App installed on the
# target repos. PEM newlines may be escaped as \n on a single line.
#
# If neither is set, the comment feature is silently skipped.
GITHUB_TOKEN=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
49 changes: 33 additions & 16 deletions src/lib/github-app.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
// Minimal GitHub App client for posting/editing a single status comment on the
// GitHub issue a bounty is funding. Zero external dependencies — the App JWT is
// signed with node:crypto (RS256), and all calls go through fetch against the
// REST API.
// Minimal GitHub client for posting/editing a single status comment on the
// GitHub issue a bounty is funding. Zero external dependencies — all calls go
// through fetch against the REST API.
//
// Configure via env:
// GITHUB_APP_ID — the numeric App ID
// GITHUB_APP_PRIVATE_KEY — the App private key (PEM). Literal "\n" sequences
// are normalized so the key can live on one env line.
// Two auth modes, checked in this order:
//
// The App must be installed on the target repo. We resolve the installation on
// demand (GET /repos/{owner}/{repo}/installation) so no installation_id needs to
// be stored — if the App isn't installed, the call returns null and callers skip
// the comment gracefully.
// 1. GITHUB_TOKEN — a personal access token or `gh auth token` value. Simplest
// setup: the token already covers every repo the user can write to, so
// there is no per-repo install. Comments post as that user.
//
// 2. GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY — a GitHub App. The App JWT is
// signed with node:crypto (RS256); the installation is resolved on demand
// (GET /repos/{owner}/{repo}/installation) so no installation_id is stored.
// The App must be installed on the target repo, else the call is skipped.
//
// If neither is configured (or the call fails / the App isn't installed), the
// comment is silently skipped and callers carry on.
import crypto from "crypto";

const API = "https://api.github.com";
Expand All @@ -25,6 +28,20 @@ export function isGitHubAppConfigured(): boolean {
return Boolean(process.env.GITHUB_APP_ID && process.env.GITHUB_APP_PRIVATE_KEY);
}

/** True if any GitHub auth mode (token or App) is configured. */
export function isGitHubConfigured(): boolean {
return Boolean(process.env.GITHUB_TOKEN) || isGitHubAppConfigured();
}

// Resolve a bearer token for REST calls on {owner}/{repo}. Prefers a static
// GITHUB_TOKEN; otherwise mints a short-lived App installation token. Returns
// null if no mode is configured or the App isn't installed on the repo.
async function resolveToken(owner: string, repo: string): Promise<string | null> {
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
if (!isGitHubAppConfigured()) return null;
return installationToken(owner, repo);
}
Comment on lines +39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 No App fallback when GITHUB_TOKEN is set but fails

resolveToken returns process.env.GITHUB_TOKEN immediately without testing it. If the token is present but lacks write permission on a specific repo (e.g. a read-only PAT, an expired token, or the automatically-injected GITHUB_TOKEN in a GitHub Actions environment — which is scoped only to the current repo), postIssueComment/updateIssueComment will receive a 401/403 from the API and return null, never attempting the GitHub App path even when the App is installed on the target repo. A deployer who sets GITHUB_TOKEN broadly and also has the App configured will silently lose App-based comments on any repo where the PAT is insufficient.

Fix in Codex Fix in Claude Code


function base64url(input: Buffer | string): string {
return Buffer.from(input)
.toString("base64")
Expand Down Expand Up @@ -84,9 +101,9 @@ export async function postIssueComment(
issueNumber: number,
body: string
): Promise<number | null> {
if (!isGitHubAppConfigured()) return null;
if (!isGitHubConfigured()) return null;
try {
const token = await installationToken(owner, repo);
const token = await resolveToken(owner, repo);
if (!token) return null;
const res = await fetch(`${API}/repos/${owner}/${repo}/issues/${issueNumber}/comments`, {
method: "POST",
Expand Down Expand Up @@ -115,9 +132,9 @@ export async function updateIssueComment(
commentId: number,
body: string
): Promise<boolean> {
if (!isGitHubAppConfigured()) return false;
if (!isGitHubConfigured()) return false;
try {
const token = await installationToken(owner, repo);
const token = await resolveToken(owner, repo);
if (!token) return false;
const res = await fetch(`${API}/repos/${owner}/${repo}/issues/comments/${commentId}`, {
method: "PATCH",
Expand Down
Loading