Team Ai
Apppublic

delqhi/sin-github-issues

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
github-app-routing.ts365 linesDownload Raw Back to src
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