@remit/doctor 0.0.1

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/src/webhook.ts ADDED
@@ -0,0 +1,155 @@
1
+ import { attempt } from "./attempt.js";
2
+ import type { Fetcher } from "./scrape.js";
3
+ import type { CheckResult, Verdict } from "./verdict.js";
4
+
5
+ /**
6
+ * D12. One URL, one template, one content type — not an integration registry.
7
+ *
8
+ * The default template is Slack-shaped JSON, which Mattermost and Discord also
9
+ * accept. A plain-text content type covers ntfy and anything else that takes a
10
+ * raw body. A named integration per provider — a Slack block builder, a Discord
11
+ * embed, a Teams card — makes every provider our maintenance; a template covers
12
+ * providers we have never heard of, and the operator owns it.
13
+ *
14
+ * Substitutions are `{{verdict}}`, `{{summary}}` and `{{reasons}}`. Anything
15
+ * else in braces is left alone, so a template can carry the literal braces some
16
+ * targets use.
17
+ */
18
+ export const PLACEHOLDERS = ["verdict", "summary", "reasons"] as const;
19
+
20
+ export type Placeholder = (typeof PLACEHOLDERS)[number];
21
+
22
+ const DEFAULT_JSON_TEMPLATE = '{"text":"{{summary}}\\n{{reasons}}"}';
23
+ const DEFAULT_TEXT_TEMPLATE = "{{summary}}\n{{reasons}}";
24
+
25
+ export const isJsonContentType = (contentType: string): boolean =>
26
+ contentType.toLowerCase().includes("json");
27
+
28
+ /**
29
+ * JSON string escaping for a value that lands between quotes the template
30
+ * supplies. `JSON.stringify` handles the quote, the backslash, the newline and
31
+ * every control character in one pass, and stripping its own quotes is what
32
+ * makes it composable into a template rather than a whole document.
33
+ *
34
+ * A malformed payload fails silently under D8 — the transition is spent and the
35
+ * next thing the operator hears is the recovery — so this is the one place in
36
+ * the alert path where getting it wrong costs an outage nobody is told about.
37
+ */
38
+ export const escapeFor =
39
+ (contentType: string): ((value: string) => string) =>
40
+ (value) =>
41
+ isJsonContentType(contentType) ? JSON.stringify(value).slice(1, -1) : value;
42
+
43
+ /**
44
+ * `\n` and `\t` in a plain-text template become real characters. A `.env` file
45
+ * has no escape sequences and compose passes the value through verbatim, so
46
+ * without this a multi-line plain-text template cannot be configured at all. A
47
+ * JSON template is left exactly as written — JSON has its own escapes and
48
+ * rewriting them here would corrupt the document.
49
+ */
50
+ export const expandTemplate = (
51
+ template: string,
52
+ contentType: string,
53
+ ): string =>
54
+ isJsonContentType(contentType)
55
+ ? template
56
+ : template.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
57
+
58
+ export const defaultTemplate = (contentType: string): string =>
59
+ isJsonContentType(contentType)
60
+ ? DEFAULT_JSON_TEMPLATE
61
+ : DEFAULT_TEXT_TEMPLATE;
62
+
63
+ export const render = (
64
+ template: string,
65
+ values: Readonly<Record<Placeholder, string>>,
66
+ escapeValue: (value: string) => string,
67
+ ): string =>
68
+ template.replace(
69
+ /\{\{(verdict|summary|reasons)\}\}/g,
70
+ (_match, name: Placeholder) => escapeValue(values[name]),
71
+ );
72
+
73
+ /**
74
+ * The payload's whole vocabulary: a verdict, a headline, and the reason
75
+ * summaries. D10 — no address, no subject, no sender, no message id, no folder
76
+ * name, and no account id. `Reason.detail`, which holds the account ids, is
77
+ * structurally out of reach here: this function never receives it.
78
+ */
79
+ export const payloadValues = (
80
+ result: CheckResult,
81
+ ): Record<Placeholder, string> => ({
82
+ verdict: result.verdict,
83
+ summary: result.summary,
84
+ reasons:
85
+ result.reasons.length === 0
86
+ ? "no problems found"
87
+ : result.reasons.map((reason) => `• ${reason.summary}`).join("\n"),
88
+ });
89
+
90
+ export interface WebhookRequest {
91
+ readonly url: string;
92
+ readonly template: string | undefined;
93
+ readonly contentType: string;
94
+ readonly timeoutMs: number;
95
+ }
96
+
97
+ export const buildBody = (
98
+ result: CheckResult,
99
+ template: string | undefined,
100
+ contentType: string,
101
+ ): string =>
102
+ render(
103
+ expandTemplate(template ?? defaultTemplate(contentType), contentType),
104
+ payloadValues(result),
105
+ escapeFor(contentType),
106
+ );
107
+
108
+ /**
109
+ * What happened to one delivery attempt, split by whether attempting again
110
+ * could plausibly work.
111
+ *
112
+ * `rejected` is a decision the endpoint made about this payload — a template
113
+ * the operator wrote wrong, a revoked URL. Repeating it produces the same
114
+ * answer forever, so the transition is spent and the operator gets one error
115
+ * line naming the status.
116
+ *
117
+ * `unreachable` is not a decision. A timeout, a refused connection, a 5xx or a
118
+ * 429 says nothing about the payload, and a transition dropped for one of them
119
+ * is an outage nobody is ever told about: the dead-man's switch cannot catch it,
120
+ * because it is a different URL at a different provider and it keeps answering
121
+ * 200 while the webhook is down.
122
+ */
123
+ export type Delivery =
124
+ | { readonly kind: "sent" }
125
+ | { readonly kind: "rejected"; readonly detail: string }
126
+ | { readonly kind: "unreachable"; readonly detail: string };
127
+
128
+ // 429 is the endpoint asking for later, not refusing the content.
129
+ const isRetryable = (status: number): boolean =>
130
+ status >= 500 || status === 429;
131
+
132
+ export const postWebhook = async (
133
+ request: WebhookRequest,
134
+ result: CheckResult,
135
+ fetcher: Fetcher = fetch,
136
+ ): Promise<Delivery> => {
137
+ const attempted = await attempt(
138
+ fetcher(request.url, {
139
+ method: "POST",
140
+ headers: { "content-type": request.contentType },
141
+ body: buildBody(result, request.template, request.contentType),
142
+ signal: AbortSignal.timeout(request.timeoutMs),
143
+ }),
144
+ );
145
+ if (!attempted.ok) {
146
+ return { kind: "unreachable", detail: attempted.error };
147
+ }
148
+ const { status } = attempted.value;
149
+ if (attempted.value.ok) return { kind: "sent" };
150
+ return isRetryable(status)
151
+ ? { kind: "unreachable", detail: `HTTP ${status}` }
152
+ : { kind: "rejected", detail: `HTTP ${status}` };
153
+ };
154
+
155
+ export type { Verdict };
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }