185 lines
5.1 KiB
TypeScript
185 lines
5.1 KiB
TypeScript
/* eslint-disable max-classes-per-file -- webhook event envelopes and errors form one normalized module. */
|
|
import { Effect, Schema } from "effect";
|
|
|
|
import { GitProvider } from "./git-provider";
|
|
|
|
const Text = Schema.String.check(
|
|
Schema.makeFilter((value) => value.trim().length > 0, {
|
|
expected: "a non-empty string",
|
|
})
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Supported event names
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const GithubEventName = Schema.Literals([
|
|
"ping",
|
|
"push",
|
|
"repository",
|
|
"delete",
|
|
"create",
|
|
"public",
|
|
"member",
|
|
"organization",
|
|
"fork",
|
|
"release",
|
|
]);
|
|
export type GithubEventName = typeof GithubEventName.Type;
|
|
|
|
export const PuterEventName = Schema.Literals([
|
|
"ping",
|
|
"push",
|
|
"repository",
|
|
"create",
|
|
"delete",
|
|
"fork",
|
|
"release",
|
|
]);
|
|
export type PuterEventName = typeof PuterEventName.Type;
|
|
|
|
/**
|
|
* Events we normalize into repository lifecycle now: repository changes,
|
|
* pushes, deletions, transfers, and visibility changes.
|
|
*/
|
|
export const PROCESSED_EVENTS = new Set([
|
|
"push",
|
|
"repository",
|
|
"delete",
|
|
"create",
|
|
"public",
|
|
"fork",
|
|
]);
|
|
|
|
export const isProcessedEvent = (event: string): boolean =>
|
|
PROCESSED_EVENTS.has(event);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Signature verification inputs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const SignatureVerificationInput = Schema.Struct({
|
|
body: Schema.String,
|
|
provider: GitProvider,
|
|
signature: Text,
|
|
/** Hex-encoded HMAC signature from the provider header. */
|
|
signatureHeaderName: Text,
|
|
token: Text,
|
|
});
|
|
export type SignatureVerificationInput = typeof SignatureVerificationInput.Type;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Webhook delivery processing states
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const DeliveryState = Schema.Literals([
|
|
"received",
|
|
"processing",
|
|
"processed",
|
|
"ignored",
|
|
"failed",
|
|
"duplicate",
|
|
]);
|
|
export type DeliveryState = typeof DeliveryState.Type;
|
|
|
|
export const isTerminalDelivery = (state: DeliveryState): boolean =>
|
|
state === "processed" ||
|
|
state === "ignored" ||
|
|
state === "failed" ||
|
|
state === "duplicate";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Normalized repository event envelope
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const RepositoryEventAction = Schema.Literals([
|
|
"push",
|
|
"created",
|
|
"deleted",
|
|
"transferred",
|
|
"visibility_changed",
|
|
"default_branch_changed",
|
|
"renamed",
|
|
"forked",
|
|
"other",
|
|
]);
|
|
export type RepositoryEventAction = typeof RepositoryEventAction.Type;
|
|
|
|
export const RepositoryEventEnvelope = Schema.Struct({
|
|
action: RepositoryEventAction,
|
|
/** Provider-assigned delivery GUID. */
|
|
deliveryId: Text,
|
|
event: Text,
|
|
/** External repository ID from the provider payload. */
|
|
externalRepositoryId: Text,
|
|
/** Full owner/name from the provider payload. */
|
|
fullName: Text,
|
|
owner: Text,
|
|
provider: GitProvider,
|
|
pushedTo: Schema.UndefinedOr(Text),
|
|
/** Ref or branch that changed, when applicable. */
|
|
ref: Schema.UndefinedOr(Text),
|
|
refType: Schema.UndefinedOr(Text),
|
|
repositoryName: Text,
|
|
/** ISO-like timestamp from the provider, or epoch 0 when absent. */
|
|
repositoryUpdatedAt: Schema.Number,
|
|
serverUrl: Text,
|
|
visibility: Schema.UndefinedOr(Text),
|
|
});
|
|
export type RepositoryEventEnvelope = typeof RepositoryEventEnvelope.Type;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Webhook errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const WebhookErrorReason = Schema.Literals([
|
|
"InvalidInput",
|
|
"Unauthorized",
|
|
"NotFound",
|
|
"Conflict",
|
|
"InvalidResponse",
|
|
"PayloadTooLarge",
|
|
]);
|
|
export type WebhookErrorReason = typeof WebhookErrorReason.Type;
|
|
|
|
export class WebhookError extends Schema.TaggedErrorClass<WebhookError>()(
|
|
"WebhookError",
|
|
{
|
|
message: Schema.String,
|
|
reason: WebhookErrorReason,
|
|
}
|
|
) {}
|
|
|
|
export const MAX_WEBHOOK_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
|
|
|
/**
|
|
* Apply a payload-size limit before JSON decoding. Rejects payloads larger
|
|
* than the maximum before any parsing.
|
|
*/
|
|
export const assertPayloadSize = (
|
|
byteLength: number
|
|
): Effect.Effect<void, WebhookError> =>
|
|
Effect.gen(function* assert() {
|
|
if (byteLength > MAX_WEBHOOK_PAYLOAD_BYTES) {
|
|
return yield* Effect.fail(
|
|
new WebhookError({
|
|
message: `Webhook payload exceeds ${MAX_WEBHOOK_PAYLOAD_BYTES} bytes`,
|
|
reason: "PayloadTooLarge",
|
|
})
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Delivery idempotency helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Construct the idempotency key for a webhook delivery. Duplicate deliveries
|
|
* with the same provider/deliveryId are no-ops.
|
|
*/
|
|
export const deliveryIdempotencyKey = (
|
|
provider: GitProvider,
|
|
deliveryId: string
|
|
): string => `webhook:${provider}:${deliveryId}`;
|