@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
120 lines (119 loc) • 4.56 kB
JavaScript
/**
* 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;
}
}