import { env } from "cloudflare:workers";
import { recordWebhookDelivery } from "../webhook-log";
import { repoDb } from "../scoped-db";
import { decryptSecret, encryptSecret, hmacSha256Hex, randomId } from "../crypto";
import { badRequest } from "../errors";
import { assertOutboundUrl, isAllowedLoopback, isSuccess, outboundFetch } from "../integrations/outbound";
import { assertResolvesPublic } from "../net/resolve-guard";
import type { WebhookEvent } from "~/lib/webhook-events";
/** Envelope marker for secrets encrypted with the SECRETS_KEY AES-GCM wrapper; anything else is a legacy plaintext row. */
const SEALED_PREFIX = "enc:";
const WEBHOOK_SEAL = { purpose: "webhook-secret" };
const MAX_STORED_REQUEST = 64 * 1024;
export const MIN_WEBHOOK_SECRET = 8;
export const MAX_WEBHOOK_SECRET = 200;
export type LegacyWebhook = { id: string; repo_id: string; url: string; secret: string | null };
function secretsKey(): string {
if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY is not configured on this instance");
return env.SECRETS_KEY;
}
/** Validates a legacy repo webhook target: public https (or http when the operator opted in), no userinfo or IP literals. */
export function assertWebhookUrl(raw: string): URL {
return assertOutboundUrl(raw, undefined, true);
}
export async function assertWebhookTarget(raw: string): Promise<URL> {
const url = assertWebhookUrl(raw);
if (!isAllowedLoopback(url)) await assertResolvesPublic(url);
return url;
}
export async function sealWebhookSecret(secret: string | null): Promise<string | null> {
if (secret === null) return null;
const sealed = await encryptSecret(secretsKey(), secret, WEBHOOK_SEAL);
return `${SEALED_PREFIX}${sealed.iv}:${sealed.ciphertext}`;
}