import { createHmac, createPrivateKey, createSign, timingSafeEqual } from 'node:crypto'; import { readFile } from 'node:fs/promises'; type JsonObject = Record; export type GitHubAppRouteRecord = { agentSlug: string; botName?: string; appId: number; clientId?: string; appSlug?: string; routePath?: string; privateKeyPem?: string; privateKeyPath?: string; privateKeyEnv?: string; webhookSecret?: string; webhookSecretPath?: string; webhookSecretEnv?: string; }; type GitHubAppRoutingConfig = { defaultMode?: 'central' | 'path' | 'hybrid'; defaultWebhookPath?: string; apps: GitHubAppRouteRecord[]; }; type WebhookRouteInput = { payload?: unknown; rawBody?: string; routePath?: string; signature256?: string; eventName?: string; deliveryId?: string; }; type CommentAsAppInput = { repo: string; issueNumber: number; body: string; agentSlug?: string; appId?: number; installationId: number; }; const GITHUB_API_BASE = String(process.env.SIN_GITHUB_API_BASE_URL || 'https://api.github.com').trim() || 'https://api.github.com'; const GITHUB_API_VERSION = '2022-11-28'; export async function getGitHubAppRoutingStatus() { const config = await loadGitHubAppRoutingConfig(); const configuredApps = await Promise.all( config.apps.map(async (app) => { const privateKeyResolved = Boolean(await readPrivateKey(app)); const webhookSecretResolved = Boolean(await readWebhookSecret(app)); return { agentSlug: app.agentSlug, appSlug: app.appSlug || null, botName: app.botName || `${app.agentSlug}[bot]`, appId: app.appId, clientId: app.clientId || null, routePath: app.routePath || `${config.defaultWebhookPath || '/github/webhook'}/${app.agentSlug}`, privateKeyRefConfigured: Boolean(app.privateKeyPem || app.privateKeyPath || app.privateKeyEnv), privateKeyResolved, webhookSecretRefConfigured: Boolean(app.webhookSecret || app.webhookSecretPath || app.webhookSecretEnv), webhookSecretResolved, }; }), ); return { ok: true, defaultMode: config.defaultMode || 'hybrid', defaultWebhookPath: config.defaultWebhookPath || '/github/webhook', configuredApps, }; } export async function listGitHubAppWebhookPaths() { const config = await loadGitHubAppRoutingConfig(); const paths = new Set(); paths.add(config.defaultWebhookPath || '/github/webhook'); for (const app of config.apps) { if (app.routePath) paths.add(normalizeRoutePath(app.routePath)); } return [...paths]; } export async function routeGitHubWebhook(input: WebhookRouteInput) { const config = await loadGitHubAppRoutingConfig(); const payload = parsePayload(input.payload, input.rawBody); const installation = asObject(payload.installation); const installationId = parseInteger(installation?.id); const appId = parseInteger(installation?.app_id); const routePath = normalizeRoutePath(input.routePath || config.defaultWebhookPath || '/github/webhook'); const matchedApp = resolveRoute(config, { appId, routePath }); if (!matchedApp) { return { ok: false, error: 'github_app_route_not_found', routePath, appId, installationId, configuredAppIds: config.apps.map((app) => app.appId), }; } const verification = await verifyWebhookSignature(matchedApp, input.signature256, input.rawBody || ''); if (!verification.ok) { return { ok: false, error: verification.error, routePath, appId, installationId, agentSlug: matchedApp.agentSlug, botName: matchedApp.botName || `${matchedApp.agentSlug}[bot]`, }; } const repository = asObject(payload.repository); return { ok: true, route: { agentSlug: matchedApp.agentSlug, botName: matchedApp.botName || `${matchedApp.agentSlug}[bot]`, appId: matchedApp.appId, installationId, routePath: matchedApp.routePath || `${config.defaultWebhookPath || '/github/webhook'}/${matchedApp.agentSlug}`, }, event: { name: input.eventName || null, deliveryId: input.deliveryId || null, action: typeof payload.action === 'string' ? payload.action : null, repository: typeof repository?.full_name === 'string' ? repository.full_name : null, }, verification: { signatureChecked: Boolean(input.signature256), signatureValid: verification.signatureValid, }, }; } export async function commentIssueAsGitHubApp(input: CommentAsAppInput) { const config = await loadGitHubAppRoutingConfig(); const matchedApp = resolveCommentRoute(config, input); if (!matchedApp) { throw new Error(`github_app_route_not_found:${input.agentSlug || input.appId || 'unknown'}`); } const privateKey = await readPrivateKey(matchedApp); if (!privateKey) { throw new Error(`github_app_private_key_missing:${matchedApp.agentSlug}`); } const jwt = createGitHubAppJwt(matchedApp.appId, privateKey); const tokenPayload = await githubApiJson<{ token?: string; expires_at?: string }>( `${GITHUB_API_BASE}/app/installations/${input.installationId}/access_tokens`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': GITHUB_API_VERSION, }, body: '{}', }, ); if (!tokenPayload.token) { throw new Error(`github_app_installation_token_missing:${matchedApp.agentSlug}`); } const commentPayload = await githubApiJson<{ html_url?: string; id?: number }>( `${GITHUB_API_BASE}/repos/${input.repo}/issues/${input.issueNumber}/comments`, { method: 'POST', headers: { Authorization: `Bearer ${tokenPayload.token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': GITHUB_API_VERSION, 'Content-Type': 'application/json', }, body: JSON.stringify({ body: input.body }), }, ); return { ok: true, repo: input.repo, issueNumber: input.issueNumber, appId: matchedApp.appId, agentSlug: matchedApp.agentSlug, botName: matchedApp.botName || `${matchedApp.agentSlug}[bot]`, installationId: input.installationId, commentId: commentPayload.id || null, commentUrl: commentPayload.html_url || null, tokenExpiresAt: tokenPayload.expires_at || null, }; } async function loadGitHubAppRoutingConfig(): Promise { const inlineJson = String(process.env.SIN_GITHUB_APP_ROUTING_JSON || '').trim(); const configPath = String(process.env.SIN_GITHUB_APP_ROUTING_PATH || '').trim(); let parsed: unknown = { apps: [] }; if (inlineJson) { parsed = JSON.parse(inlineJson); } else if (configPath) { parsed = JSON.parse(await readFile(configPath, 'utf8')); } const asConfig = asObject(parsed) || { apps: [] }; const apps = Array.isArray(asConfig.apps) ? asConfig.apps .map((entry) => normalizeAppRecord(entry)) .filter((entry): entry is GitHubAppRouteRecord => Boolean(entry)) : []; return { defaultMode: parseMode(asConfig.defaultMode ?? asConfig.mode), defaultWebhookPath: typeof asConfig.defaultWebhookPath === 'string' ? normalizeRoutePath(asConfig.defaultWebhookPath) : '/github/webhook', apps, }; } function normalizeAppRecord(value: unknown): GitHubAppRouteRecord | null { const entry = asObject(value); if (!entry) return null; const agentSlug = firstNonEmptyString(entry.agentSlug, entry.preferredAgentSlug, entry.appSlug); const appId = parseInteger(entry.appId) ?? parseInteger(resolveEnvString(entry.appIdEnv)); if (!agentSlug || !appId) return null; const routePath = firstNonEmptyString(entry.routePath, entry.webhookPath); return { agentSlug, appSlug: firstNonEmptyString(entry.appSlug) || undefined, botName: typeof entry.botName === 'string' ? entry.botName.trim() : undefined, appId, clientId: firstNonEmptyString(entry.clientId, resolveEnvString(entry.clientIdEnv)) || undefined, routePath: routePath ? normalizeRoutePath(routePath) : undefined, privateKeyPem: typeof entry.privateKeyPem === 'string' ? entry.privateKeyPem : undefined, privateKeyPath: typeof entry.privateKeyPath === 'string' ? entry.privateKeyPath.trim() : undefined, privateKeyEnv: firstNonEmptyString(entry.privateKeyEnv) || undefined, webhookSecret: typeof entry.webhookSecret === 'string' ? entry.webhookSecret : undefined, webhookSecretPath: typeof entry.webhookSecretPath === 'string' ? entry.webhookSecretPath.trim() : undefined, webhookSecretEnv: firstNonEmptyString(entry.webhookSecretEnv) || undefined, }; } function parsePayload(payload: unknown, rawBody?: string) { if (payload && typeof payload === 'object') return payload as JsonObject; if (rawBody?.trim()) return JSON.parse(rawBody) as JsonObject; return {} as JsonObject; } function resolveRoute(config: GitHubAppRoutingConfig, input: { appId: number | null; routePath: string }) { if (input.appId) { const byAppId = config.apps.find((app) => app.appId === input.appId); if (byAppId) return byAppId; } const matchesByPath = config.apps.filter((app) => app.routePath && normalizeRoutePath(app.routePath) === input.routePath); if (matchesByPath.length === 1) return matchesByPath[0] || null; return null; } function resolveCommentRoute(config: GitHubAppRoutingConfig, input: CommentAsAppInput) { if (input.appId) { return config.apps.find((app) => app.appId === input.appId) || null; } if (input.agentSlug) { return config.apps.find((app) => app.agentSlug === input.agentSlug) || null; } return null; } async function verifyWebhookSignature(app: GitHubAppRouteRecord, signature256: string | undefined, rawBody: string) { if (!signature256) { return { ok: true, signatureValid: false }; } const secret = await readWebhookSecret(app); if (!secret) { return { ok: false, signatureValid: false, error: `github_app_webhook_secret_missing:${app.agentSlug}` }; } const expected = `sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`; const valid = safeEqual(expected, signature256.trim()); return valid ? { ok: true, signatureValid: true } : { ok: false, signatureValid: false, error: 'github_app_webhook_signature_invalid' }; } async function readPrivateKey(app: GitHubAppRouteRecord) { if (app.privateKeyPem?.trim()) return app.privateKeyPem.trim(); if (app.privateKeyEnv && process.env[app.privateKeyEnv]?.trim()) return String(process.env[app.privateKeyEnv]).trim(); if (app.privateKeyPath?.trim()) return (await readFile(app.privateKeyPath, 'utf8')).trim(); return null; } async function readWebhookSecret(app: GitHubAppRouteRecord) { if (app.webhookSecret?.trim()) return app.webhookSecret.trim(); if (app.webhookSecretEnv && process.env[app.webhookSecretEnv]?.trim()) return String(process.env[app.webhookSecretEnv]).trim(); if (app.webhookSecretPath?.trim()) return (await readFile(app.webhookSecretPath, 'utf8')).trim(); return null; } function createGitHubAppJwt(appId: number, privateKeyPem: string) { const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url'); const now = Math.floor(Date.now() / 1000); const payload = Buffer.from(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })).toString('base64url'); const signingInput = `${header}.${payload}`; const signer = createSign('RSA-SHA256'); signer.update(signingInput); signer.end(); const signature = signer.sign(createPrivateKey(privateKeyPem), 'base64url'); return `${signingInput}.${signature}`; } async function githubApiJson(url: string, init: RequestInit): Promise { const response = await fetch(url, init); const text = await response.text(); if (!response.ok) { throw new Error(`github_api_request_failed:${response.status}:${text.slice(0, 500)}`); } return text ? (JSON.parse(text) as T) : ({} as T); } function parseMode(value: unknown): 'central' | 'path' | 'hybrid' | undefined { if (value === 'central' || value === 'path' || value === 'hybrid') return value; if (value === 'central-endpoint-routing') return 'central'; if (value === 'path-routing') return 'path'; if (value === 'hybrid-routing') return 'hybrid'; return undefined; } function normalizeRoutePath(value: string) { const clean = value.split('?', 1)[0].trim(); return clean.startsWith('/') ? clean : `/${clean}`; } function parseInteger(value: unknown) { const parsed = Number.parseInt(String(value ?? '').trim(), 10); return Number.isFinite(parsed) ? parsed : null; } function asObject(value: unknown): JsonObject | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : null; } function safeEqual(left: string, right: string) { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); if (leftBuffer.length !== rightBuffer.length) return false; return timingSafeEqual(leftBuffer, rightBuffer); } function firstNonEmptyString(...values: unknown[]) { for (const value of values) { if (typeof value === 'string' && value.trim()) return value.trim(); } return null; } function resolveEnvString(envKey: unknown) { if (typeof envKey !== 'string' || !envKey.trim()) return null; const value = process.env[envKey.trim()]; return typeof value === 'string' && value.trim() ? value.trim() : null; }