///|
/// A fully formed webhook HTTP request ready to be sent by an HTTP transport.
pub(all) struct SignedRequest {
http_method : String
url : String
headers : Array[(String, String)]
body : String
signature : String
} derive(Eq, @debug.Debug)
///|
/// HTTP header names used by MoonHook signed requests.
pub const HEADER_SIGNATURE : String = "X-MoonHook-Signature"
///|
pub const HEADER_EVENT_ID : String = "X-MoonHook-Event-Id"
///|
pub const HEADER_EVENT_TYPE : String = "X-MoonHook-Event-Type"
///|
pub const HEADER_SUBJECT : String = "X-MoonHook-Subject"
///|
pub const HEADER_TIMESTAMP : String = "X-MoonHook-Timestamp"
///|
pub const HEADER_IDEMPOTENCY_KEY : String = "X-MoonHook-Idempotency-Key"
///|
pub const HEADER_CONTENT_TYPE : String = "Content-Type"
///|
/// Builds a signed POST request from a Hook and an event. The signature is an
/// HMAC-SHA256 digest of `"."`, sent together with the
/// timestamp in `X-MoonHook-Timestamp` so receivers can reject replays.
pub fn build_signed_request(hook : Hook, event : WebhookEvent) -> SignedRequest {
let signature = sign_webhook(hook.secret, event.timestamp_ms, event.payload)
let headers = hook.headers.copy()
headers.push((HEADER_SIGNATURE, signature))
headers.push((HEADER_EVENT_ID, event.id))
headers.push((HEADER_EVENT_TYPE, event.event_type))
headers.push((HEADER_SUBJECT, event.subject))
headers.push((HEADER_TIMESTAMP, event.timestamp_ms.to_string()))
headers.push((HEADER_IDEMPOTENCY_KEY, event.idempotency_key))
headers.push((HEADER_CONTENT_TYPE, "application/json"))
{
http_method: "POST",
url: hook.url,
headers,
body: event.payload,
signature,
}
}
///|
/// Compares two HTTP header names case-insensitively.
fn header_name_matches(left : String, right : String) -> Bool {
left.to_lower() == right.to_lower()
}
///|
/// Finds a header value by name, returning the last match. Header names are
/// matched case-insensitively, following HTTP semantics.
pub fn find_header(headers : Array[(String, String)], name : String) -> String? {
let mut found : String? = None
for item in headers {
if header_name_matches(item.0, name) {
found = Some(item.1)
}
}
found
}
///|
/// Strips the common `sha256=` prefix from a signature header value after
/// trimming surrounding whitespace. Raw hex signatures are returned unchanged.
fn strip_signature_prefix(value : String) -> String {
let trimmed = value.trim().to_owned()
match trimmed.split_once("=") {
Some((scheme, rest)) if scheme.to_owned().to_lower() == "sha256" =>
rest.to_owned()
_ => trimmed
}
}
///|
/// Verifies an incoming webhook signature header, accepting either a raw
/// lowercase hex digest or a GitHub-style `sha256=` value.
pub fn verify_signature_header(
secret : String,
body : String,
header_value : String,
) -> Bool {
verify_hmac_sha256(secret, body, strip_signature_prefix(header_value))
}
///|
/// Verifies an incoming webhook request using the shared secret, raw body, and
/// the `X-MoonHook-Signature` header.
pub fn verify_webhook_request(
secret : String,
headers : Array[(String, String)],
body : String,
) -> Bool {
match find_header(headers, HEADER_SIGNATURE) {
Some(signature) => verify_signature_header(secret, body, signature)
None => false
}
}
///|
/// Signs a webhook payload with HMAC-SHA256 over `"."`.
/// Including the timestamp lets receivers reject replayed requests.
pub fn sign_webhook(
secret : String,
timestamp_ms : Int,
payload : String,
) -> String {
sign_hmac_sha256(secret, timestamp_ms.to_string() + "." + payload)
}
///|
/// Verifies a timestamped webhook signature and requires the timestamp to fall
/// inside `[now_ms - tolerance_ms, now_ms + tolerance_ms]`.
pub fn verify_webhook(
secret : String,
timestamp_ms : Int,
payload : String,
signature : String,
now_ms : Int,
tolerance_ms : Int,
) -> Bool {
let tolerance = if tolerance_ms < 0 { 0 } else { tolerance_ms }
let in_window = timestamp_ms >= now_ms - tolerance &&
timestamp_ms <= now_ms + tolerance
in_window &&
verify_hmac_sha256(
secret,
timestamp_ms.to_string() + "." + payload,
signature,
)
}
///|
/// Verifies a MoonHook native signed request using `X-MoonHook-Timestamp` and
/// the signature header, rejecting messages outside the freshness window.
pub fn verify_webhook_request_with_timestamp(
secret : String,
headers : Array[(String, String)],
body : String,
now_ms : Int,
tolerance_ms : Int,
) -> Bool {
match find_header(headers, HEADER_TIMESTAMP) {
None => false
Some(timestamp) => {
let timestamp_ms = @string.parse_int(timestamp.trim()) catch {
_ => return false
}
match find_header(headers, HEADER_SIGNATURE) {
Some(signature) =>
verify_webhook(
secret,
timestamp_ms,
body,
strip_signature_prefix(signature),
now_ms,
tolerance_ms,
)
None => false
}
}
}
}
///|
pub fn verify_signed_request(secret : String, request : SignedRequest) -> Bool {
match find_header(request.headers, HEADER_TIMESTAMP) {
None => false
Some(timestamp) => {
let timestamp_ms = @string.parse_int(timestamp.trim()) catch {
_ => return false
}
verify_hmac_sha256(
secret,
timestamp_ms.to_string() + "." + request.body,
request.signature,
)
}
}
}