@smthrs/notifications 1.0.0-rc.4 → 1.0.0-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Release candidate scope, host requirements and compatibility review are defined in the [library support policy](https://github.com/smithersai/smithers/blob/main/RELEASE_SUPPORT.md).
4
4
 
5
- This package declares `effect` as an exact
6
- `4.0.0-rc.115` peer dependency. Keep the application on that version so
7
- all Smithers packages share one Effect runtime.
5
+ This package declares `effect` as a `^4.0.4` peer dependency. Install one
6
+ `effect` release in that range so all Smithers packages share one Effect
7
+ runtime.
8
8
 
9
9
  **Documentation:** https://notifications.smithers.sh
10
10
 
@@ -35,6 +35,13 @@ export declare const defaultWebhookTimeout: Duration.Duration;
35
35
  * Each delivery has its own scope, which aborts the request and releases any
36
36
  * unread response body on completion, failure, timeout, or interruption.
37
37
  *
38
+ * The url is treated as a credential, because a webhook's secret usually is
39
+ * its path or query. The delivery's HTTP span is named for the method and
40
+ * records the scheme, host, port and answering status, never the path, the
41
+ * query or userinfo, and it fails with the same url-free reason the sink
42
+ * returns. A host that records HTTP headers on spans gets every name in
43
+ * `headers`, and a redirect's `location`, as `<redacted>`.
44
+ *
38
45
  * A non-2xx response is a failure, not a delivery, and an endpoint that never
39
46
  * answers is a failure after `timeout`. Paging is exactly the case where a
40
47
  * silently dropped request is worse than a retry, and a hung request is worse
@@ -1 +1 @@
1
- {"version":3,"file":"layerWebhook.d.ts","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,QAAQ,EAAU,KAAK,EAAU,MAAM,QAAQ,CAAA;AAKxD,OAAO,EAAc,IAAI,EAAE,MAAM,gBAAgB,CAAA;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,QAAQ,CAAC,QAA+B,CAAA;AAmD5E;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS;IACP,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAA;CACjD,KACA,KAAK,CAAC,KAAK,CAAC,IAAI,CAmD2B,CAAA"}
1
+ {"version":3,"file":"layerWebhook.d.ts","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAS,QAAQ,EAAgB,KAAK,EAAkB,MAAM,QAAQ,CAAA;AAM7E,OAAO,EAAc,IAAI,EAAE,MAAM,gBAAgB,CAAA;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,QAAQ,CAAC,QAA+B,CAAA;AAoH5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS;IACP,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAA;CACjD,KACA,KAAK,CAAC,KAAK,CAAC,IAAI,CAwD2B,CAAA"}
@@ -33,9 +33,10 @@ __export(layerWebhook_exports, {
33
33
  });
34
34
  module.exports = __toCommonJS(layerWebhook_exports);
35
35
  var import_effect = require("effect");
36
- var FetchHttpClient = __toESM(require("effect/unstable/http/FetchHttpClient"), 1);
37
- var HttpClient = __toESM(require("effect/unstable/http/HttpClient"), 1);
38
- var HttpClientRequest = __toESM(require("effect/unstable/http/HttpClientRequest"), 1);
36
+ var FetchHttpClient = __toESM(require("effect/http/FetchHttpClient"), 1);
37
+ var Headers = __toESM(require("effect/http/Headers"), 1);
38
+ var HttpClient = __toESM(require("effect/http/HttpClient"), 1);
39
+ var HttpClientRequest = __toESM(require("effect/http/HttpClientRequest"), 1);
39
40
  var import_AlertPolicy = require("./AlertPolicy.js");
40
41
  var import_AlertSink = require("./AlertSink.js");
41
42
  const defaultWebhookTimeout = import_effect.Duration.seconds(10);
@@ -59,12 +60,50 @@ const followingNoRedirects = (effect) => import_effect.Effect.flatMap(
59
60
  redirect: "manual"
60
61
  })
61
62
  );
63
+ const rejected = (status) => new import_AlertSink.AlertError({ code: "sink_rejected", status, message: `Alert webhook answered ${status}` });
64
+ const originAttributes = /* @__PURE__ */ new Set([
65
+ "http.request.method",
66
+ "url.scheme",
67
+ "server.address",
68
+ "server.port",
69
+ "http.response.status_code",
70
+ "error.type"
71
+ ]);
72
+ const isHeaderAttribute = (key) => key.startsWith("http.request.header.") || key.startsWith("http.response.header.");
73
+ const redirectHeaderNames = ["location", "content-location"];
74
+ const withoutRequest = (tracer) => import_effect.Tracer.make({
75
+ ...tracer,
76
+ span(options) {
77
+ const span = tracer.span(options);
78
+ const end = span.end.bind(span);
79
+ const attribute = span.attribute.bind(span);
80
+ span.attribute = (key, value) => {
81
+ if (originAttributes.has(key) || isHeaderAttribute(key)) attribute(key, value);
82
+ };
83
+ span.end = (endTime, exit) => {
84
+ if (exit._tag === "Success") return end(endTime, import_effect.Exit.void);
85
+ if (import_effect.Cause.hasInterruptsOnly(exit.cause)) return end(endTime, exit);
86
+ const status = span.attributes.get("http.response.status_code");
87
+ return end(
88
+ endTime,
89
+ import_effect.Exit.fail(
90
+ typeof status === "number" ? rejected(status) : new import_AlertSink.AlertError({ code: "sink_unreachable", message: "Alert webhook could not be reached" })
91
+ )
92
+ );
93
+ };
94
+ return span;
95
+ }
96
+ });
62
97
  const layerWebhook = (options) => import_effect.Layer.effect(
63
98
  import_AlertSink.Sink,
64
99
  import_effect.Effect.gen(function* () {
65
100
  const url = yield* import_effect.Effect.orDie(webhookUrl(options.url));
66
101
  const client = HttpClient.withScope(yield* HttpClient.HttpClient);
67
102
  const timeout = options.timeout ?? defaultWebhookTimeout;
103
+ const redactedNames = [
104
+ ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),
105
+ ...redirectHeaderNames
106
+ ];
68
107
  return {
69
108
  deliver: (alert) => client.execute(
70
109
  HttpClientRequest.post(url).pipe(
@@ -74,14 +113,13 @@ const layerWebhook = (options) => import_effect.Layer.effect(
74
113
  )
75
114
  ).pipe(
76
115
  followingNoRedirects,
116
+ import_effect.Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),
117
+ // A host may name HTTP spans for their url. This one is named for
118
+ // its method, as the client names it by default.
119
+ import_effect.Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method),
120
+ import_effect.Effect.updateService(import_effect.Tracer.Tracer, withoutRequest),
77
121
  import_effect.Effect.flatMap(
78
- (response) => response.status >= 200 && response.status < 300 ? import_effect.Effect.void : import_effect.Effect.fail(
79
- new import_AlertSink.AlertError({
80
- code: "sink_rejected",
81
- status: response.status,
82
- message: `Alert webhook answered ${response.status}`
83
- })
84
- )
122
+ (response) => response.status >= 200 && response.status < 300 ? import_effect.Effect.void : import_effect.Effect.fail(rejected(response.status))
85
123
  ),
86
124
  import_effect.Effect.timeout(timeout),
87
125
  import_effect.Effect.scoped,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/layerWebhook.ts"],
4
- "sourcesContent": ["/**\n * The webhook alert sink: one POST per alert, keyed so the receiver can\n * dedupe on it.\n *\n * The transport rules live here rather than beside the policy, because which\n * endpoint is paged, over which scheme, carrying which credential is a\n * deployment's decision and the delays are not.\n *\n * @since 1.0.0\n */\n\nimport { Duration, Effect, Layer, Option } from \"effect\"\nimport * as FetchHttpClient from \"effect/unstable/http/FetchHttpClient\"\nimport * as HttpClient from \"effect/unstable/http/HttpClient\"\nimport * as HttpClientRequest from \"effect/unstable/http/HttpClientRequest\"\nimport { alertId } from \"./AlertPolicy.ts\"\nimport { AlertError, Sink } from \"./AlertSink.ts\"\n\n/**\n * How long the webhook sink waits for an answer before it calls the page\n * refused.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)\n\n/**\n * The one check the webhook endpoint gets before an alert is ever built on\n * it.\n *\n * The url is operator config rather than runtime input, so there is no SSRF\n * vector to close here; the scheme bound is what keeps a credential the\n * deployment set in `headers` from being handed to a scheme that is not HTTP\n * at all. A `file:` or `javascript:` url is a config bug, and it fails the\n * composition the way an impossible policy rule does. The error names no\n * url: the value can carry basic-auth credentials, and an error is logged.\n */\nconst webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>\n Effect.suspend(() => {\n const misconfigured = new AlertError({\n code: \"sink_misconfigured\",\n message: \"Alert webhook url must be an absolute http: or https: URL\"\n })\n let parsed: URL\n try {\n parsed = new URL(raw)\n } catch {\n return Effect.fail(misconfigured)\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\"\n ? Effect.succeed(raw)\n : Effect.fail(misconfigured)\n })\n\n/**\n * Runs a webhook request with redirect following switched off at the fetch\n * transport.\n *\n * The request carries whatever credential the deployment put in `headers`,\n * and a transport that follows a 3xx re-sends those headers to an origin the\n * deployment never named. Every other fetch default the composition set is\n * kept; only the redirect mode is forced. A 3xx therefore surfaces to the\n * status rule, which calls it `sink_rejected`: a redirecting pager endpoint\n * is a misconfiguration to fix, not an address to forward credentials to.\n */\nconst followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>\n Effect.flatMap(\n Effect.serviceOption(FetchHttpClient.RequestInit),\n (init) =>\n Effect.provideService(effect, FetchHttpClient.RequestInit, {\n ...(Option.isSome(init) ? init.value : {}),\n redirect: \"manual\"\n })\n )\n\n/**\n * A sink that POSTs each alert to one webhook.\n *\n * The body is the alert plus its {@link alertId}, and the same id is sent as\n * an `Idempotency-Key` header, because the package requires the receiver to\n * dedupe on it and a body without it cannot. The header is set after the\n * caller's headers, so it is the one this sink sends.\n *\n * The endpoint must be an absolute `http:` or `https:` URL, checked when the\n * layer is built, and the request is made with redirect following disabled:\n * the caller's headers can carry a credential, and a followed redirect would\n * re-send them to an origin the deployment never named. A 3xx answer is a\n * refusal like any other non-2xx, not a forwarding instruction. The sink owns\n * its Fetch transport; an externally supplied `HttpClient` is not used.\n * Each delivery has its own scope, which aborts the request and releases any\n * unread response body on completion, failure, timeout, or interruption.\n *\n * A non-2xx response is a failure, not a delivery, and an endpoint that never\n * answers is a failure after `timeout`. Paging is exactly the case where a\n * silently dropped request is worse than a retry, and a hung request is worse\n * than either: it is indistinguishable from silence, so it must not be\n * possible to wait on one forever.\n *\n * @param options the endpoint, any headers it needs, and how long to wait\n * @category layers\n * @since 0.1.0\n */\nexport const layerWebhook = (\n options: {\n readonly url: string\n readonly headers?: Readonly<Record<string, string>> | undefined\n readonly timeout?: Duration.Duration | undefined\n }\n): Layer.Layer<Sink> =>\n Layer.effect(\n Sink,\n Effect.gen(function*() {\n const url = yield* Effect.orDie(webhookUrl(options.url))\n const client = HttpClient.withScope(yield* HttpClient.HttpClient)\n const timeout = options.timeout ?? defaultWebhookTimeout\n return {\n deliver: (alert) =>\n client.execute(\n HttpClientRequest.post(url).pipe(\n (request) =>\n options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),\n HttpClientRequest.setHeader(\"Idempotency-Key\", alertId(alert)),\n HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })\n )\n ).pipe(\n followingNoRedirects,\n Effect.flatMap((response) =>\n response.status >= 200 && response.status < 300\n ? Effect.void\n : Effect.fail(\n new AlertError({\n code: \"sink_rejected\",\n status: response.status,\n message: `Alert webhook answered ${response.status}`\n })\n )\n ),\n Effect.timeout(timeout),\n Effect.scoped,\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(\n new AlertError({\n code: \"sink_timeout\",\n message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`\n })\n )),\n // The transport error holds the request, and the request holds the\n // caller's headers. Only its reason tag crosses into the failure.\n Effect.catchTag(\"HttpClientError\", (cause) =>\n Effect.fail(\n new AlertError({\n code: \"sink_unreachable\",\n reason: cause.reason._tag,\n message: \"Alert webhook could not be reached\"\n })\n ))\n )\n }\n })\n ).pipe(Layer.provide(FetchHttpClient.layer))\n"],
5
- "mappings": ";;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWA,oBAAgD;AAChD,sBAAiC;AACjC,iBAA4B;AAC5B,wBAAmC;AACnC,yBAAwB;AACxB,uBAAiC;AAS1B,MAAM,wBAA2C,uBAAS,QAAQ,EAAE;AAa3E,MAAM,aAAa,CAAC,QAClB,qBAAO,QAAQ,MAAM;AACnB,QAAM,gBAAgB,IAAI,4BAAW;AAAA,IACnC,MAAM;AAAA,IACN,SAAS;AAAA,EACX,CAAC;AACD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,GAAG;AAAA,EACtB,QAAQ;AACN,WAAO,qBAAO,KAAK,aAAa;AAAA,EAClC;AACA,SAAO,OAAO,aAAa,WAAW,OAAO,aAAa,WACtD,qBAAO,QAAQ,GAAG,IAClB,qBAAO,KAAK,aAAa;AAC/B,CAAC;AAaH,MAAM,uBAAuB,CAAU,WACrC,qBAAO;AAAA,EACL,qBAAO,cAAc,gBAAgB,WAAW;AAAA,EAChD,CAAC,SACC,qBAAO,eAAe,QAAQ,gBAAgB,aAAa;AAAA,IACzD,GAAI,qBAAO,OAAO,IAAI,IAAI,KAAK,QAAQ,CAAC;AAAA,IACxC,UAAU;AAAA,EACZ,CAAC;AACL;AA6BK,MAAM,eAAe,CAC1B,YAMA,oBAAM;AAAA,EACJ;AAAA,EACA,qBAAO,IAAI,aAAY;AACrB,UAAM,MAAM,OAAO,qBAAO,MAAM,WAAW,QAAQ,GAAG,CAAC;AACvD,UAAM,SAAS,WAAW,UAAU,OAAO,WAAW,UAAU;AAChE,UAAM,UAAU,QAAQ,WAAW;AACnC,WAAO;AAAA,MACL,SAAS,CAAC,UACR,OAAO;AAAA,QACL,kBAAkB,KAAK,GAAG,EAAE;AAAA,UAC1B,CAAC,YACC,QAAQ,YAAY,SAAY,UAAU,kBAAkB,WAAW,SAAS,QAAQ,OAAO;AAAA,UACjG,kBAAkB,UAAU,uBAAmB,4BAAQ,KAAK,CAAC;AAAA,UAC7D,kBAAkB,eAAe,EAAE,GAAG,OAAO,aAAS,4BAAQ,KAAK,EAAE,CAAC;AAAA,QACxE;AAAA,MACF,EAAE;AAAA,QACA;AAAA,QACA,qBAAO;AAAA,UAAQ,CAAC,aACd,SAAS,UAAU,OAAO,SAAS,SAAS,MACxC,qBAAO,OACP,qBAAO;AAAA,YACP,IAAI,4BAAW;AAAA,cACb,MAAM;AAAA,cACN,QAAQ,SAAS;AAAA,cACjB,SAAS,0BAA0B,SAAS,MAAM;AAAA,YACpD,CAAC;AAAA,UACH;AAAA,QACJ;AAAA,QACA,qBAAO,QAAQ,OAAO;AAAA,QACtB,qBAAO;AAAA,QACP,qBAAO,SAAS,gBAAgB,MAC9B,qBAAO;AAAA,UACL,IAAI,4BAAW;AAAA,YACb,MAAM;AAAA,YACN,SAAS,uCAAuC,uBAAS,SAAS,OAAO,CAAC;AAAA,UAC5E,CAAC;AAAA,QACH,CAAC;AAAA;AAAA;AAAA,QAGH,qBAAO,SAAS,mBAAmB,CAAC,UAClC,qBAAO;AAAA,UACL,IAAI,4BAAW;AAAA,YACb,MAAM;AAAA,YACN,QAAQ,MAAM,OAAO;AAAA,YACrB,SAAS;AAAA,UACX,CAAC;AAAA,QACH,CAAC;AAAA,MACL;AAAA,IACJ;AAAA,EACF,CAAC;AACH,EAAE,KAAK,oBAAM,QAAQ,gBAAgB,KAAK,CAAC;",
4
+ "sourcesContent": ["/**\n * The webhook alert sink: one POST per alert, keyed so the receiver can\n * dedupe on it.\n *\n * The transport rules live here rather than beside the policy, because which\n * endpoint is paged, over which scheme, carrying which credential is a\n * deployment's decision and the delays are not.\n *\n * @since 1.0.0\n */\n\nimport { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from \"effect\"\nimport * as FetchHttpClient from \"effect/http/FetchHttpClient\"\nimport * as Headers from \"effect/http/Headers\"\nimport * as HttpClient from \"effect/http/HttpClient\"\nimport * as HttpClientRequest from \"effect/http/HttpClientRequest\"\nimport { alertId } from \"./AlertPolicy.ts\"\nimport { AlertError, Sink } from \"./AlertSink.ts\"\n\n/**\n * How long the webhook sink waits for an answer before it calls the page\n * refused.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)\n\n/**\n * The one check the webhook endpoint gets before an alert is ever built on\n * it.\n *\n * The url is operator config rather than runtime input, so there is no SSRF\n * vector to close here; the scheme bound is what keeps a credential the\n * deployment set in `headers` from being handed to a scheme that is not HTTP\n * at all. A `file:` or `javascript:` url is a config bug, and it fails the\n * composition the way an impossible policy rule does. The error names no\n * url: the value can carry basic-auth credentials, and an error is logged.\n */\nconst webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>\n Effect.suspend(() => {\n const misconfigured = new AlertError({\n code: \"sink_misconfigured\",\n message: \"Alert webhook url must be an absolute http: or https: URL\"\n })\n let parsed: URL\n try {\n parsed = new URL(raw)\n } catch {\n return Effect.fail(misconfigured)\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\"\n ? Effect.succeed(raw)\n : Effect.fail(misconfigured)\n })\n\n/**\n * Runs a webhook request with redirect following switched off at the fetch\n * transport.\n *\n * The request carries whatever credential the deployment put in `headers`,\n * and a transport that follows a 3xx re-sends those headers to an origin the\n * deployment never named. Every other fetch default the composition set is\n * kept; only the redirect mode is forced. A 3xx therefore surfaces to the\n * status rule, which calls it `sink_rejected`: a redirecting pager endpoint\n * is a misconfiguration to fix, not an address to forward credentials to.\n */\nconst followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>\n Effect.flatMap(\n Effect.serviceOption(FetchHttpClient.RequestInit),\n (init) =>\n Effect.provideService(effect, FetchHttpClient.RequestInit, {\n ...(Option.isSome(init) ? init.value : {}),\n redirect: \"manual\"\n })\n )\n\nconst rejected = (status: number): AlertError =>\n new AlertError({ code: \"sink_rejected\", status, message: `Alert webhook answered ${status}` })\n\n/**\n * The span attributes of a delivery that say nothing of the url's path, query\n * or userinfo: the method, the origin and the answer.\n */\nconst originAttributes: ReadonlySet<string> = new Set([\n \"http.request.method\",\n \"url.scheme\",\n \"server.address\",\n \"server.port\",\n \"http.response.status_code\",\n \"error.type\"\n])\n\nconst isHeaderAttribute = (key: string): boolean =>\n key.startsWith(\"http.request.header.\") || key.startsWith(\"http.response.header.\")\n\n/**\n * The headers of a redirect that name where the endpoint moved, which is the\n * same secret under another address.\n */\nconst redirectHeaderNames: ReadonlyArray<string> = [\"location\", \"content-location\"]\n\n/**\n * The tracer a delivery opens its HTTP span on: the caller's own, with nothing\n * of the request left on the span.\n *\n * A webhook url is a credential. Slack, Discord and PagerDuty put the secret\n * in the path, others take it in the query, and a url may carry basic-auth\n * userinfo. The HTTP client writes the whole url on its span and again in the\n * failure it ends the span with, and the answer or the transport error it ends\n * with holds the request. So only the attributes that name the method, the\n * origin and the answer are kept, with the headers a host chose to record, and\n * the span ends with the outcome alone: nothing on success, the refusing\n * status, or that the endpoint could not be reached.\n */\nconst withoutRequest = (tracer: Tracer.Tracer): Tracer.Tracer =>\n Tracer.make({\n ...tracer,\n span(options) {\n const span = tracer.span(options)\n const end = span.end.bind(span)\n const attribute = span.attribute.bind(span)\n span.attribute = (key, value) => {\n if (originAttributes.has(key) || isHeaderAttribute(key)) attribute(key, value)\n }\n span.end = (endTime, exit) => {\n if (exit._tag === \"Success\") return end(endTime, Exit.void)\n if (Cause.hasInterruptsOnly(exit.cause)) return end(endTime, exit)\n const status = span.attributes.get(\"http.response.status_code\")\n return end(\n endTime,\n Exit.fail(\n typeof status === \"number\"\n ? rejected(status)\n : new AlertError({ code: \"sink_unreachable\", message: \"Alert webhook could not be reached\" })\n )\n )\n }\n return span\n }\n })\n\n/**\n * A sink that POSTs each alert to one webhook.\n *\n * The body is the alert plus its {@link alertId}, and the same id is sent as\n * an `Idempotency-Key` header, because the package requires the receiver to\n * dedupe on it and a body without it cannot. The header is set after the\n * caller's headers, so it is the one this sink sends.\n *\n * The endpoint must be an absolute `http:` or `https:` URL, checked when the\n * layer is built, and the request is made with redirect following disabled:\n * the caller's headers can carry a credential, and a followed redirect would\n * re-send them to an origin the deployment never named. A 3xx answer is a\n * refusal like any other non-2xx, not a forwarding instruction. The sink owns\n * its Fetch transport; an externally supplied `HttpClient` is not used.\n * Each delivery has its own scope, which aborts the request and releases any\n * unread response body on completion, failure, timeout, or interruption.\n *\n * The url is treated as a credential, because a webhook's secret usually is\n * its path or query. The delivery's HTTP span is named for the method and\n * records the scheme, host, port and answering status, never the path, the\n * query or userinfo, and it fails with the same url-free reason the sink\n * returns. A host that records HTTP headers on spans gets every name in\n * `headers`, and a redirect's `location`, as `<redacted>`.\n *\n * A non-2xx response is a failure, not a delivery, and an endpoint that never\n * answers is a failure after `timeout`. Paging is exactly the case where a\n * silently dropped request is worse than a retry, and a hung request is worse\n * than either: it is indistinguishable from silence, so it must not be\n * possible to wait on one forever.\n *\n * @param options the endpoint, any headers it needs, and how long to wait\n * @category layers\n * @since 0.1.0\n */\nexport const layerWebhook = (\n options: {\n readonly url: string\n readonly headers?: Readonly<Record<string, string>> | undefined\n readonly timeout?: Duration.Duration | undefined\n }\n): Layer.Layer<Sink> =>\n Layer.effect(\n Sink,\n Effect.gen(function*() {\n const url = yield* Effect.orDie(webhookUrl(options.url))\n const client = HttpClient.withScope(yield* HttpClient.HttpClient)\n const timeout = options.timeout ?? defaultWebhookTimeout\n // `Headers.isRedactedName` compares a string matcher against an\n // already-lowercased header name without lowercasing the matcher.\n const redactedNames = [\n ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),\n ...redirectHeaderNames\n ]\n return {\n deliver: (alert) =>\n client.execute(\n HttpClientRequest.post(url).pipe(\n (request) =>\n options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),\n HttpClientRequest.setHeader(\"Idempotency-Key\", alertId(alert)),\n HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })\n )\n ).pipe(\n followingNoRedirects,\n Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),\n // A host may name HTTP spans for their url. This one is named for\n // its method, as the client names it by default.\n Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method),\n Effect.updateService(Tracer.Tracer, withoutRequest),\n Effect.flatMap((response) =>\n response.status >= 200 && response.status < 300\n ? Effect.void\n : Effect.fail(rejected(response.status))\n ),\n Effect.timeout(timeout),\n Effect.scoped,\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(\n new AlertError({\n code: \"sink_timeout\",\n message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`\n })\n )),\n // The transport error holds the request, and the request holds the\n // caller's headers. Only its reason tag crosses into the failure.\n Effect.catchTag(\"HttpClientError\", (cause) =>\n Effect.fail(\n new AlertError({\n code: \"sink_unreachable\",\n reason: cause.reason._tag,\n message: \"Alert webhook could not be reached\"\n })\n ))\n )\n }\n })\n ).pipe(Layer.provide(FetchHttpClient.layer))\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -35,6 +35,13 @@ export declare const defaultWebhookTimeout: Duration.Duration;
35
35
  * Each delivery has its own scope, which aborts the request and releases any
36
36
  * unread response body on completion, failure, timeout, or interruption.
37
37
  *
38
+ * The url is treated as a credential, because a webhook's secret usually is
39
+ * its path or query. The delivery's HTTP span is named for the method and
40
+ * records the scheme, host, port and answering status, never the path, the
41
+ * query or userinfo, and it fails with the same url-free reason the sink
42
+ * returns. A host that records HTTP headers on spans gets every name in
43
+ * `headers`, and a redirect's `location`, as `<redacted>`.
44
+ *
38
45
  * A non-2xx response is a failure, not a delivery, and an endpoint that never
39
46
  * answers is a failure after `timeout`. Paging is exactly the case where a
40
47
  * silently dropped request is worse than a retry, and a hung request is worse
@@ -1 +1 @@
1
- {"version":3,"file":"layerWebhook.d.ts","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,QAAQ,EAAU,KAAK,EAAU,MAAM,QAAQ,CAAA;AAKxD,OAAO,EAAc,IAAI,EAAE,MAAM,gBAAgB,CAAA;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,QAAQ,CAAC,QAA+B,CAAA;AAmD5E;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS;IACP,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAA;CACjD,KACA,KAAK,CAAC,KAAK,CAAC,IAAI,CAmD2B,CAAA"}
1
+ {"version":3,"file":"layerWebhook.d.ts","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAS,QAAQ,EAAgB,KAAK,EAAkB,MAAM,QAAQ,CAAA;AAM7E,OAAO,EAAc,IAAI,EAAE,MAAM,gBAAgB,CAAA;AAEjD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,EAAE,QAAQ,CAAC,QAA+B,CAAA;AAoH5E;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,eAAO,MAAM,YAAY,GACvB,SAAS;IACP,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,CAAA;IAC/D,QAAQ,CAAC,OAAO,CAAC,EAAE,QAAQ,CAAC,QAAQ,GAAG,SAAS,CAAA;CACjD,KACA,KAAK,CAAC,KAAK,CAAC,IAAI,CAwD2B,CAAA"}
@@ -8,10 +8,11 @@
8
8
  *
9
9
  * @since 1.0.0
10
10
  */
11
- import { Duration, Effect, Layer, Option } from "effect";
12
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
13
- import * as HttpClient from "effect/unstable/http/HttpClient";
14
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
11
+ import { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from "effect";
12
+ import * as FetchHttpClient from "effect/http/FetchHttpClient";
13
+ import * as Headers from "effect/http/Headers";
14
+ import * as HttpClient from "effect/http/HttpClient";
15
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
15
16
  import { alertId } from "./AlertPolicy.js";
16
17
  import { AlertError, Sink } from "./AlertSink.js";
17
18
  /**
@@ -64,6 +65,61 @@ const followingNoRedirects = (effect) => Effect.flatMap(Effect.serviceOption(Fet
64
65
  ...(Option.isSome(init) ? init.value : {}),
65
66
  redirect: "manual"
66
67
  }));
68
+ const rejected = (status) => new AlertError({ code: "sink_rejected", status, message: `Alert webhook answered ${status}` });
69
+ /**
70
+ * The span attributes of a delivery that say nothing of the url's path, query
71
+ * or userinfo: the method, the origin and the answer.
72
+ */
73
+ const originAttributes = new Set([
74
+ "http.request.method",
75
+ "url.scheme",
76
+ "server.address",
77
+ "server.port",
78
+ "http.response.status_code",
79
+ "error.type"
80
+ ]);
81
+ const isHeaderAttribute = (key) => key.startsWith("http.request.header.") || key.startsWith("http.response.header.");
82
+ /**
83
+ * The headers of a redirect that name where the endpoint moved, which is the
84
+ * same secret under another address.
85
+ */
86
+ const redirectHeaderNames = ["location", "content-location"];
87
+ /**
88
+ * The tracer a delivery opens its HTTP span on: the caller's own, with nothing
89
+ * of the request left on the span.
90
+ *
91
+ * A webhook url is a credential. Slack, Discord and PagerDuty put the secret
92
+ * in the path, others take it in the query, and a url may carry basic-auth
93
+ * userinfo. The HTTP client writes the whole url on its span and again in the
94
+ * failure it ends the span with, and the answer or the transport error it ends
95
+ * with holds the request. So only the attributes that name the method, the
96
+ * origin and the answer are kept, with the headers a host chose to record, and
97
+ * the span ends with the outcome alone: nothing on success, the refusing
98
+ * status, or that the endpoint could not be reached.
99
+ */
100
+ const withoutRequest = (tracer) => Tracer.make({
101
+ ...tracer,
102
+ span(options) {
103
+ const span = tracer.span(options);
104
+ const end = span.end.bind(span);
105
+ const attribute = span.attribute.bind(span);
106
+ span.attribute = (key, value) => {
107
+ if (originAttributes.has(key) || isHeaderAttribute(key))
108
+ attribute(key, value);
109
+ };
110
+ span.end = (endTime, exit) => {
111
+ if (exit._tag === "Success")
112
+ return end(endTime, Exit.void);
113
+ if (Cause.hasInterruptsOnly(exit.cause))
114
+ return end(endTime, exit);
115
+ const status = span.attributes.get("http.response.status_code");
116
+ return end(endTime, Exit.fail(typeof status === "number"
117
+ ? rejected(status)
118
+ : new AlertError({ code: "sink_unreachable", message: "Alert webhook could not be reached" })));
119
+ };
120
+ return span;
121
+ }
122
+ });
67
123
  /**
68
124
  * A sink that POSTs each alert to one webhook.
69
125
  *
@@ -81,6 +137,13 @@ const followingNoRedirects = (effect) => Effect.flatMap(Effect.serviceOption(Fet
81
137
  * Each delivery has its own scope, which aborts the request and releases any
82
138
  * unread response body on completion, failure, timeout, or interruption.
83
139
  *
140
+ * The url is treated as a credential, because a webhook's secret usually is
141
+ * its path or query. The delivery's HTTP span is named for the method and
142
+ * records the scheme, host, port and answering status, never the path, the
143
+ * query or userinfo, and it fails with the same url-free reason the sink
144
+ * returns. A host that records HTTP headers on spans gets every name in
145
+ * `headers`, and a redirect's `location`, as `<redacted>`.
146
+ *
84
147
  * A non-2xx response is a failure, not a delivery, and an endpoint that never
85
148
  * answers is a failure after `timeout`. Paging is exactly the case where a
86
149
  * silently dropped request is worse than a retry, and a hung request is worse
@@ -95,14 +158,19 @@ export const layerWebhook = (options) => Layer.effect(Sink, Effect.gen(function*
95
158
  const url = yield* Effect.orDie(webhookUrl(options.url));
96
159
  const client = HttpClient.withScope(yield* HttpClient.HttpClient);
97
160
  const timeout = options.timeout ?? defaultWebhookTimeout;
161
+ // `Headers.isRedactedName` compares a string matcher against an
162
+ // already-lowercased header name without lowercasing the matcher.
163
+ const redactedNames = [
164
+ ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),
165
+ ...redirectHeaderNames
166
+ ];
98
167
  return {
99
- deliver: (alert) => client.execute(HttpClientRequest.post(url).pipe((request) => options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers), HttpClientRequest.setHeader("Idempotency-Key", alertId(alert)), HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) }))).pipe(followingNoRedirects, Effect.flatMap((response) => response.status >= 200 && response.status < 300
168
+ deliver: (alert) => client.execute(HttpClientRequest.post(url).pipe((request) => options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers), HttpClientRequest.setHeader("Idempotency-Key", alertId(alert)), HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) }))).pipe(followingNoRedirects, Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),
169
+ // A host may name HTTP spans for their url. This one is named for
170
+ // its method, as the client names it by default.
171
+ Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method), Effect.updateService(Tracer.Tracer, withoutRequest), Effect.flatMap((response) => response.status >= 200 && response.status < 300
100
172
  ? Effect.void
101
- : Effect.fail(new AlertError({
102
- code: "sink_rejected",
103
- status: response.status,
104
- message: `Alert webhook answered ${response.status}`
105
- }))), Effect.timeout(timeout), Effect.scoped, Effect.catchTag("TimeoutError", () => Effect.fail(new AlertError({
173
+ : Effect.fail(rejected(response.status))), Effect.timeout(timeout), Effect.scoped, Effect.catchTag("TimeoutError", () => Effect.fail(new AlertError({
106
174
  code: "sink_timeout",
107
175
  message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`
108
176
  }))),
@@ -1 +1 @@
1
- {"version":3,"file":"layerWebhook.js","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The webhook alert sink: one POST per alert, keyed so the receiver can\n * dedupe on it.\n *\n * The transport rules live here rather than beside the policy, because which\n * endpoint is paged, over which scheme, carrying which credential is a\n * deployment's decision and the delays are not.\n *\n * @since 1.0.0\n */\n\nimport { Duration, Effect, Layer, Option } from \"effect\"\nimport * as FetchHttpClient from \"effect/unstable/http/FetchHttpClient\"\nimport * as HttpClient from \"effect/unstable/http/HttpClient\"\nimport * as HttpClientRequest from \"effect/unstable/http/HttpClientRequest\"\nimport { alertId } from \"./AlertPolicy.ts\"\nimport { AlertError, Sink } from \"./AlertSink.ts\"\n\n/**\n * How long the webhook sink waits for an answer before it calls the page\n * refused.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)\n\n/**\n * The one check the webhook endpoint gets before an alert is ever built on\n * it.\n *\n * The url is operator config rather than runtime input, so there is no SSRF\n * vector to close here; the scheme bound is what keeps a credential the\n * deployment set in `headers` from being handed to a scheme that is not HTTP\n * at all. A `file:` or `javascript:` url is a config bug, and it fails the\n * composition the way an impossible policy rule does. The error names no\n * url: the value can carry basic-auth credentials, and an error is logged.\n */\nconst webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>\n Effect.suspend(() => {\n const misconfigured = new AlertError({\n code: \"sink_misconfigured\",\n message: \"Alert webhook url must be an absolute http: or https: URL\"\n })\n let parsed: URL\n try {\n parsed = new URL(raw)\n } catch {\n return Effect.fail(misconfigured)\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\"\n ? Effect.succeed(raw)\n : Effect.fail(misconfigured)\n })\n\n/**\n * Runs a webhook request with redirect following switched off at the fetch\n * transport.\n *\n * The request carries whatever credential the deployment put in `headers`,\n * and a transport that follows a 3xx re-sends those headers to an origin the\n * deployment never named. Every other fetch default the composition set is\n * kept; only the redirect mode is forced. A 3xx therefore surfaces to the\n * status rule, which calls it `sink_rejected`: a redirecting pager endpoint\n * is a misconfiguration to fix, not an address to forward credentials to.\n */\nconst followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>\n Effect.flatMap(\n Effect.serviceOption(FetchHttpClient.RequestInit),\n (init) =>\n Effect.provideService(effect, FetchHttpClient.RequestInit, {\n ...(Option.isSome(init) ? init.value : {}),\n redirect: \"manual\"\n })\n )\n\n/**\n * A sink that POSTs each alert to one webhook.\n *\n * The body is the alert plus its {@link alertId}, and the same id is sent as\n * an `Idempotency-Key` header, because the package requires the receiver to\n * dedupe on it and a body without it cannot. The header is set after the\n * caller's headers, so it is the one this sink sends.\n *\n * The endpoint must be an absolute `http:` or `https:` URL, checked when the\n * layer is built, and the request is made with redirect following disabled:\n * the caller's headers can carry a credential, and a followed redirect would\n * re-send them to an origin the deployment never named. A 3xx answer is a\n * refusal like any other non-2xx, not a forwarding instruction. The sink owns\n * its Fetch transport; an externally supplied `HttpClient` is not used.\n * Each delivery has its own scope, which aborts the request and releases any\n * unread response body on completion, failure, timeout, or interruption.\n *\n * A non-2xx response is a failure, not a delivery, and an endpoint that never\n * answers is a failure after `timeout`. Paging is exactly the case where a\n * silently dropped request is worse than a retry, and a hung request is worse\n * than either: it is indistinguishable from silence, so it must not be\n * possible to wait on one forever.\n *\n * @param options the endpoint, any headers it needs, and how long to wait\n * @category layers\n * @since 0.1.0\n */\nexport const layerWebhook = (\n options: {\n readonly url: string\n readonly headers?: Readonly<Record<string, string>> | undefined\n readonly timeout?: Duration.Duration | undefined\n }\n): Layer.Layer<Sink> =>\n Layer.effect(\n Sink,\n Effect.gen(function*() {\n const url = yield* Effect.orDie(webhookUrl(options.url))\n const client = HttpClient.withScope(yield* HttpClient.HttpClient)\n const timeout = options.timeout ?? defaultWebhookTimeout\n return {\n deliver: (alert) =>\n client.execute(\n HttpClientRequest.post(url).pipe(\n (request) =>\n options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),\n HttpClientRequest.setHeader(\"Idempotency-Key\", alertId(alert)),\n HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })\n )\n ).pipe(\n followingNoRedirects,\n Effect.flatMap((response) =>\n response.status >= 200 && response.status < 300\n ? Effect.void\n : Effect.fail(\n new AlertError({\n code: \"sink_rejected\",\n status: response.status,\n message: `Alert webhook answered ${response.status}`\n })\n )\n ),\n Effect.timeout(timeout),\n Effect.scoped,\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(\n new AlertError({\n code: \"sink_timeout\",\n message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`\n })\n )),\n // The transport error holds the request, and the request holds the\n // caller's headers. Only its reason tag crosses into the failure.\n Effect.catchTag(\"HttpClientError\", (cause) =>\n Effect.fail(\n new AlertError({\n code: \"sink_unreachable\",\n reason: cause.reason._tag,\n message: \"Alert webhook could not be reached\"\n })\n ))\n )\n }\n })\n ).pipe(Layer.provide(FetchHttpClient.layer))\n"]}
1
+ {"version":3,"file":"layerWebhook.js","sourceRoot":"","sources":["../../src/layerWebhook.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The webhook alert sink: one POST per alert, keyed so the receiver can\n * dedupe on it.\n *\n * The transport rules live here rather than beside the policy, because which\n * endpoint is paged, over which scheme, carrying which credential is a\n * deployment's decision and the delays are not.\n *\n * @since 1.0.0\n */\n\nimport { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from \"effect\"\nimport * as FetchHttpClient from \"effect/http/FetchHttpClient\"\nimport * as Headers from \"effect/http/Headers\"\nimport * as HttpClient from \"effect/http/HttpClient\"\nimport * as HttpClientRequest from \"effect/http/HttpClientRequest\"\nimport { alertId } from \"./AlertPolicy.ts\"\nimport { AlertError, Sink } from \"./AlertSink.ts\"\n\n/**\n * How long the webhook sink waits for an answer before it calls the page\n * refused.\n *\n * @category constants\n * @since 1.0.0\n */\nexport const defaultWebhookTimeout: Duration.Duration = Duration.seconds(10)\n\n/**\n * The one check the webhook endpoint gets before an alert is ever built on\n * it.\n *\n * The url is operator config rather than runtime input, so there is no SSRF\n * vector to close here; the scheme bound is what keeps a credential the\n * deployment set in `headers` from being handed to a scheme that is not HTTP\n * at all. A `file:` or `javascript:` url is a config bug, and it fails the\n * composition the way an impossible policy rule does. The error names no\n * url: the value can carry basic-auth credentials, and an error is logged.\n */\nconst webhookUrl = (raw: string): Effect.Effect<string, AlertError> =>\n Effect.suspend(() => {\n const misconfigured = new AlertError({\n code: \"sink_misconfigured\",\n message: \"Alert webhook url must be an absolute http: or https: URL\"\n })\n let parsed: URL\n try {\n parsed = new URL(raw)\n } catch {\n return Effect.fail(misconfigured)\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\"\n ? Effect.succeed(raw)\n : Effect.fail(misconfigured)\n })\n\n/**\n * Runs a webhook request with redirect following switched off at the fetch\n * transport.\n *\n * The request carries whatever credential the deployment put in `headers`,\n * and a transport that follows a 3xx re-sends those headers to an origin the\n * deployment never named. Every other fetch default the composition set is\n * kept; only the redirect mode is forced. A 3xx therefore surfaces to the\n * status rule, which calls it `sink_rejected`: a redirecting pager endpoint\n * is a misconfiguration to fix, not an address to forward credentials to.\n */\nconst followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> =>\n Effect.flatMap(\n Effect.serviceOption(FetchHttpClient.RequestInit),\n (init) =>\n Effect.provideService(effect, FetchHttpClient.RequestInit, {\n ...(Option.isSome(init) ? init.value : {}),\n redirect: \"manual\"\n })\n )\n\nconst rejected = (status: number): AlertError =>\n new AlertError({ code: \"sink_rejected\", status, message: `Alert webhook answered ${status}` })\n\n/**\n * The span attributes of a delivery that say nothing of the url's path, query\n * or userinfo: the method, the origin and the answer.\n */\nconst originAttributes: ReadonlySet<string> = new Set([\n \"http.request.method\",\n \"url.scheme\",\n \"server.address\",\n \"server.port\",\n \"http.response.status_code\",\n \"error.type\"\n])\n\nconst isHeaderAttribute = (key: string): boolean =>\n key.startsWith(\"http.request.header.\") || key.startsWith(\"http.response.header.\")\n\n/**\n * The headers of a redirect that name where the endpoint moved, which is the\n * same secret under another address.\n */\nconst redirectHeaderNames: ReadonlyArray<string> = [\"location\", \"content-location\"]\n\n/**\n * The tracer a delivery opens its HTTP span on: the caller's own, with nothing\n * of the request left on the span.\n *\n * A webhook url is a credential. Slack, Discord and PagerDuty put the secret\n * in the path, others take it in the query, and a url may carry basic-auth\n * userinfo. The HTTP client writes the whole url on its span and again in the\n * failure it ends the span with, and the answer or the transport error it ends\n * with holds the request. So only the attributes that name the method, the\n * origin and the answer are kept, with the headers a host chose to record, and\n * the span ends with the outcome alone: nothing on success, the refusing\n * status, or that the endpoint could not be reached.\n */\nconst withoutRequest = (tracer: Tracer.Tracer): Tracer.Tracer =>\n Tracer.make({\n ...tracer,\n span(options) {\n const span = tracer.span(options)\n const end = span.end.bind(span)\n const attribute = span.attribute.bind(span)\n span.attribute = (key, value) => {\n if (originAttributes.has(key) || isHeaderAttribute(key)) attribute(key, value)\n }\n span.end = (endTime, exit) => {\n if (exit._tag === \"Success\") return end(endTime, Exit.void)\n if (Cause.hasInterruptsOnly(exit.cause)) return end(endTime, exit)\n const status = span.attributes.get(\"http.response.status_code\")\n return end(\n endTime,\n Exit.fail(\n typeof status === \"number\"\n ? rejected(status)\n : new AlertError({ code: \"sink_unreachable\", message: \"Alert webhook could not be reached\" })\n )\n )\n }\n return span\n }\n })\n\n/**\n * A sink that POSTs each alert to one webhook.\n *\n * The body is the alert plus its {@link alertId}, and the same id is sent as\n * an `Idempotency-Key` header, because the package requires the receiver to\n * dedupe on it and a body without it cannot. The header is set after the\n * caller's headers, so it is the one this sink sends.\n *\n * The endpoint must be an absolute `http:` or `https:` URL, checked when the\n * layer is built, and the request is made with redirect following disabled:\n * the caller's headers can carry a credential, and a followed redirect would\n * re-send them to an origin the deployment never named. A 3xx answer is a\n * refusal like any other non-2xx, not a forwarding instruction. The sink owns\n * its Fetch transport; an externally supplied `HttpClient` is not used.\n * Each delivery has its own scope, which aborts the request and releases any\n * unread response body on completion, failure, timeout, or interruption.\n *\n * The url is treated as a credential, because a webhook's secret usually is\n * its path or query. The delivery's HTTP span is named for the method and\n * records the scheme, host, port and answering status, never the path, the\n * query or userinfo, and it fails with the same url-free reason the sink\n * returns. A host that records HTTP headers on spans gets every name in\n * `headers`, and a redirect's `location`, as `<redacted>`.\n *\n * A non-2xx response is a failure, not a delivery, and an endpoint that never\n * answers is a failure after `timeout`. Paging is exactly the case where a\n * silently dropped request is worse than a retry, and a hung request is worse\n * than either: it is indistinguishable from silence, so it must not be\n * possible to wait on one forever.\n *\n * @param options the endpoint, any headers it needs, and how long to wait\n * @category layers\n * @since 0.1.0\n */\nexport const layerWebhook = (\n options: {\n readonly url: string\n readonly headers?: Readonly<Record<string, string>> | undefined\n readonly timeout?: Duration.Duration | undefined\n }\n): Layer.Layer<Sink> =>\n Layer.effect(\n Sink,\n Effect.gen(function*() {\n const url = yield* Effect.orDie(webhookUrl(options.url))\n const client = HttpClient.withScope(yield* HttpClient.HttpClient)\n const timeout = options.timeout ?? defaultWebhookTimeout\n // `Headers.isRedactedName` compares a string matcher against an\n // already-lowercased header name without lowercasing the matcher.\n const redactedNames = [\n ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),\n ...redirectHeaderNames\n ]\n return {\n deliver: (alert) =>\n client.execute(\n HttpClientRequest.post(url).pipe(\n (request) =>\n options.headers === undefined ? request : HttpClientRequest.setHeaders(request, options.headers),\n HttpClientRequest.setHeader(\"Idempotency-Key\", alertId(alert)),\n HttpClientRequest.bodyJsonUnsafe({ ...alert, alertId: alertId(alert) })\n )\n ).pipe(\n followingNoRedirects,\n Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),\n // A host may name HTTP spans for their url. This one is named for\n // its method, as the client names it by default.\n Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method),\n Effect.updateService(Tracer.Tracer, withoutRequest),\n Effect.flatMap((response) =>\n response.status >= 200 && response.status < 300\n ? Effect.void\n : Effect.fail(rejected(response.status))\n ),\n Effect.timeout(timeout),\n Effect.scoped,\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(\n new AlertError({\n code: \"sink_timeout\",\n message: `Alert webhook did not answer within ${Duration.toMillis(timeout)} ms`\n })\n )),\n // The transport error holds the request, and the request holds the\n // caller's headers. Only its reason tag crosses into the failure.\n Effect.catchTag(\"HttpClientError\", (cause) =>\n Effect.fail(\n new AlertError({\n code: \"sink_unreachable\",\n reason: cause.reason._tag,\n message: \"Alert webhook could not be reached\"\n })\n ))\n )\n }\n })\n ).pipe(Layer.provide(FetchHttpClient.layer))\n"]}
package/docs/api.md CHANGED
@@ -366,13 +366,13 @@ own `detectors` are merged over `defaultDetectors`.
366
366
 
367
367
  ### Sinks
368
368
 
369
- | Export | Signature | What it is |
370
- | ----------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
371
- | `SinkService` | `{ deliver: (alert: Alert) => Effect.Effect<void, AlertError> }` | Where a raised alert is sent. `deliver` MUST be idempotent on `alertId(alert)`, and MUST fail when the page did not go out. |
372
- | `Sink` | `Context.Service` under `/notifications/AlertSink` | The service tag. |
373
- | `layerNoop` | `Layer<Sink>` | Accepts every alert and sends it nowhere. The admission and the delivery record still happen. |
374
- | `defaultWebhookTimeout` | `Duration.Duration` | Ten seconds. |
375
- | `layerWebhook` | `(options: { url: string; headers?: Record<string, string>; timeout?: Duration.Duration }) => Layer<Sink>` | POSTs each alert to one webhook using an owned Fetch client with redirects disabled and a scope per delivery. Injected `HttpClient` layers are not used. The body is the alert plus its `alertId`, and the same id is sent as an `Idempotency-Key` header, set after the caller's headers. A non-2xx answer is a failure; an endpoint that never answers fails after `timeout`. |
369
+ | Export | Signature | What it is |
370
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
371
+ | `SinkService` | `{ deliver: (alert: Alert) => Effect.Effect<void, AlertError> }` | Where a raised alert is sent. `deliver` MUST be idempotent on `alertId(alert)`, and MUST fail when the page did not go out. |
372
+ | `Sink` | `Context.Service` under `/notifications/AlertSink` | The service tag. |
373
+ | `layerNoop` | `Layer<Sink>` | Accepts every alert and sends it nowhere. The admission and the delivery record still happen. |
374
+ | `defaultWebhookTimeout` | `Duration.Duration` | Ten seconds. |
375
+ | `layerWebhook` | `(options: { url: string; headers?: Record<string, string>; timeout?: Duration.Duration }) => Layer<Sink>` | POSTs each alert to one webhook using an owned Fetch client with redirects disabled and a scope per delivery. Injected `HttpClient` layers are not used. The body is the alert plus its `alertId`, and the same id is sent as an `Idempotency-Key` header, set after the caller's headers. A non-2xx answer is a failure; an endpoint that never answers fails after `timeout`. The delivery's HTTP span records the method, scheme, host, port and status, never the url's path, query or userinfo. |
376
376
 
377
377
  | Export | Shape |
378
378
  | ------------- | --------------------------------------------------------------------------------------------------------------- |
@@ -49,6 +49,31 @@ Each delivery has its own scope. Completion, refusal, timeout, and interruption
49
49
  abort the request and release any unread response body. Delivery depends on the
50
50
  response status, so a body that never ends does not delay completion.
51
51
 
52
+ ## What a trace shows
53
+
54
+ A webhook url is a credential: Slack, Discord and PagerDuty put the secret in
55
+ the path, and other receivers take it in the query. The sink keeps it out of
56
+ telemetry. Each delivery opens one HTTP span named `POST` that records:
57
+
58
+ | Attribute | Value |
59
+ | --------------------------- | ---------------------------------------------- |
60
+ | `http.request.method` | `POST` |
61
+ | `url.scheme` | `https` or `http` |
62
+ | `server.address` | The endpoint's host name |
63
+ | `server.port` | The endpoint's port |
64
+ | `http.response.status_code` | The answering status, when there was an answer |
65
+ | `error.type` | The status again, for a `4xx` or `5xx` answer |
66
+
67
+ The span never records the url's path, query or userinfo. It fails with the
68
+ reason the sink returns: `Alert webhook answered 503`, or
69
+ `Alert webhook could not be reached` when no answer came. A delivery that hit
70
+ `timeout` ends its span as interrupted.
71
+
72
+ The span records no header. A host that sets `HttpClient.TracerHeaderFilter` to
73
+ record headers gets every name in `headers` as `<redacted>`, and the `location`
74
+ and `content-location` of a redirect too, because they name where the endpoint
75
+ moved.
76
+
52
77
  ## What the request looks like
53
78
 
54
79
  The body is the alert plus its `alertId`, and the same id is sent as an
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@smthrs/notifications",
3
3
  "type": "module",
4
- "version": "1.0.0-rc.4",
4
+ "version": "1.0.0-rc.6",
5
5
  "license": "MIT",
6
6
  "smthrs": {
7
7
  "group": "agent"
@@ -145,19 +145,19 @@
145
145
  "tag": "next"
146
146
  },
147
147
  "dependencies": {
148
- "@smthrs/canonical": "1.0.0-rc.4",
149
- "@smthrs/crypto": "1.0.0-rc.4",
150
- "@smthrs/journal": "1.0.0-rc.4"
148
+ "@smthrs/canonical": "1.0.0-rc.6",
149
+ "@smthrs/crypto": "1.0.0-rc.6",
150
+ "@smthrs/journal": "1.0.0-rc.6"
151
151
  },
152
152
  "devDependencies": {
153
153
  "@effect/language-service": "0.87.2",
154
- "@effect/vitest": "4.0.0-rc.115",
154
+ "@effect/vitest": "4.0.4",
155
155
  "@eslint/js": "9.39.5",
156
- "@smthrs/database": "1.0.0-rc.4",
156
+ "@smthrs/database": "1.0.0-rc.6",
157
157
  "@types/node": "26.4.1",
158
158
  "@vitest/coverage-v8": "5.0.0",
159
159
  "dprint": "0.57.1",
160
- "effect": "4.0.0-rc.115",
160
+ "effect": "4.0.4",
161
161
  "esbuild": "0.28.2",
162
162
  "eslint": "9.39.5",
163
163
  "eslint-import-resolver-typescript": "4.4.5",
@@ -171,7 +171,7 @@
171
171
  "fast-check": "4.9.0"
172
172
  },
173
173
  "peerDependencies": {
174
- "effect": "4.0.0-rc.115"
174
+ "effect": "^4.0.4"
175
175
  },
176
176
  "scripts": {
177
177
  "lint": "eslint src --max-warnings=0 && dprint check",
@@ -9,10 +9,11 @@
9
9
  * @since 1.0.0
10
10
  */
11
11
 
12
- import { Duration, Effect, Layer, Option } from "effect"
13
- import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient"
14
- import * as HttpClient from "effect/unstable/http/HttpClient"
15
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
12
+ import { Cause, Duration, Effect, Exit, Layer, Option, Tracer } from "effect"
13
+ import * as FetchHttpClient from "effect/http/FetchHttpClient"
14
+ import * as Headers from "effect/http/Headers"
15
+ import * as HttpClient from "effect/http/HttpClient"
16
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
16
17
  import { alertId } from "./AlertPolicy.ts"
17
18
  import { AlertError, Sink } from "./AlertSink.ts"
18
19
 
@@ -74,6 +75,71 @@ const followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.E
74
75
  })
75
76
  )
76
77
 
78
+ const rejected = (status: number): AlertError =>
79
+ new AlertError({ code: "sink_rejected", status, message: `Alert webhook answered ${status}` })
80
+
81
+ /**
82
+ * The span attributes of a delivery that say nothing of the url's path, query
83
+ * or userinfo: the method, the origin and the answer.
84
+ */
85
+ const originAttributes: ReadonlySet<string> = new Set([
86
+ "http.request.method",
87
+ "url.scheme",
88
+ "server.address",
89
+ "server.port",
90
+ "http.response.status_code",
91
+ "error.type"
92
+ ])
93
+
94
+ const isHeaderAttribute = (key: string): boolean =>
95
+ key.startsWith("http.request.header.") || key.startsWith("http.response.header.")
96
+
97
+ /**
98
+ * The headers of a redirect that name where the endpoint moved, which is the
99
+ * same secret under another address.
100
+ */
101
+ const redirectHeaderNames: ReadonlyArray<string> = ["location", "content-location"]
102
+
103
+ /**
104
+ * The tracer a delivery opens its HTTP span on: the caller's own, with nothing
105
+ * of the request left on the span.
106
+ *
107
+ * A webhook url is a credential. Slack, Discord and PagerDuty put the secret
108
+ * in the path, others take it in the query, and a url may carry basic-auth
109
+ * userinfo. The HTTP client writes the whole url on its span and again in the
110
+ * failure it ends the span with, and the answer or the transport error it ends
111
+ * with holds the request. So only the attributes that name the method, the
112
+ * origin and the answer are kept, with the headers a host chose to record, and
113
+ * the span ends with the outcome alone: nothing on success, the refusing
114
+ * status, or that the endpoint could not be reached.
115
+ */
116
+ const withoutRequest = (tracer: Tracer.Tracer): Tracer.Tracer =>
117
+ Tracer.make({
118
+ ...tracer,
119
+ span(options) {
120
+ const span = tracer.span(options)
121
+ const end = span.end.bind(span)
122
+ const attribute = span.attribute.bind(span)
123
+ span.attribute = (key, value) => {
124
+ if (originAttributes.has(key) || isHeaderAttribute(key)) attribute(key, value)
125
+ }
126
+ span.end = (endTime, exit) => {
127
+ if (exit._tag === "Success") return end(endTime, Exit.void)
128
+ if (Cause.hasInterruptsOnly(exit.cause)) return end(endTime, exit)
129
+ const status = span.attributes.get("http.response.status_code")
130
+ return end(
131
+ endTime,
132
+ Exit.fail(
133
+ typeof status === "number"
134
+ ? rejected(status)
135
+ : new AlertError({ code: "sink_unreachable", message: "Alert webhook could not be reached" })
136
+ )
137
+ )
138
+ }
139
+ return span
140
+ }
141
+ })
142
+
77
143
  /**
78
144
  * A sink that POSTs each alert to one webhook.
79
145
  *
@@ -91,6 +157,13 @@ const followingNoRedirects = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.E
91
157
  * Each delivery has its own scope, which aborts the request and releases any
92
158
  * unread response body on completion, failure, timeout, or interruption.
93
159
  *
160
+ * The url is treated as a credential, because a webhook's secret usually is
161
+ * its path or query. The delivery's HTTP span is named for the method and
162
+ * records the scheme, host, port and answering status, never the path, the
163
+ * query or userinfo, and it fails with the same url-free reason the sink
164
+ * returns. A host that records HTTP headers on spans gets every name in
165
+ * `headers`, and a redirect's `location`, as `<redacted>`.
166
+ *
94
167
  * A non-2xx response is a failure, not a delivery, and an endpoint that never
95
168
  * answers is a failure after `timeout`. Paging is exactly the case where a
96
169
  * silently dropped request is worse than a retry, and a hung request is worse
@@ -114,6 +187,12 @@ export const layerWebhook = (
114
187
  const url = yield* Effect.orDie(webhookUrl(options.url))
115
188
  const client = HttpClient.withScope(yield* HttpClient.HttpClient)
116
189
  const timeout = options.timeout ?? defaultWebhookTimeout
190
+ // `Headers.isRedactedName` compares a string matcher against an
191
+ // already-lowercased header name without lowercasing the matcher.
192
+ const redactedNames = [
193
+ ...Object.keys(options.headers ?? {}).map((name) => name.toLowerCase()),
194
+ ...redirectHeaderNames
195
+ ]
117
196
  return {
118
197
  deliver: (alert) =>
119
198
  client.execute(
@@ -125,16 +204,15 @@ export const layerWebhook = (
125
204
  )
126
205
  ).pipe(
127
206
  followingNoRedirects,
207
+ Effect.updateService(Headers.CurrentRedactedNames, (names) => [...names, ...redactedNames]),
208
+ // A host may name HTTP spans for their url. This one is named for
209
+ // its method, as the client names it by default.
210
+ Effect.provideService(HttpClient.SpanNameGenerator, (request) => request.method),
211
+ Effect.updateService(Tracer.Tracer, withoutRequest),
128
212
  Effect.flatMap((response) =>
129
213
  response.status >= 200 && response.status < 300
130
214
  ? Effect.void
131
- : Effect.fail(
132
- new AlertError({
133
- code: "sink_rejected",
134
- status: response.status,
135
- message: `Alert webhook answered ${response.status}`
136
- })
137
- )
215
+ : Effect.fail(rejected(response.status))
138
216
  ),
139
217
  Effect.timeout(timeout),
140
218
  Effect.scoped,