@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
201 lines (200 loc) • 9.99 kB
JavaScript
import { lstat, readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import path from "node:path";
import { z } from "zod";
import { DeliveryLog, parseGitboyPush, storePushMatches, verifyGitboySignature } from "../sync/gitboy-webhook.js";
import { optionalHookPeers, peerRequest } from "./peers.js";
const storeSchema = z.object({
storeId: z.string().min(1).max(128), owner: z.string().min(1).max(256), name: z.string().min(1).max(256),
branch: z.string().min(1).max(1024), secret: z.string().min(1).max(4096).optional(), secretFile: z.string().min(1).optional(),
}).strict().refine(store => Boolean(store.secret) !== Boolean(store.secretFile));
const configSchema = z.object({ listenPort: z.number().int().min(1).max(65535), stores: z.array(storeSchema).min(1).max(64) }).strict();
async function privateText(file, cap) {
const info = await lstat(file);
// POSIX permission bits carry no meaning on Windows (ACLs); there only file type and size are checked.
if (!info.isFile() || info.isSymbolicLink() || info.size > cap || (process.platform !== "win32" && (info.mode & 0o077))
|| (process.getuid && info.uid !== process.getuid()))
throw new Error("Unsafe private webhook file.");
return readFile(file, "utf8");
}
const BODY_CAP = 1024 * 1024;
function readBody(request) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const cleanup = () => { clearTimeout(timer); request.off("data", data); request.off("end", end); request.off("error", error); request.off("aborted", aborted); };
const fail = (status) => { cleanup(); request.resume(); reject(status); };
const data = (chunk) => { size += chunk.length; if (size > BODY_CAP)
fail(413);
else
chunks.push(chunk); };
const end = () => { cleanup(); resolve(Buffer.concat(chunks)); };
const error = () => fail(400);
const aborted = () => fail(400);
const timer = setTimeout(() => fail(408), 5000);
request.on("data", data);
request.once("end", end);
request.once("error", error);
request.once("aborted", aborted);
if (Number(request.headers["content-length"]) > BODY_CAP)
fail(413);
});
}
/** All peers come from the private, verified directory; peerRequest pins SSH host keys. */
async function nudgePeers(identity) {
const { peers } = await optionalHookPeers();
await Promise.allSettled(peers.map(peer => peerRequest(peer, "/v1/store/pull-now", { ...identity }, 15_000)));
}
/** Optional private HTTP ingress. Invalid config disables only this listener, never the Hook. */
export class GitboyWebhooks {
root;
pull;
nudge;
status = { configured: false, listening: false };
server;
deliveries = new DeliveryLog();
bodies = new DeliveryLog();
nudges = new Map();
closed = false;
constructor(root, pull, nudge = nudgePeers) {
this.root = root;
this.pull = pull;
this.nudge = nudge;
}
notify(store) {
// Configuration may name a local attachment. Only the ID carried by that store is portable.
let storeId = store.storeId;
try {
storeId = this.pull(storeId) ?? storeId;
}
catch { /* Peers may still have the configured portable ID. */ }
const identity = { storeId, repository: `${store.owner}/${store.name}`.toLowerCase(), branch: store.branch };
let state = this.nudges.get(storeId);
if (!state) {
state = { running: false, pending: false, last: -Infinity, identity };
this.nudges.set(storeId, state);
}
state.identity = identity;
state.pending = true;
const schedule = () => {
if (state.running || state.timer || this.closed)
return;
state.timer = setTimeout(() => {
state.timer = undefined;
if (this.closed)
return;
state.pending = false;
state.running = true;
state.last = Date.now();
void this.nudge(state.identity).catch(() => { }).finally(() => {
state.running = false;
if (state.pending)
schedule();
});
}, Math.max(0, state.last + 10_000 - Date.now()));
state.timer.unref();
};
schedule();
}
async start() {
const file = path.join(this.root, "gitboy-webhooks.json");
try {
await lstat(file);
}
catch (error) {
if (error.code === "ENOENT")
return;
this.status.problem = "Webhook config unavailable.";
return;
}
this.status.configured = true;
try {
const config = configSchema.parse(JSON.parse(await privateText(file, 65_536)));
const stores = await Promise.all(config.stores.map(async (store) => ({ ...store,
secret: store.secret ?? (await privateText(path.resolve(this.root, store.secretFile), 4096)).trim(),
})));
if (stores.some(store => !store.secret))
throw new Error("Empty secret.");
const server = createServer({ maxHeaderSize: 16_384 }, (request, response) => {
const reply = (status, outcome) => {
this.status.lastDeliveryAt = new Date().toISOString();
this.status.lastOutcome = outcome;
response.writeHead(status, { "Connection": "close", "Cache-Control": "no-store" });
response.end();
};
void (async () => {
if (request.method !== "POST" || request.url !== "/gitboy/push") {
reply(404, "not-found");
return;
}
if (![`127.0.0.1:${config.listenPort}`, `localhost:${config.listenPort}`].includes(request.headers.host ?? "")
|| request.headers.origin !== undefined || Object.keys(request.headers).some(name => name.startsWith("sec-fetch-"))) {
reply(403, "browser-refused");
return;
}
if (!/^application\/json(?:\s*;.*)?$/i.test(request.headers["content-type"] ?? "")) {
reply(415, "content-type-refused");
return;
}
const raw = await readBody(request);
const signature = request.headers["x-hub-signature-256"];
// Authenticate the raw bytes against EVERY secret before parsing or traversing the payload.
const verified = stores.filter(store => verifyGitboySignature(store.secret, raw, typeof signature === "string" ? signature : undefined));
if (!verified.length) {
reply(401, "unauthorized");
return;
}
const push = parseGitboyPush(raw, request.headers);
if (!push?.repository) {
reply(400, "malformed");
return;
}
const candidates = verified.filter(store => store.owner.toLowerCase() === push.repository.owner.toLowerCase()
&& store.name.toLowerCase() === push.repository.name.toLowerCase());
if (!candidates.length) {
reply(401, "unauthorized");
return;
}
const repo = push.repository;
const duplicateId = this.deliveries.seen(JSON.stringify([repo.owner.toLowerCase(), repo.name.toLowerCase(), push.deliveryId]));
// Per verified store set: the same body signed with another stores secret is that stores delivery.
const storeKey = candidates.map(store => store.storeId).sort().join("\u0000");
const duplicateBody = this.bodies.seen(`${storeKey}\u0000${createHash("sha256").update(raw).digest("hex")}`);
if (duplicateId || duplicateBody) {
reply(200, "duplicate");
return;
}
const matching = candidates.filter(store => storePushMatches(push, store));
if (!matching.length) {
reply(200, "non-matching");
return;
}
reply(202, "accepted");
setImmediate(() => { if (!this.closed)
for (const store of matching)
this.notify(store); });
})().catch(error => reply(typeof error === "number" ? error : 400, typeof error === "number" && error === 413 ? "too-large" : "malformed"));
});
this.server = server;
server.requestTimeout = 6000;
server.headersTimeout = 5000;
server.maxHeadersCount = 32;
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(config.listenPort, "127.0.0.1", resolve); });
server.on("error", () => { this.status.listening = false; this.status.problem = "Webhook listener failed."; });
this.status.listening = true;
this.status.listenPort = config.listenPort;
}
catch {
this.status.problem = "Webhook config invalid, unsafe, or port unavailable.";
}
}
stop() {
this.closed = true;
this.status.listening = false;
for (const state of this.nudges.values())
clearTimeout(state.timer);
this.server?.close();
this.server?.closeAllConnections();
}
}