UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

120 lines (119 loc) • 4.56 kB
/** * Transport-agnostic core for gitboy push webhooks: signature checks, push payload parsing, * matching a push to a store's branch, and delivery de-duplication. Used by the optional * Hook receiver; periodic polling (pull.ts) stays the fallback. */ import { createHmac, timingSafeEqual } from "crypto"; const SIGNATURE_PREFIX = "sha256="; const SHA256_HEX = /^[0-9a-f]{64}$/i; /** True when `header` is `sha256=<hex HMAC-SHA256 of rawBody>` under `secret`. */ export function verifyGitboySignature(secret, rawBody, header) { if (!secret || typeof header !== "string" || !header.startsWith(SIGNATURE_PREFIX)) return false; const hex = header.slice(SIGNATURE_PREFIX.length); if (!SHA256_HEX.test(hex)) return false; const expected = createHmac("sha256", secret).update(rawBody).digest(); const given = Buffer.from(hex, "hex"); return given.length === expected.length && timingSafeEqual(given, expected); } function header(headers, name) { const wanted = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() !== wanted) continue; const first = Array.isArray(value) ? value[0] : value; return typeof first === "string" ? first : undefined; } return undefined; } function isRecord(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } /** gitboy's push payload names the repository `full_name: "owner/name"` beside `name`. */ function parseRepository(value) { if (!isRecord(value) || typeof value.full_name !== "string") return null; const slash = value.full_name.indexOf("/"); if (slash <= 0) return null; const owner = value.full_name.slice(0, slash); const name = value.full_name.slice(slash + 1); if (!name || name.includes("/")) return null; return { owner, name }; } function parseUpdate(value) { if (!isRecord(value)) return null; const { ref, old, new: next } = value; if (typeof ref !== "string" || typeof old !== "string" || typeof next !== "string") return null; const paths = value.changed_paths; const changedPaths = Array.isArray(paths) && paths.every((p) => typeof p === "string") ? paths : null; return { ref, old, new: next, changedPaths, truncated: value.changed_paths_truncated === true }; } /** A gitboy `push` delivery, or null for any other event, a missing delivery id or a malformed body. Never throws. */ export function parseGitboyPush(rawBody, headers) { if (header(headers, "x-gitboy-event") !== "push") return null; const deliveryId = header(headers, "x-gitboy-delivery"); if (!deliveryId) return null; let payload; try { payload = JSON.parse(rawBody.toString("utf8")); } catch { return null; } if (!isRecord(payload) || !Array.isArray(payload.updates)) return null; const updates = []; for (const entry of payload.updates) { const update = parseUpdate(entry); if (update) updates.push(update); } return { deliveryId, event: "push", repository: parseRepository(payload.repository), updates }; } /** * True when the push is to the store's repository (owner and name compared case-insensitively, * as gitboy resolves them) and moves `refs/heads/<branch>` exactly. */ export function storePushMatches(push, store) { const repo = push.repository; if (!repo || !store.branch) return false; if (repo.owner.toLowerCase() !== store.owner.toLowerCase()) return false; if (repo.name.toLowerCase() !== store.name.toLowerCase()) return false; const ref = `refs/heads/${store.branch}`; return push.updates.some((update) => update.ref === ref); } export const DEFAULT_DELIVERY_LOG_SIZE = 1_000; /** Recently seen delivery ids, oldest evicted first. gitboy retries keep the delivery id. */ export class DeliveryLog { capacity; ids = new Set(); constructor(capacity = DEFAULT_DELIVERY_LOG_SIZE) { this.capacity = capacity; } /** Records `id` and reports whether it had already been seen. */ seen(id) { if (this.ids.has(id)) return true; this.ids.add(id); while (this.ids.size > this.capacity) { const oldest = this.ids.values().next().value; if (oldest === undefined) break; this.ids.delete(oldest); } return false; } get size() { return this.ids.size; } }