import { cache } from "@devpulse/cache"; import { config } from "@devpulse/config"; import { formatBytes } from "@devpulse/core"; import { db, users } from "@devpulse/database"; import { logger } from "@devpulse/logger"; import { metricsCollector } from "@devpulse/monitoring"; import { encryptSecret } from "@devpulse/security"; import { createHmac, timingSafeEqual } from "crypto"; import { eq, sql } from "drizzle-orm"; import Fastify, { type FastifyBaseLogger, type FastifyInstance } from "fastify"; const RATE_LIMIT_WINDOW = 60; const RATE_LIMIT_MAX = 100; async function rateLimitHook( req: { ip: string }, reply: { status: (code: number) => { send: (body: unknown) => void } }, ) { const key = `ratelimit:api:${req.ip}`; const result = await cache.checkRateLimit(key, RATE_LIMIT_MAX, RATE_LIMIT_WINDOW); if (!result.allowed) { reply.status(429).send({ error: "Too Many Requests", retryAfter: result.resetSeconds, }); } } export interface ServerOptions { discordClient?: { isReady?: () => boolean; ws?: { ping?: number }; guilds?: { cache?: { size: number } }; user?: { tag?: string } | null; }; } /** * Builds and configures the Fastify HTTP server with health, readiness, * metrics, and GitHub OAuth endpoints. Includes rate limiting. */ export function buildServer(options?: ServerOptions): FastifyInstance { const server = Fastify({ loggerInstance: logger as FastifyBaseLogger, }); server.addHook("onRequest", rateLimitHook); server.get("/health", async () => { const memory = process.memoryUsage(); const discordStatus = options?.discordClient ? { connected: options.discordClient.isReady ? options.discordClient.isReady() : false, pingMs: options.discordClient.ws?.ping ?? -1, guildsCount: options.discordClient.guilds?.cache?.size ?? 0, user: options.discordClient.user?.tag ?? null, } : undefined; return { status: "healthy", service: "DevPulse API", uptimeSeconds: Math.floor(process.uptime()), timestamp: new Date().toISOString(), ...(discordStatus ? { discord: discordStatus } : {}), memory: { rss: formatBytes(memory.rss), heapUsed: formatBytes(memory.heapUsed), heapTotal: formatBytes(memory.heapTotal), }, }; }); server.get("/ready", async (_req, reply) => { try { // Test DB await db.run(sql`SELECT 1`); // Test Cache await cache.set("devpulse:readiness-probe", "ok", 10); const cacheVal = await cache.get("devpulse:readiness-probe"); if (cacheVal !== "ok") { return reply.status(503).send({ status: "degraded", reason: "Cache read failure" }); } return { status: "ready", database: "connected", cache: "connected", ...(options?.discordClient ? { discord: options.discordClient.isReady?.() ? "connected" : "connecting" } : {}), timestamp: new Date().toISOString(), }; } catch (err: unknown) { logger.error({ err }, "Readiness check failed"); return reply.status(503).send({ status: "unready", error: err instanceof Error ? err.message : String(err), }); } }); server.get("/metrics", async () => { const allServers = await db.query.servers.findMany(); const allRepos = await db.query.repositories.findMany(); const allMonitors = await db.query.monitors.findMany(); return { serversCount: allServers.length, repositoriesTracked: allRepos.length, monitorsActive: allMonitors.filter((m) => m.isActive).length, monitorsTotal: allMonitors.length, ...(options?.discordClient?.guilds?.cache ? { discordGuilds: options.discordClient.guilds.cache.size } : {}), nodeVersion: process.version, platform: process.platform, detailed: metricsCollector.getSnapshot(), }; }); server.get("/metrics/prometheus", async (_req, reply) => { reply.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8"); return metricsCollector.getPrometheusMetrics(); }); // -------------------------------------------------------------------------- // GitHub Webhook Endpoint // -------------------------------------------------------------------------- server.post("/webhooks/github", async (req, reply) => { const signature = req.headers["x-hub-signature-256"] as string | undefined; const event = req.headers["x-github-event"] as string | undefined; const deliveryId = req.headers["x-github-delivery"] as string | undefined; if (!event) { return reply.status(400).send({ error: "Missing X-GitHub-Event header" }); } // Verify webhook signature if secret is configured if (config.GITHUB_WEBHOOK_SECRET && signature) { const payload = JSON.stringify(req.body); const expectedSig = `sha256=${createHmac("sha256", config.GITHUB_WEBHOOK_SECRET).update(payload).digest("hex")}`; const sigBytes = Buffer.from(signature); const expectedBytes = Buffer.from(expectedSig); if (sigBytes.length !== expectedBytes.length || !timingSafeEqual(sigBytes, expectedBytes)) { logger.warn({ deliveryId, event }, "Webhook signature verification failed"); return reply.status(401).send({ error: "Invalid signature" }); } } const payload = req.body as Record; const repo = (payload.repository as { full_name?: string })?.full_name || "unknown"; logger.info({ event, repo, deliveryId }, "GitHub webhook received"); // Store event for deduplication const eventId = `webhook:${deliveryId || Date.now()}:${event}`; const existing = await cache.get(eventId); if (existing) { return reply.status(200).send({ status: "duplicate" }); } await cache.set(eventId, "1", 3600); // Process event type switch (event) { case "release": { const action = payload.action as string; const release = payload.release as { tag_name?: string; html_url?: string } | undefined; if (action === "published" && release) { logger.info({ repo, tag: release.tag_name }, "New release published"); } break; } case "pull_request": { const action = payload.action as string; const pr = payload.pull_request as { number?: number; title?: string } | undefined; if (action === "opened" && pr) { logger.info({ repo, pr: pr.number, title: pr.title }, "New PR opened"); } break; } case "issues": { const action = payload.action as string; const issue = payload.issue as { number?: number; title?: string } | undefined; if (action === "opened" && issue) { logger.info({ repo, issue: issue.number, title: issue.title }, "New issue opened"); } break; } case "push": { const commits = payload.commits as Array<{ message?: string }> | undefined; const branch = (payload.ref as string)?.replace("refs/heads/", ""); logger.info({ repo, branch, commitCount: commits?.length || 0 }, "Push received"); break; } default: logger.debug({ event, repo }, "Unhandled webhook event type"); } return reply.status(200).send({ status: "ok", event, repo }); }); // -------------------------------------------------------------------------- // GitHub OAuth Endpoints // -------------------------------------------------------------------------- server.get("/auth/github/login", async (req, reply) => { const { userId } = req.query as { userId?: string }; if (!config.GITHUB_CLIENT_ID) { return reply .status(500) .type("text/html") .send("

Error: GITHUB_CLIENT_ID is not configured in .env

"); } const redirectUri = config.GITHUB_REDIRECT_URI || `${req.protocol}://${req.headers.host || req.hostname}/auth/github/callback`; const state = userId || "anonymous"; const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${encodeURIComponent( config.GITHUB_CLIENT_ID, )}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,read:user&state=${encodeURIComponent(state)}`; return reply.redirect(githubAuthUrl); }); server.get("/auth/github/callback", async (req, reply) => { const { code, state } = req.query as { code?: string; state?: string }; if (!code) { return reply .status(400) .type("text/html") .send("

Error: Missing authorization code from GitHub

"); } if (!config.GITHUB_CLIENT_ID || !config.GITHUB_CLIENT_SECRET) { return reply .status(500) .type("text/html") .send("

Error: GitHub OAuth credentials not configured on server

"); } try { const tokenRes = await fetch("https://github.com/login/oauth/access_token", { method: "POST", headers: { Accept: "application/json", "Content-Type": "application/json", }, body: JSON.stringify({ client_id: config.GITHUB_CLIENT_ID, client_secret: config.GITHUB_CLIENT_SECRET, code, }), }); const tokenData = (await tokenRes.json()) as { access_token?: string; error?: string; error_description?: string; }; if (!tokenData.access_token) { return reply .status(400) .type("text/html") .send( `

Authentication Failed

${tokenData.error_description || "Could not retrieve access token."}

`, ); } const userRes = await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${tokenData.access_token}`, "User-Agent": "DevPulse-Discord-Bot", }, }); const ghUser = (await userRes.json()) as { login: string; id: number }; const encrypted = encryptSecret(tokenData.access_token); const discordUserId = state && state !== "anonymous" ? state : null; if (discordUserId) { const existing = await db.query.users.findFirst({ where: eq(users.id, discordUserId), }); if (existing) { await db .update(users) .set({ githubUsername: ghUser.login, githubTokenEncrypted: encrypted, updatedAt: new Date(), }) .where(eq(users.id, discordUserId)); } else { await db.insert(users).values({ id: discordUserId, username: ghUser.login, githubUsername: ghUser.login, githubTokenEncrypted: encrypted, }); } } return reply.type("text/html").send(` DevPulse - Connected

GitHub Connected

@${ghUser.login}

Your GitHub account has been securely linked and encrypted with AES-256-GCM. You can now close this tab and return to Discord!

`); } catch (err: unknown) { logger.error({ err }, "GitHub OAuth exchange failed"); return reply .status(500) .type("text/html") .send( `

Internal Server Error

${err instanceof Error ? err.message : String(err)}

`, ); } }); return server; }