Skip to main content

Crate cratefield_adapter_webhook_tracker

Crate cratefield_adapter_webhook_tracker 

Source
Expand description

cratefield-adapter-webhook-tracker: the Tracker port over a tenant-configured webhook (issue #432). It POSTs the ticket as a JSON document signed with HMAC-SHA256, through the runtime’s HttpClient and Clock ports — no vendor SDK — so the same adapter runs on Workers and natively (ADR 0002: the port lives in core, the vendor client here).

The URL is attacker-adjacent, so it is never fetched directly. A webhook destination comes from tenant config, which is exactly the kind of value an SSRF rides in on. It goes out through the HttpClient port, whose contract refuses non-http(s) schemes, userinfo, and loopback, private, link-local and cloud-metadata destinations — re-vetting every redirect hop — and reports a refusal as HttpError::BlockedDestination, which this adapter maps to TrackerError::Rejected: a blocked destination is a config error, not weather a retry fixes.

Idempotency is delegated. A webhook cannot be searched the way the sibling cratefield-adapter-github-issues searches a repository, so the receiver dedupes on the draft’s idempotency_key, which the payload carries. There is no tracker-side id to learn back, so the Filed this adapter returns reports the key as the id and deduplicated: false.

cratefield-adapter-webhook-tracker on crates.io cratefield-adapter-webhook-tracker documentation MIT

§cratefield-adapter-webhook-tracker

The Tracker port over a tenant-configured webhook for the Cratefield harness (issue #432). It POSTs the ticket as a JSON document signed with HMAC-SHA256, through the runtime’s HttpClient port — no vendor SDK, no reqwest — so the same adapter runs unchanged on Cloudflare Workers and natively (ADR 0002: the port is core’s, the vendor client is this crate’s).

§Usage

ⓘ
use std::sync::Arc;
use cratefield_adapter_webhook_tracker::WebhookTracker;
use cratefield_core::{Destination, TicketDraft};
use cratefield_runtime_cloudflare::{FetchClient, WorkersClock};

let tracker = WebhookTracker::new(Arc::new(FetchClient), Arc::new(WorkersClock), signing_secret);
let filed = tracker
    .file(
        &TicketDraft::new(
            "Outbox: refund failed",
            "The refund webhook failed twice.",
            Destination::Webhook {
                url: "https://ops.example.test/hooks/tickets".into(),
            },
            "01JFILED000000000000000000",
        )
        .labels(["from-outbox"]),
    )
    .await?;

The Clock is a constructor argument rather than a builder default so a deployment that forgets it fails to compile — it is what stamps both the payload and the signature, so the two cannot disagree.

§The signature

Every delivery carries one header, Stripe-style:

Cratefield-Signature: t=<unix seconds>,v1=<lowercase hex>

The MAC is HMAC-SHA256 under the shared secret, computed over the bytes {t}.{raw_body} — the timestamp is bound into the signature, so a captured delivery cannot be replayed with a fresh timestamp: re-stamping invalidates the MAC. That is the reason for this scheme over a separate, unsigned timestamp header. The body is built once, signed, and sent — the signed bytes are the wire bytes.

§Idempotency is delegated

A webhook cannot be searched the way the sibling cratefield-adapter-github-issues searches a repository, so the receiver dedupes: the payload carries the draft’s idempotency_key, and a repeat delivery with the same key is answered once. There is no tracker-side id to learn back, so the returned Filed reports the idempotency key as its id and deduplicated: false.

§Error mapping

To cratefield_core::TrackerError: 401/403 → Unauthorized, 404 → Rejected, 422 → Rejected, 429/5xx → Transient { retry_after } (Retry-After, both the seconds and the HTTP-date form), any other 4xx → Rejected. A destination the HttpClient port’s SSRF vetting refuses — the URL comes from tenant config, so it is never fetched directly — is Rejected: a config error, not weather a retry fixes. No error Display ever includes the signing secret. A draft whose destination is not Destination::Webhook is Rejected before any network call.


MIT. Built in the open for Cratefield, a Factory Zero venture.

Structs§

WebhookTracker
Tracker over a tenant-configured webhook URL.