webhooks.bend
bend-kit-webhooks@0.2.0.0 · 506 lines · 0 definitions
1# Webhook signatures: Standard Webhooks sign and verify, Stripe and GitHub verify, over the raw body. Source: https://github.com/paymog/bend-kit/tree/main/webhooks
2import Base
3import bend-kit-crypto@0.2.0.0/crypto.bend as Crypto
4import bend-kit-time@0.1.0.0/time.bend as Time
5import bend-kit-int@0.2.0.0/int.bend as Int
6# By hash, as http imports them: bend-kit-encoding@0.3.0.0, -bytes@0.3.0.0.
7import bend-kit-encoding@0.3.0.0/encoding.bend as Enc
8import bend-kit-bytes@0.3.0.0/bytes.bend as Bytes
9import bend-kit-hairpin@0.2.1.0/hairpin.bend as Hairpin
10import bend-kit-hairpin@0.2.1.0/retry.bend as Retry
11import bend-kit-http@0.23.0.1/http.bend as Http
12
13# Every MAC is HMAC-SHA256 over the body exactly as received, never a re-encoding of parsed JSON.
14# Headers are Http.Req headers: lowercase names, octet-string values. Each verify hands the body back.
15# secrets: candidates, tried in turn, for key rotation. Every MAC comparison is Crypto.eq.ct.words.
16# now is explicit, so a check is deterministic; a handler passes Time.now().
17
18type Err is Data:
19 Missing{name: String}
20 BadSecret{}
21 BadId{}
22 BadTimestamp{}
23 TooOld{}
24 TooNew{}
25 NoMatch{}
26 CryptoErr{code: U32, msg: String}
27
28# ---- headers and text
29
30def field.head(xs: List<&2, String>) -> Maybe<&2, String>:
31 match xs:
32 case Nil{}:
33 None{}
34 case Con{v, t}:
35 Some{v}
36
37def field.of(r: Map<&2, List<&2, String>> & List<&2, String>) -> Maybe<&2, String>:
38 (h, xs) = r
39 field.head(xs)
40
41def field(+h: Map<&2, List<&2, String>>, k: String) -> Maybe<&2, String>:
42 field.of(Map.get(List<&2, String>, Nil{}, h, k))
43
44def need(+name: String, m: Maybe<&2, String>) -> Result<&1, &1, Err, String>:
45 match m:
46 case None{}:
47 Fail{Missing{name}}
48 case Some{v}:
49 Done{v}
50
51# s after the prefix p, or None.
52def strip(+p: String, +s: String) -> Maybe<&2, String>:
53 Bool.pick(Maybe<&2, String>, String.starts_with(s, p), Some{String.drop(s, String.length(p))}, None{})
54
55def push(m: Maybe<&2, String>, rest: List<&2, String>) -> List<&2, String>:
56 match m:
57 case None{}:
58 rest
59 case Some{s}:
60 Con{s, rest}
61
62def put(m: Map<&2, List<&2, String>>, k: String, v: String) -> Map<&2, List<&2, String>>:
63 Map.set(&2, List<&2, String>, m, k, Con{v, Nil{}})
64
65def digits(s: String) -> Bool:
66 match s:
67 case SNil{}:
68 True{}
69 case SCon{Chr{+c}, t}:
70 Bool.and(Bool.and(U32.is_le(48, c), U32.is_le(c, 57)), digits(t))
71
72def ts.some(ok: Bool, m: Maybe<&2, Int.I64>) -> Result<&1, &1, Err, Int.I64>:
73 match ok:
74 case False{}:
75 Fail{BadTimestamp{}}
76 case True{}:
77 match m:
78 case None{}:
79 Fail{BadTimestamp{}}
80 case Some{t}:
81 Done{t}
82
83# Unix seconds: 1 to 18 ASCII digits, no sign, so the parse never overflows.
84def ts.read(+s: String) -> Result<&1, &1, Err, Int.I64>:
85 +n = String.length(s)
86 ts.some(Bool.and(Bool.and(Nat.is_lt(0n, n), Nat.is_le(n, 18n)), digits(s)), Int.I64.read(s))
87
88def window.pick(old: Bool, ahead: Bool) -> Result<&1, &1, Err, Unit>:
89 match old:
90 case True{}:
91 Fail{TooOld{}}
92 case False{}:
93 Bool.pick(Result<&1, &1, Err, Unit>, ahead, Fail{TooNew{}}, Done{Unit{}})
94
95# t within tol seconds of now, either way.
96def window(+tol: U32, now: Time.Instant, t: Int.I64) -> Result<&1, &1, Err, Unit>:
97 match now:
98 case Time.Instant{n, ns}:
99 +d = Int.I64.sub(n, t)
100 +w = Int.I64.from_u32(tol)
101 window.pick(Int.I64.is_gt(d, w), Int.I64.is_lt(d, Int.I64.neg(w)))
102
103# A webhook id is not empty and has no '.', so id.timestamp.body reads one way.
104def id.check(+id: String) -> Result<&1, &1, Err, Unit>:
105 Bool.pick(Result<&1, &1, Err, Unit>, Bool.or(String.is_empty(id), String.contains(id, ".")), Fail{BadId{}}, Done{Unit{}})
106
107# ---- keys and signatures
108
109def b64.same(+s: String, r: Bytes.Bytes & Bytes.Bytes) -> Maybe<&1, Bytes.Bytes>:
110 (b, c) = r
111 Bool.pick(Maybe<&1, Bytes.Bytes>, String.eq(Bytes.to_base64(c), s), Some{b}, None{})
112
113def b64.canon(+s: String, m: Maybe<&1, Bytes.Bytes>) -> Maybe<&1, Bytes.Bytes>:
114 match m:
115 case None{}:
116 None{}
117 case Some{b}:
118 b64.same(s, Bytes.slice(b, 0, 4294967295))
119
120# Padded standard base64, only in its canonical text: Bytes.from_base64 alone accepts nonzero pad bits.
121def b64(+s: String) -> Maybe<&1, Bytes.Bytes>:
122 b64.canon(s, Bytes.from_base64(s))
123
124# '=' up to a multiple of four chars.
125def pad(+s: String) -> String:
126 +r = (String.length(s) % 4n : Nat)
127 s ++ Bool.pick(String, Nat.is_eq(r, 2n), "==", Bool.pick(String, Nat.is_eq(r, 3n), "=", ""))
128
129def nonempty.of(b: Bytes.Bytes) -> Maybe<&1, Bytes.Bytes>:
130 Bytes.Bytes{+n, a} = b
131 Bool.pick(Maybe<&1, Bytes.Bytes>, U32.is_eq(n, 0), None{}, Some{Bytes.Bytes{n, a}})
132
133def nonempty(m: Maybe<&1, Bytes.Bytes>) -> Maybe<&1, Bytes.Bytes>:
134 match m:
135 case None{}:
136 None{}
137 case Some{b}:
138 nonempty.of(b)
139
140# A Standard Webhooks secret: whsec_ then base64 of the key, padded or not. The prefix is optional.
141def key.std(+s: String) -> Maybe<&1, Bytes.Bytes>:
142 nonempty(b64(pad(Bool.pick(String, String.starts_with(s, "whsec_"), String.drop(s, 6n), s))))
143
144# std: a Standard Webhooks secret. Otherwise the secret's UTF-8 is the key, as Stripe and GitHub use it.
145def key(+std: Bool, +s: String) -> Maybe<&1, Bytes.Bytes>:
146 match std:
147 case True{}:
148 key.std(s)
149 case False{}:
150 nonempty(Some{Enc.utf8.encode(s)})
151
152# A signature's octets: hex (Stripe, GitHub) or canonical base64 (Standard Webhooks).
153def decode(+hex: Bool, +s: String) -> Maybe<&1, Bytes.Bytes>:
154 match hex:
155 case True{}:
156 Bytes.from_hex(s)
157 case False{}:
158 b64(s)
159
160def mac.of(r: Result<&1, &1, U32 & String, U32 & Array<U32>>) -> Result<&1, &1, Err, Bytes.Bytes>:
161 match r:
162 case Fail{(c, w)}:
163 Fail{CryptoErr{c, w}}
164 case Done{(n, b)}:
165 Done{Bytes.Bytes{n, b}}
166
167def mac(k: Bytes.Bytes, data: Bytes.Bytes) -> IO(Result<&1, &1, Err, Bytes.Bytes>):
168 Bytes.Bytes{+kn, kb} = k
169 Bytes.Bytes{+dn, db} = data
170 do IO<Result<&1, &1, Err, Bytes.Bytes>>:
171 r : Result<&1, &1, U32 & String, U32 & Array<U32>> <- Crypto.hmac.words("SHA256", kn, kb, dn, db)
172 return mac.of(r)
173
174def ct.go(m: Bytes.Bytes, s: Bytes.Bytes) -> IO(Bool):
175 Bytes.Bytes{+mn, mb} = m
176 Bytes.Bytes{+sn, sb} = s
177 Crypto.eq.ct.words(mn, mb, sn, sb)
178
179# A signature that does not decode matches nothing.
180def ct(s: Maybe<&1, Bytes.Bytes>, m: Bytes.Bytes) -> IO(Bool):
181 match s:
182 case None{}:
183 IO.pure(Bool, False{})
184 case Some{b}:
185 ct.go(m, b)
186
187# Does any signature equal the MAC? r holds the MAC and a copy; every signature is compared.
188def any.sig(sigs: List<&2, String>, +hex: Bool, r: Bytes.Bytes & Bytes.Bytes) -> IO(Bool):
189 match sigs:
190 case Nil{}:
191 IO.pure(Bool, False{})
192 case Con{s, t}:
193 (m1, m2) = r
194 do IO<Bool>:
195 ok : Bool <- ct(decode(hex, s), m2)
196 rest : Bool <- any.sig(t, hex, Bytes.slice(m1, 0, 4294967295))
197 return Bool.or(ok, rest)
198
199def key.cmp(r: Result<&1, &1, Err, Bytes.Bytes>, +hex: Bool, +sigs: List<&2, String>) -> IO(Result<&1, &1, Err, Bool>):
200 match r:
201 case Fail{e}:
202 IO.pure(Result<&1, &1, Err, Bool>, Fail{e})
203 case Done{m}:
204 do IO<Result<&1, &1, Err, Bool>>:
205 ok : Bool <- any.sig(sigs, hex, Bytes.slice(m, 0, 4294967295))
206 return Done{ok}
207
208def key.check(k: Maybe<&1, Bytes.Bytes>, +hex: Bool, +sigs: List<&2, String>, msg: Bytes.Bytes) -> IO(Result<&1, &1, Err, Bool>):
209 match k:
210 case None{}:
211 IO.pure(Result<&1, &1, Err, Bool>, Fail{BadSecret{}})
212 case Some{b}:
213 do IO<Result<&1, &1, Err, Bool>>:
214 r : Result<&1, &1, Err, Bytes.Bytes> <- mac(b, msg)
215 key.cmp(r, hex, sigs)
216
217def both.done(+x: Bool, b: Result<&1, &1, Err, Bool>) -> Result<&1, &1, Err, Bool>:
218 match b:
219 case Fail{e}:
220 Fail{e}
221 case Done{y}:
222 Done{Bool.or(x, y)}
223
224# A bad secret fails the check, even when another secret matches.
225def both(a: Result<&1, &1, Err, Bool>, b: Result<&1, &1, Err, Bool>) -> Result<&1, &1, Err, Bool>:
226 match a:
227 case Fail{e}:
228 Fail{e}
229 case Done{x}:
230 both.done(x, b)
231
232# Does any signature sign the message under any secret? r holds the message and a copy.
233def any.key(keys: List<&2, String>, +std: Bool, +hex: Bool, +sigs: List<&2, String>, r: Bytes.Bytes & Bytes.Bytes) ->
234 IO(Result<&1, &1, Err, Bool>):
235 match keys:
236 case Nil{}:
237 IO.pure(Result<&1, &1, Err, Bool>, Done{False{}})
238 case Con{k, t}:
239 (m1, m2) = r
240 do IO<Result<&1, &1, Err, Bool>>:
241 a : Result<&1, &1, Err, Bool> <- key.check(key(std, k), hex, sigs, m2)
242 b : Result<&1, &1, Err, Bool> <- any.key(t, std, hex, sigs, Bytes.slice(m1, 0, 4294967295))
243 return both(a, b)
244
245def found(-A: Data, r: Result<&1, &1, Err, Bool>, v: A) -> Result<&1, &1, Err, A>:
246 match r:
247 case Fail{e}:
248 Fail{e}
249 case Done{ok}:
250 Bool.pick(Result<&1, &1, Err, A>, ok, Done{v}, Fail{NoMatch{}})
251
252# The body back beside a failure.
253def back(-A: Data, r: Bytes.Bytes & Bytes.Bytes, e: Err) -> IO(Bytes.Bytes & Result<&1, &1, Err, A>):
254 (keep, copy) = r
255 IO.pure(Bytes.Bytes & Result<&1, &1, Err, A>, (keep, Fail{e}))
256
257def run.mac(-A: Data, v: A, pre: String, +sigs: List<&2, String>, +std: Bool, +hex: Bool, secrets: List<&2, String>,
258 r: Bytes.Bytes & Bytes.Bytes) -> IO(Bytes.Bytes & Result<&1, &1, Err, A>):
259 (keep, copy) = r
260 do IO<Bytes.Bytes & Result<&1, &1, Err, A>>:
261 hit : Result<&1, &1, Err, Bool> <-
262 any.key(secrets, std, hex, sigs, Bytes.slice(Bytes.append(Bytes.from_string(pre), copy), 0, 4294967295))
263 return (keep, found(A, hit, v))
264
265# c: the header checks' answer, the signed prefix before the body, and the candidate signatures.
266def run(-A: Data, c: Result<&1, &1, Err, A & String & List<&2, String>>, +std: Bool, +hex: Bool, secrets: List<&2, String>,
267 r: Bytes.Bytes & Bytes.Bytes) -> IO(Bytes.Bytes & Result<&1, &1, Err, A>):
268 match c:
269 case Fail{e}:
270 back(A, r, e)
271 case Done{(v, pre, sigs)}:
272 run.mac(A, v, pre, sigs, std, hex, secrets, r)
273
274# ---- Standard Webhooks (https://www.standardwebhooks.com)
275
276# The v1 entries of a space-separated webhook-signature; other versions are skipped.
277def std.sigs(xs: List<&2, String>) -> List<&2, String>:
278 match xs:
279 case Nil{}:
280 Nil{}
281 case Con{x, t}:
282 push(strip("v1,", x), std.sigs(t))
283
284def std.check(+tol: U32, now: Time.Instant, +h: Map<&2, List<&2, String>>) -> Result<&1, &1, Err, String & String & List<&2, String>>:
285 do Result<&1, &1, Err, String & String & List<&2, String>>:
286 +id : String <- need("webhook-id", field(h, "webhook-id"))
287 +ts : String <- need("webhook-timestamp", field(h, "webhook-timestamp"))
288 sig : String <- need("webhook-signature", field(h, "webhook-signature"))
289 u : Unit <- id.check(id)
290 t : Int.I64 <- ts.read(ts)
291 w : Unit <- window(tol, now, t)
292 return (id, id ++ "." ++ ts ++ ".", std.sigs(String.split(sig, ' ')))
293
294# Done with the webhook-id when a v1 signature in webhook-signature signs id.timestamp.body under a secret,
295# and webhook-timestamp is within tol seconds of now.
296def verify.with(+tol: U32, secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
297 IO(Bytes.Bytes & Result<&1, &1, Err, String>):
298 run(String, std.check(tol, now, headers), True{}, False{}, secrets, Bytes.slice(body, 0, 4294967295))
299
300# verify.with a 300 s tolerance.
301def verify(secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
302 IO(Bytes.Bytes & Result<&1, &1, Err, String>):
303 verify.with(300, secrets, now, headers, body)
304
305def sign.check(+id: String, +ts: Int.I64) -> Result<&1, &1, Err, String & String>:
306 do Result<&1, &1, Err, String & String>:
307 u : Unit <- id.check(id)
308 w : Unit <- Bool.pick(Result<&1, &1, Err, Unit>, Int.I64.is_neg(ts), Fail{BadTimestamp{}}, Done{Unit{}})
309 return (id, Int.I64.show(ts))
310
311def sign.headers(+id: String, +ts: String, m: Result<&1, &1, Err, Bytes.Bytes>) -> Result<&1, &1, Err, Map<&2, List<&2, String>>>:
312 match m:
313 case Fail{e}:
314 Fail{e}
315 case Done{s}:
316 Done{put(put(put(Map.new(&2, List<&2, String>), "webhook-id", id), "webhook-timestamp", ts), "webhook-signature", "v1," ++ Bytes.to_base64(s))}
317
318def sign.mac(k: Bytes.Bytes, +id: String, +ts: String, r: Bytes.Bytes & Bytes.Bytes) ->
319 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>):
320 (keep, copy) = r
321 do IO<Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>>:
322 m : Result<&1, &1, Err, Bytes.Bytes> <- mac(k, Bytes.append(Bytes.from_string(id ++ "." ++ ts ++ "."), copy))
323 return (keep, sign.headers(id, ts, m))
324
325def sign.key(k: Maybe<&1, Bytes.Bytes>, +id: String, +ts: String, r: Bytes.Bytes & Bytes.Bytes) ->
326 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>):
327 match k:
328 case None{}:
329 back(Map<&2, List<&2, String>>, r, BadSecret{})
330 case Some{b}:
331 sign.mac(b, id, ts, r)
332
333def sign.go(c: Result<&1, &1, Err, String & String>, +secret: String, r: Bytes.Bytes & Bytes.Bytes) ->
334 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>):
335 match c:
336 case Fail{e}:
337 back(Map<&2, List<&2, String>>, r, e)
338 case Done{(id, ts)}:
339 sign.key(key.std(secret), id, ts, r)
340
341# The webhook-id, webhook-timestamp, and webhook-signature ("v1,<base64 MAC>") headers for body.
342# id: not empty, no '.'. ts: unix seconds, not negative. secret: whsec_<base64>; send the prefix.
343def sign(+id: String, +ts: Int.I64, body: Bytes.Bytes, +secret: String) ->
344 IO(Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>):
345 sign.go(sign.check(id, ts), secret, Bytes.slice(body, 0, 4294967295))
346
347# ---- Stripe (https://docs.stripe.com/webhooks#verify-manually)
348
349def stripe.t(s: String, r: List<&2, String> & List<&2, String>) -> List<&2, String> & List<&2, String>:
350 (ts, vs) = r
351 (Con{s, ts}, vs)
352
353def stripe.v.push(s: String, r: List<&2, String> & List<&2, String>) -> List<&2, String> & List<&2, String>:
354 (ts, vs) = r
355 (ts, Con{s, vs})
356
357def stripe.v(m: Maybe<&2, String>, r: List<&2, String> & List<&2, String>) -> List<&2, String> & List<&2, String>:
358 match m:
359 case None{}:
360 r
361 case Some{s}:
362 stripe.v.push(s, r)
363
364def stripe.item(t: Maybe<&2, String>, v: Maybe<&2, String>, r: List<&2, String> & List<&2, String>) ->
365 List<&2, String> & List<&2, String>:
366 match t:
367 case None{}:
368 stripe.v(v, r)
369 case Some{s}:
370 stripe.t(s, r)
371
372# The t= and v1= values of comma-separated items; other schemes (v0) are skipped.
373def stripe.items(xs: List<&2, String>) -> List<&2, String> & List<&2, String>:
374 match xs:
375 case Nil{}:
376 (Nil{}, Nil{})
377 case Con{+x, rest}:
378 stripe.item(strip("t=", x), strip("v1=", x), stripe.items(rest))
379
380def stripe.single(ts: List<&2, String>, vs: List<&2, String>) -> Result<&1, &1, Err, String & List<&2, String>>:
381 match ts:
382 case Nil{}:
383 Fail{BadTimestamp{}}
384 case Con{t, Nil{}}:
385 Done{(t, vs)}
386 case Con{t, more}:
387 Fail{BadTimestamp{}}
388
389# Exactly one t=: a second one would leave the signed timestamp ambiguous.
390def stripe.one(r: List<&2, String> & List<&2, String>) -> Result<&1, &1, Err, String & List<&2, String>>:
391 (ts, vs) = r
392 stripe.single(ts, vs)
393
394def stripe.when(+tol: U32, now: Time.Instant, p: String & List<&2, String>) -> Result<&1, &1, Err, Unit & String & List<&2, String>>:
395 (+ts, vs) = p
396 do Result<&1, &1, Err, Unit & String & List<&2, String>>:
397 t : Int.I64 <- ts.read(ts)
398 w : Unit <- window(tol, now, t)
399 return (Unit{}, ts ++ ".", vs)
400
401def stripe.check(+tol: U32, now: Time.Instant, +h: Map<&2, List<&2, String>>) -> Result<&1, &1, Err, Unit & String & List<&2, String>>:
402 do Result<&1, &1, Err, Unit & String & List<&2, String>>:
403 sig : String <- need("stripe-signature", field(h, "stripe-signature"))
404 p : String & List<&2, String> <- stripe.one(stripe.items(String.split(sig, ',')))
405 stripe.when(tol, now, p)
406
407# Done when a v1 hex signature in stripe-signature signs t.body under a secret (the whsec_ text itself, as UTF-8),
408# and t is within tol seconds of now.
409def stripe.verify.with(+tol: U32, secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
410 IO(Bytes.Bytes & Result<&1, &1, Err, Unit>):
411 run(Unit, stripe.check(tol, now, headers), False{}, True{}, secrets, Bytes.slice(body, 0, 4294967295))
412
413# stripe.verify.with a 300 s tolerance, Stripe's default.
414def stripe.verify(secrets: List<&2, String>, now: Time.Instant, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
415 IO(Bytes.Bytes & Result<&1, &1, Err, Unit>):
416 stripe.verify.with(300, secrets, now, headers, body)
417
418# ---- GitHub (https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)
419
420def github.check(+h: Map<&2, List<&2, String>>) -> Result<&1, &1, Err, Unit & String & List<&2, String>>:
421 do Result<&1, &1, Err, Unit & String & List<&2, String>>:
422 sig : String <- need("x-hub-signature-256", field(h, "x-hub-signature-256"))
423 return (Unit{}, "", push(strip("sha256=", sig), Nil{}))
424
425# Done when x-hub-signature-256 is sha256=<hex> of the body under a secret's UTF-8. GitHub signs no timestamp.
426def github.verify(secrets: List<&2, String>, +headers: Map<&2, List<&2, String>>, body: Bytes.Bytes) ->
427 IO(Bytes.Bytes & Result<&1, &1, Err, Unit>):
428 run(Unit, github.check(headers), False{}, True{}, secrets, Bytes.slice(body, 0, 4294967295))
429
430type SendErr is Data:
431 SendSign{err: Err}
432 SendNet{err: Hairpin.Err}
433
434def send.result(x: Hairpin.Client & Retry.Budget & Result<&1, &1, Hairpin.Err, Http.Res>) ->
435 Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>:
436 (c, b, r) = x
437 match r:
438 case Fail{e}:
439 (c, b, Fail{SendNet{e}})
440 case Done{res}:
441 (c, b, Done{res})
442
443# A timeout, a refused connect, 408, 429, or any 5xx may clear.
444def send.judge(r: Result<&1, &1, Http.Err, Http.Res>) ->
445 Result<&1, &1, Http.Err, Http.Res> & Maybe<&2, String>:
446 match r:
447 case Fail{e}:
448 Http.retry.judge(True{}, Fail{e})
449 case Done{res}:
450 Http.Res{+status, +headers, body} = res
451 +again = Bool.or(Http.retry.status(status), U32.is_eq((status / 100 : U32), 5))
452 (Done{Http.Res{status, headers, body}}, Bool.pick(Maybe<&2, String>, again, Some{Http.header(headers, "retry-after")}, None{}))
453
454# Receivers deduplicate on the event ID, so the POST is safe to repeat.
455def send.signed(c: Hairpin.Client, b: Retry.Budget, +url: String, r: Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>>) ->
456 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>):
457 (body, signed) = r
458 match signed:
459 case Fail{e}:
460 IO.pure(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>, (c, b, Fail{SendSign{e}}))
461 case Done{+h}:
462 do IO<Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>>:
463 enc : String <- Http.codings()
464 x : Hairpin.Client & Retry.Budget & Result<&1, &1, Hairpin.Err, Http.Res> <-
465 Hairpin.request.as(~send.judge, Hairpin.redirect(c, Http.ModeManual{}), b, True{}, enc, "POST", url,
466 Http.set(h, "content-type", "application/json"), body)
467 return send.result(x)
468
469def send.at(c: Hairpin.Client, b: Retry.Budget, url: String, id: String, secret: String, body: Bytes.Bytes, now: Time.Instant) ->
470 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>):
471 match now:
472 case Time.Instant{secs, nanos}:
473 do IO<Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>>:
474 signed : Bytes.Bytes & Result<&1, &1, Err, Map<&2, List<&2, String>>> <- sign(id, secs, body, secret)
475 send.signed(c, b, url, signed)
476
477# One event and one signature per delivery, inside a caller's retry layer. Every attempt takes from b and
478# reuses this event ID, timestamp, headers, and raw body; the client's retry setting caps this delivery.
479# The budget left comes back: pass it to the next delivery of the same operation.
480def send.in(c: Hairpin.Client, b: Retry.Budget, url: String, id: String, secret: String, body: Bytes.Bytes) ->
481 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>):
482 do IO<Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>>:
483 now : Time.Instant <- Time.now()
484 send.at(c, b, url, id, secret, body, now)
485
486def send.drop(x: Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>) -> Hairpin.Client & Result<&1, &1, SendErr, Http.Res>:
487 (c, b, r) = x
488 (c, r)
489
490def send.budget(url: String, id: String, secret: String, body: Bytes.Bytes, y: Hairpin.Client & Retry.Budget) ->
491 IO(Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res>):
492 (c, b) = y
493 send.in(c, b, url, id, secret, body)
494
495# n extra attempts, on a budget of n + 1 and the client's deadline, with Hairpin's backoff. The client comes back with retry n.
496def send.with(+n: U32, c: Hairpin.Client, url: String, id: String, secret: String, body: Bytes.Bytes) ->
497 IO(Hairpin.Client & Result<&1, &1, SendErr, Http.Res>):
498 do IO<Hairpin.Client & Result<&1, &1, SendErr, Http.Res>>:
499 y : Hairpin.Client & Retry.Budget <- Hairpin.budget(Hairpin.retry(c, n))
500 x : Hairpin.Client & Retry.Budget & Result<&1, &1, SendErr, Http.Res> <- send.budget(url, id, secret, body, y)
501 return send.drop(x)
502
503# Three extra attempts by default.
504def send(c: Hairpin.Client, url: String, id: String, secret: String, body: Bytes.Bytes) ->
505 IO(Hairpin.Client & Result<&1, &1, SendErr, Http.Res>):
506 send.with(3, c, url, id, secret, body)