Spaces:
Sleeping
Sleeping
| import { createHmac, createPrivateKey, createSign, timingSafeEqual } from 'node:crypto'; | |
| import { readFile } from 'node:fs/promises'; | |
| type JsonObject = Record<string, unknown>; | |
| 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<string>(); | |
| 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<GitHubAppRoutingConfig> { | |
| 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<T>(url: string, init: RequestInit): Promise<T> { | |
| 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; | |
| } | |