delqhi/sin-github-issues
0
1import { createHmac, createPrivateKey, createSign, timingSafeEqual } from 'node:crypto';2import { readFile } from 'node:fs/promises';3 4type JsonObject = Record<string, unknown>;5 6export type GitHubAppRouteRecord = {7 agentSlug: string;8 botName?: string;9 appId: number;10 clientId?: string;11 appSlug?: string;12 routePath?: string;13 privateKeyPem?: string;14 privateKeyPath?: string;15 privateKeyEnv?: string;16 webhookSecret?: string;17 webhookSecretPath?: string;18 webhookSecretEnv?: string;19};20 21type GitHubAppRoutingConfig = {22 defaultMode?: 'central' | 'path' | 'hybrid';23 defaultWebhookPath?: string;24 apps: GitHubAppRouteRecord[];25};26 27type WebhookRouteInput = {28 payload?: unknown;29 rawBody?: string;30 routePath?: string;31 signature256?: string;32 eventName?: string;33 deliveryId?: string;34};35 36type CommentAsAppInput = {37 repo: string;38 issueNumber: number;39 body: string;40 agentSlug?: string;41 appId?: number;42 installationId: number;43};44 45const GITHUB_API_BASE = String(process.env.SIN_GITHUB_API_BASE_URL || 'https://api.github.com').trim() || 'https://api.github.com';46const GITHUB_API_VERSION = '2022-11-28';47 48export async function getGitHubAppRoutingStatus() {49 const config = await loadGitHubAppRoutingConfig();50 const configuredApps = await Promise.all(51 config.apps.map(async (app) => {52 const privateKeyResolved = Boolean(await readPrivateKey(app));53 const webhookSecretResolved = Boolean(await readWebhookSecret(app));54 return {55 agentSlug: app.agentSlug,56 appSlug: app.appSlug || null,57 botName: app.botName || `${app.agentSlug}[bot]`,58 appId: app.appId,59 clientId: app.clientId || null,60 routePath: app.routePath || `${config.defaultWebhookPath || '/github/webhook'}/${app.agentSlug}`,61 privateKeyRefConfigured: Boolean(app.privateKeyPem || app.privateKeyPath || app.privateKeyEnv),62 privateKeyResolved,63 webhookSecretRefConfigured: Boolean(app.webhookSecret || app.webhookSecretPath || app.webhookSecretEnv),64 webhookSecretResolved,65 };66 }),67 );68 return {69 ok: true,70 defaultMode: config.defaultMode || 'hybrid',71 defaultWebhookPath: config.defaultWebhookPath || '/github/webhook',72 configuredApps,73 };74}75 76export async function listGitHubAppWebhookPaths() {77 const config = await loadGitHubAppRoutingConfig();78 const paths = new Set<string>();79 paths.add(config.defaultWebhookPath || '/github/webhook');80 for (const app of config.apps) {81 if (app.routePath) paths.add(normalizeRoutePath(app.routePath));82 }83 return [...paths];84}85 86export async function routeGitHubWebhook(input: WebhookRouteInput) {87 const config = await loadGitHubAppRoutingConfig();88 const payload = parsePayload(input.payload, input.rawBody);89 const installation = asObject(payload.installation);90 const installationId = parseInteger(installation?.id);91 const appId = parseInteger(installation?.app_id);92 const routePath = normalizeRoutePath(input.routePath || config.defaultWebhookPath || '/github/webhook');93 const matchedApp = resolveRoute(config, { appId, routePath });94 95 if (!matchedApp) {96 return {97 ok: false,98 error: 'github_app_route_not_found',99 routePath,100 appId,101 installationId,102 configuredAppIds: config.apps.map((app) => app.appId),103 };104 }105 106 const verification = await verifyWebhookSignature(matchedApp, input.signature256, input.rawBody || '');107 if (!verification.ok) {108 return {109 ok: false,110 error: verification.error,111 routePath,112 appId,113 installationId,114 agentSlug: matchedApp.agentSlug,115 botName: matchedApp.botName || `${matchedApp.agentSlug}[bot]`,116 };117 }118 119 const repository = asObject(payload.repository);120 return {121 ok: true,122 route: {123 agentSlug: matchedApp.agentSlug,124 botName: matchedApp.botName || `${matchedApp.agentSlug}[bot]`,125 appId: matchedApp.appId,126 installationId,127 routePath: matchedApp.routePath || `${config.defaultWebhookPath || '/github/webhook'}/${matchedApp.agentSlug}`,128 },129 event: {130 name: input.eventName || null,131 deliveryId: input.deliveryId || null,132 action: typeof payload.action === 'string' ? payload.action : null,133 repository: typeof repository?.full_name === 'string' ? repository.full_name : null,134 },135 verification: {136 signatureChecked: Boolean(input.signature256),137 signatureValid: verification.signatureValid,138 },139 };140}141 142export async function commentIssueAsGitHubApp(input: CommentAsAppInput) {143 const config = await loadGitHubAppRoutingConfig();144 const matchedApp = resolveCommentRoute(config, input);145 if (!matchedApp) {146 throw new Error(`github_app_route_not_found:${input.agentSlug || input.appId || 'unknown'}`);147 }148 149 const privateKey = await readPrivateKey(matchedApp);150 if (!privateKey) {151 throw new Error(`github_app_private_key_missing:${matchedApp.agentSlug}`);152 }153 154 const jwt = createGitHubAppJwt(matchedApp.appId, privateKey);155 const tokenPayload = await githubApiJson<{ token?: string; expires_at?: string }>(156 `${GITHUB_API_BASE}/app/installations/${input.installationId}/access_tokens`,157 {158 method: 'POST',159 headers: {160 Authorization: `Bearer ${jwt}`,161 Accept: 'application/vnd.github+json',162 'X-GitHub-Api-Version': GITHUB_API_VERSION,163 },164 body: '{}',165 },166 );167 168 if (!tokenPayload.token) {169 throw new Error(`github_app_installation_token_missing:${matchedApp.agentSlug}`);170 }171 172 const commentPayload = await githubApiJson<{ html_url?: string; id?: number }>(173 `${GITHUB_API_BASE}/repos/${input.repo}/issues/${input.issueNumber}/comments`,174 {175 method: 'POST',176 headers: {177 Authorization: `Bearer ${tokenPayload.token}`,178 Accept: 'application/vnd.github+json',179 'X-GitHub-Api-Version': GITHUB_API_VERSION,180 'Content-Type': 'application/json',181 },182 body: JSON.stringify({ body: input.body }),183 },184 );185 186 return {187 ok: true,188 repo: input.repo,189 issueNumber: input.issueNumber,190 appId: matchedApp.appId,191 agentSlug: matchedApp.agentSlug,192 botName: matchedApp.botName || `${matchedApp.agentSlug}[bot]`,193 installationId: input.installationId,194 commentId: commentPayload.id || null,195 commentUrl: commentPayload.html_url || null,196 tokenExpiresAt: tokenPayload.expires_at || null,197 };198}199 200async function loadGitHubAppRoutingConfig(): Promise<GitHubAppRoutingConfig> {201 const inlineJson = String(process.env.SIN_GITHUB_APP_ROUTING_JSON || '').trim();202 const configPath = String(process.env.SIN_GITHUB_APP_ROUTING_PATH || '').trim();203 204 let parsed: unknown = { apps: [] };205 if (inlineJson) {206 parsed = JSON.parse(inlineJson);207 } else if (configPath) {208 parsed = JSON.parse(await readFile(configPath, 'utf8'));209 }210 211 const asConfig = asObject(parsed) || { apps: [] };212 const apps = Array.isArray(asConfig.apps)213 ? asConfig.apps214 .map((entry) => normalizeAppRecord(entry))215 .filter((entry): entry is GitHubAppRouteRecord => Boolean(entry))216 : [];217 218 return {219 defaultMode: parseMode(asConfig.defaultMode ?? asConfig.mode),220 defaultWebhookPath: typeof asConfig.defaultWebhookPath === 'string' ? normalizeRoutePath(asConfig.defaultWebhookPath) : '/github/webhook',221 apps,222 };223}224 225function normalizeAppRecord(value: unknown): GitHubAppRouteRecord | null {226 const entry = asObject(value);227 if (!entry) return null;228 const agentSlug = firstNonEmptyString(entry.agentSlug, entry.preferredAgentSlug, entry.appSlug);229 const appId = parseInteger(entry.appId) ?? parseInteger(resolveEnvString(entry.appIdEnv));230 if (!agentSlug || !appId) return null;231 const routePath = firstNonEmptyString(entry.routePath, entry.webhookPath);232 return {233 agentSlug,234 appSlug: firstNonEmptyString(entry.appSlug) || undefined,235 botName: typeof entry.botName === 'string' ? entry.botName.trim() : undefined,236 appId,237 clientId: firstNonEmptyString(entry.clientId, resolveEnvString(entry.clientIdEnv)) || undefined,238 routePath: routePath ? normalizeRoutePath(routePath) : undefined,239 privateKeyPem: typeof entry.privateKeyPem === 'string' ? entry.privateKeyPem : undefined,240 privateKeyPath: typeof entry.privateKeyPath === 'string' ? entry.privateKeyPath.trim() : undefined,241 privateKeyEnv: firstNonEmptyString(entry.privateKeyEnv) || undefined,242 webhookSecret: typeof entry.webhookSecret === 'string' ? entry.webhookSecret : undefined,243 webhookSecretPath: typeof entry.webhookSecretPath === 'string' ? entry.webhookSecretPath.trim() : undefined,244 webhookSecretEnv: firstNonEmptyString(entry.webhookSecretEnv) || undefined,245 };246}247 248function parsePayload(payload: unknown, rawBody?: string) {249 if (payload && typeof payload === 'object') return payload as JsonObject;250 if (rawBody?.trim()) return JSON.parse(rawBody) as JsonObject;251 return {} as JsonObject;252}253 254function resolveRoute(config: GitHubAppRoutingConfig, input: { appId: number | null; routePath: string }) {255 if (input.appId) {256 const byAppId = config.apps.find((app) => app.appId === input.appId);257 if (byAppId) return byAppId;258 }259 const matchesByPath = config.apps.filter((app) => app.routePath && normalizeRoutePath(app.routePath) === input.routePath);260 if (matchesByPath.length === 1) return matchesByPath[0] || null;261 return null;262}263 264function resolveCommentRoute(config: GitHubAppRoutingConfig, input: CommentAsAppInput) {265 if (input.appId) {266 return config.apps.find((app) => app.appId === input.appId) || null;267 }268 if (input.agentSlug) {269 return config.apps.find((app) => app.agentSlug === input.agentSlug) || null;270 }271 return null;272}273 274async function verifyWebhookSignature(app: GitHubAppRouteRecord, signature256: string | undefined, rawBody: string) {275 if (!signature256) {276 return { ok: true, signatureValid: false };277 }278 const secret = await readWebhookSecret(app);279 if (!secret) {280 return { ok: false, signatureValid: false, error: `github_app_webhook_secret_missing:${app.agentSlug}` };281 }282 const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`;283 const valid = safeEqual(expected, signature256.trim());284 return valid285 ? { ok: true, signatureValid: true }286 : { ok: false, signatureValid: false, error: 'github_app_webhook_signature_invalid' };287}288 289async function readPrivateKey(app: GitHubAppRouteRecord) {290 if (app.privateKeyPem?.trim()) return app.privateKeyPem.trim();291 if (app.privateKeyEnv && process.env[app.privateKeyEnv]?.trim()) return String(process.env[app.privateKeyEnv]).trim();292 if (app.privateKeyPath?.trim()) return (await readFile(app.privateKeyPath, 'utf8')).trim();293 return null;294}295 296async function readWebhookSecret(app: GitHubAppRouteRecord) {297 if (app.webhookSecret?.trim()) return app.webhookSecret.trim();298 if (app.webhookSecretEnv && process.env[app.webhookSecretEnv]?.trim()) return String(process.env[app.webhookSecretEnv]).trim();299 if (app.webhookSecretPath?.trim()) return (await readFile(app.webhookSecretPath, 'utf8')).trim();300 return null;301}302 303function createGitHubAppJwt(appId: number, privateKeyPem: string) {304 const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');305 const now = Math.floor(Date.now() / 1000);306 const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })).toString('base64url');307 const signingInput = `${header}.${payload}`;308 const signer = createSign('RSA-SHA256');309 signer.update(signingInput);310 signer.end();311 const signature = signer.sign(createPrivateKey(privateKeyPem), 'base64url');312 return `${signingInput}.${signature}`;313}314 315async function githubApiJson<T>(url: string, init: RequestInit): Promise<T> {316 const response = await fetch(url, init);317 const text = await response.text();318 if (!response.ok) {319 throw new Error(`github_api_request_failed:${response.status}:${text.slice(0, 500)}`);320 }321 return text ? (JSON.parse(text) as T) : ({} as T);322}323 324function parseMode(value: unknown): 'central' | 'path' | 'hybrid' | undefined {325 if (value === 'central' || value === 'path' || value === 'hybrid') return value;326 if (value === 'central-endpoint-routing') return 'central';327 if (value === 'path-routing') return 'path';328 if (value === 'hybrid-routing') return 'hybrid';329 return undefined;330}331 332function normalizeRoutePath(value: string) {333 const clean = value.split('?', 1)[0].trim();334 return clean.startsWith('/') ? clean : `/${clean}`;335}336 337function parseInteger(value: unknown) {338 const parsed = Number.parseInt(String(value ?? '').trim(), 10);339 return Number.isFinite(parsed) ? parsed : null;340}341 342function asObject(value: unknown): JsonObject | null {343 return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : null;344}345 346function safeEqual(left: string, right: string) {347 const leftBuffer = Buffer.from(left);348 const rightBuffer = Buffer.from(right);349 if (leftBuffer.length !== rightBuffer.length) return false;350 return timingSafeEqual(leftBuffer, rightBuffer);351}352 353function firstNonEmptyString(...values: unknown[]) {354 for (const value of values) {355 if (typeof value === 'string' && value.trim()) return value.trim();356 }357 return null;358}359 360function resolveEnvString(envKey: unknown) {361 if (typeof envKey !== 'string' || !envKey.trim()) return null;362 const value = process.env[envKey.trim()];363 return typeof value === 'string' && value.trim() ? value.trim() : null;364}365 