sin-github-issues / src /github-app-routing.ts
OpenCode
Deploy latest SIN-GitHub-Issues runtime
cd455f6
Raw
History Blame Contribute Delete
13.5 kB
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;
}