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
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§
- Webhook
Tracker Trackerover a tenant-configured webhook URL.