@crewhaus/tool-notify 0.7.0
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 +271 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +2167 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +217 -0
- package/dist/lib/digest.d.ts +56 -0
- package/dist/lib/digest.js +140 -0
- package/dist/lib/dns-records.d.ts +179 -0
- package/dist/lib/dns-records.js +431 -0
- package/dist/lib/mime.d.ts +125 -0
- package/dist/lib/mime.js +618 -0
- package/dist/lib/preflight.d.ts +71 -0
- package/dist/lib/preflight.js +291 -0
- package/dist/lib/quiet.d.ts +59 -0
- package/dist/lib/quiet.js +217 -0
- package/dist/lib/ratelimit.d.ts +57 -0
- package/dist/lib/ratelimit.js +87 -0
- package/dist/lib/sign.d.ts +8 -0
- package/dist/lib/sign.js +39 -0
- package/dist/lib/template.d.ts +41 -0
- package/dist/lib/template.js +85 -0
- package/dist/net.d.ts +391 -0
- package/dist/net.js +1131 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/smtp.d.ts +56 -0
- package/dist/smtp.js +479 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# @crewhaus/tool-notify
|
|
2
|
+
|
|
3
|
+
Messaging and notification tools: post to a chat platform, send email over
|
|
4
|
+
SMTP, deliver a signed webhook, reach a phone — and, first, decide whether to
|
|
5
|
+
send anything at all.
|
|
6
|
+
|
|
7
|
+
`@crewhaus/tool-message-channel`'s `SendMessage` replies on the harness's own
|
|
8
|
+
bound channel: the room it was spoken to in. That is the right primitive for a
|
|
9
|
+
conversation, and it is not the one a scheduled job needs when the thing it has
|
|
10
|
+
to say belongs in `#ops`, or in an email to the person who owns the broken
|
|
11
|
+
service. These tools address a destination an **operator** allow-listed, which
|
|
12
|
+
is a different and more dangerous act.
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
tools:
|
|
16
|
+
- all-notify # every tool below
|
|
17
|
+
- -SmsSend # ...except this one
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Tool | What it does |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `ChatPost` | Post to Slack, Discord, Teams or a generic webhook, with optional blocks and a thread id |
|
|
23
|
+
| `ChatUpdate` | Edit a message already posted, by its id |
|
|
24
|
+
| `ChatDelete` | Remove a message already posted |
|
|
25
|
+
| `ChatReact` | Add an emoji reaction |
|
|
26
|
+
| `EmailSend` | Send over SMTP: EHLO, STARTTLS, AUTH, MAIL FROM, RCPT TO, DATA |
|
|
27
|
+
| `EmailCompose` | Build the RFC 5322 bytes without sending them |
|
|
28
|
+
| `EmailSendPreflight` | Check a message the way `EmailSend` would build it, and send nothing |
|
|
29
|
+
| `DeliverabilityCheck` | Read a sending domain's SPF, DMARC and named DKIM selectors out of public DNS |
|
|
30
|
+
| `WebhookPost` | POST JSON to an allow-listed URL, optionally HMAC-signed, retrying 5xx |
|
|
31
|
+
| `SmsSend` | SMS through a REST gateway the operator described |
|
|
32
|
+
| `PushNotify` | Push notification through a REST provider the operator described |
|
|
33
|
+
| `DeliveryCheck` | Ask a provider what became of a message it accepted |
|
|
34
|
+
| `NotifyDigest` | Fold many events into one message |
|
|
35
|
+
| `QuietHours` | May this go out now, and if not, when? |
|
|
36
|
+
| `RateLimitGate` | Has this key already been notified inside the window? |
|
|
37
|
+
| `MessageTemplate` | Render a named template with per-platform escaping |
|
|
38
|
+
|
|
39
|
+
## Start with the three that stop you sending
|
|
40
|
+
|
|
41
|
+
The tools worth reaching for first are the ones that send nothing.
|
|
42
|
+
|
|
43
|
+
`NotifyDigest` is the reason a watcher does not page a channel forty times
|
|
44
|
+
about the same broken host. `QuietHours` is the reason an overnight alert waits
|
|
45
|
+
until 09:00 when it will read identically. `RateLimitGate` is the reason a loop
|
|
46
|
+
does not re-report the same thing on every iteration. All three are pure, take
|
|
47
|
+
`now` as an argument, and compose:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
NotifyDigest → QuietHours → RateLimitGate → MessageTemplate → ChatPost
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Safety flags
|
|
54
|
+
|
|
55
|
+
Every tool that sends is `destructive: true` **and**
|
|
56
|
+
`requireJustification: true`. Putting text in front of a person is a side
|
|
57
|
+
effect that deleting the message does not undo, because it has already been
|
|
58
|
+
read — that is precisely what the intent gate is for, and nothing here opts
|
|
59
|
+
out of it. `DeliveryCheck` and `DeliverabilityCheck` are the outbound tools that only
|
|
60
|
+
read, so they are `readOnly` and carry no justification. The six pure tools
|
|
61
|
+
are `readOnly`, `scope: "internal"`, declare no io capability, and are
|
|
62
|
+
concurrency-safe. `src/index.test.ts` asserts all of this per tool, so a
|
|
63
|
+
future addition that forgets cannot land quietly.
|
|
64
|
+
|
|
65
|
+
The two `…Check` names sit uncomfortably close, so: `DeliveryCheck` asks a
|
|
66
|
+
PROVIDER what became of one message it accepted. `DeliverabilityCheck` asks
|
|
67
|
+
public DNS what a DOMAIN publishes about itself, and knows nothing about any
|
|
68
|
+
particular message.
|
|
69
|
+
|
|
70
|
+
## The outbound posture
|
|
71
|
+
|
|
72
|
+
`src/net.ts` carries `@crewhaus/tool-http`'s gate over whole rather than
|
|
73
|
+
re-deriving it — a second outbound surface with a weaker gate is the same hole
|
|
74
|
+
twice. In summary:
|
|
75
|
+
|
|
76
|
+
- **Empty allow-list denies everything.** Separately for origins, for SMTP
|
|
77
|
+
hosts, and for email recipients. There is no "allow all" value.
|
|
78
|
+
- **SSRF refusal is numeric, not textual.** Loopback, link-local (including
|
|
79
|
+
the cloud metadata address), RFC1918, CGNAT, multicast and mDNS are refused
|
|
80
|
+
as an IP literal in any of its encodings — octal, hex, integer, every IPv6
|
|
81
|
+
spelling — and as the DNS-resolved address. An IPv6-shaped string the parser
|
|
82
|
+
cannot classify is refused, never waved through.
|
|
83
|
+
- **The vetted IP is pinned** for the connection, so a rebinding resolver
|
|
84
|
+
cannot swap in a private address between the check and the socket. The SMTP
|
|
85
|
+
client pins the same way, and still presents and verifies the hostname.
|
|
86
|
+
- **A send never follows a redirect.** Replaying a body at a new origin, or
|
|
87
|
+
downgrading to a GET that delivers nothing, are both worse than stopping.
|
|
88
|
+
Only `DeliveryCheck` follows, re-running the whole gate on every hop.
|
|
89
|
+
- **Everything is deadline-bounded and byte-capped,** with the cap bounding
|
|
90
|
+
what is ever held in memory rather than applied after buffering.
|
|
91
|
+
|
|
92
|
+
## Credentials
|
|
93
|
+
|
|
94
|
+
No tool accepts a secret. Every credential is the **NAME** of an environment
|
|
95
|
+
variable, read at call time:
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
tool_config:
|
|
99
|
+
notify:
|
|
100
|
+
allowed_origins: ["https://hooks.slack.com", "https://slack.com"]
|
|
101
|
+
allowed_recipients: ["ops@example.com", "*@team.example.com"]
|
|
102
|
+
allowed_smtp_hosts: ["smtp.example.com"]
|
|
103
|
+
allowed_sender_domains: ["example.com"]
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```jsonc
|
|
107
|
+
{ "platform": "slack", "webhookUrlEnv": "SLACK_OPS_WEBHOOK", "text": "…" }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A Slack incoming-webhook URL (`/services/T…/B…/…`) and a Discord one
|
|
111
|
+
(`/api/webhooks/<id>/<token>`) carry their whole authority in the **path**, so
|
|
112
|
+
the URL is itself a credential: it is named rather than passed, and no error
|
|
113
|
+
message ever prints a webhook path. `WebhookPost` refuses one handed to `url`
|
|
114
|
+
inline — where it would be recorded verbatim — and points at `urlEnv`. A value that arrives where a variable name
|
|
115
|
+
was expected and looks like a secret is refused **without being echoed back**,
|
|
116
|
+
because a tool result is a transcript, a trace and usually an eval report. Every
|
|
117
|
+
result — success or failure — passes through a redactor built from the resolved
|
|
118
|
+
secret, in its literal, URL-encoded and base64 forms.
|
|
119
|
+
|
|
120
|
+
## Injection is the thing this package is most careful about
|
|
121
|
+
|
|
122
|
+
A notification is almost always assembled from data the harness did not write:
|
|
123
|
+
a branch name, an error string, a customer's subject line.
|
|
124
|
+
|
|
125
|
+
- **Chat.** Every value is escaped for its platform before it is sent, so
|
|
126
|
+
`<!channel>`, `@everyone` or a stray code fence can change what a message
|
|
127
|
+
*says* and never what it *does*. Discord is additionally sent
|
|
128
|
+
`allowed_mentions: { parse: [] }`, which is the only reliable way to stop a
|
|
129
|
+
mention in a body from paging a server. A link block whose URL is not
|
|
130
|
+
http(s) is dropped and reported, never rendered.
|
|
131
|
+
- **Templates.** `MessageTemplate` escapes the **values** and leaves the
|
|
132
|
+
**template** alone: the operator owns the structure, the run supplies the
|
|
133
|
+
content, and there is no path by which content becomes structure. A missing
|
|
134
|
+
key is an error rather than an empty string, because a notification reading
|
|
135
|
+
`deploy of failed at ` still gets acted on.
|
|
136
|
+
- **Email.** Any header value that is not plain printable ASCII becomes an RFC
|
|
137
|
+
2047 encoded word — which turns a CR or LF into `=0D`/`=0A` inside the word
|
|
138
|
+
— and an address containing either is refused outright. A subject cannot add
|
|
139
|
+
a `Bcc:`. The values that are emitted verbatim and so cannot be encoded — a
|
|
140
|
+
message id in `In-Reply-To` or `References`, an attachment's `Content-ID` —
|
|
141
|
+
are checked against their grammar instead, and the finished header block is
|
|
142
|
+
re-checked for a line break that is not a fold. Blind recipients reach the
|
|
143
|
+
envelope and never a header.
|
|
144
|
+
- **SMTP.** SMTP is a line protocol, so a CR or LF inside any command argument
|
|
145
|
+
is not a malformed command but a second one. The EHLO name and the envelope
|
|
146
|
+
addresses are checked before the socket opens, which is what stops a
|
|
147
|
+
smuggled `RCPT TO:` from reaching a recipient the allow-list never saw.
|
|
148
|
+
|
|
149
|
+
## Email, specifically
|
|
150
|
+
|
|
151
|
+
`EmailCompose` builds the message; `EmailSend` sends it. Splitting them means
|
|
152
|
+
the assembly is testable without a mail server and the bytes can be handed to
|
|
153
|
+
another transport.
|
|
154
|
+
|
|
155
|
+
The composer does RFC 2047 encoded words, header folding that never breaks an
|
|
156
|
+
encoded word or an address in half, quoted-printable bodies with correct
|
|
157
|
+
trailing-whitespace and soft-break handling, base64 attachments, and
|
|
158
|
+
`multipart/alternative` inside `multipart/mixed`. MIME boundaries are derived
|
|
159
|
+
from a hash of the message's own content rather than from a random number, so
|
|
160
|
+
composing the same message twice gives byte-identical output.
|
|
161
|
+
|
|
162
|
+
The SMTP client is written out over `node:net` / `node:tls`. **STARTTLS is
|
|
163
|
+
required by default**: a server that does not offer it ends the session before
|
|
164
|
+
the credential, the envelope or the body is sent. The transcript records
|
|
165
|
+
`AUTH PLAIN <redacted>` and the base64 blob is never kept. Attachment size is
|
|
166
|
+
checked from the file's own metadata *before* it is read — and counted as the
|
|
167
|
+
base64 it will become, not as the bytes on disk — so an oversized attachment
|
|
168
|
+
never reaches memory.
|
|
169
|
+
|
|
170
|
+
## Before the send
|
|
171
|
+
|
|
172
|
+
`EmailSendPreflight` takes the same arguments as `EmailSend` and runs the
|
|
173
|
+
same composer, and sends nothing. It exists because `EmailSend` answers one
|
|
174
|
+
refusal at a time, at the moment somebody has already decided to send, and
|
|
175
|
+
because some of what is wrong with a message is not a refusal at all: an
|
|
176
|
+
empty body composes perfectly and delivers nothing, a `{{placeholder}}` that
|
|
177
|
+
never got filled goes out as written, the same mailbox on To and Bcc is one
|
|
178
|
+
copy rather than two. The result is a row per check, and the verdict has
|
|
179
|
+
three values rather than two — a check that could not run comes back
|
|
180
|
+
`unknown`, and a message with an attachment nobody could read is
|
|
181
|
+
`incomplete`, which is neither ready nor blocked.
|
|
182
|
+
|
|
183
|
+
`DeliverabilityCheck` reads what a sending domain publishes: its SPF record,
|
|
184
|
+
its DMARC policy, and the DKIM key record at each selector the caller names.
|
|
185
|
+
It reports facts rather than a score, because the facts decide different
|
|
186
|
+
things — no DMARC record at all and a DMARC record with `p=none` score the
|
|
187
|
+
same and are not the same situation, and a lookup that failed is a third
|
|
188
|
+
answer again, reported as `unknown` with its reason. There is no way to
|
|
189
|
+
enumerate DKIM selectors, so it checks the ones you name and guesses none.
|
|
190
|
+
|
|
191
|
+
DNS is not an HTTP request and so does not pass through `allowed_origins`,
|
|
192
|
+
but the name queried is still chosen by the caller and still leaves the
|
|
193
|
+
machine. `allowed_sender_domains` is that surface's allow-list, fail-closed
|
|
194
|
+
like the others: empty denies everything. The `_dmarc.` and `._domainkey.`
|
|
195
|
+
names are derived from the canonicalised domain after it passes, so there is
|
|
196
|
+
no spelling that clears the gate and a different one that reaches the
|
|
197
|
+
resolver.
|
|
198
|
+
|
|
199
|
+
## What is deliberately not here
|
|
200
|
+
|
|
201
|
+
- **`SendMessage`.** That is `@crewhaus/tool-message-channel`'s, for the
|
|
202
|
+
harness's own bound channel. This package does not duplicate it.
|
|
203
|
+
- **A mail queue, retries or bounce handling.** `EmailSend` sends once and
|
|
204
|
+
reports what the server said. A refused recipient comes back named; deciding
|
|
205
|
+
whether to try again is the caller's.
|
|
206
|
+
- **CRAM-MD5, XOAUTH2, DSN, pipelining, connection reuse.** PLAIN and LOGIN
|
|
207
|
+
over TLS cover submission to every service that matters; the rest would be
|
|
208
|
+
surface without users.
|
|
209
|
+
- **DKIM signature verification.** `DeliverabilityCheck` reads the KEY
|
|
210
|
+
record a selector publishes — its type, its size, whether it has been
|
|
211
|
+
revoked. It does not verify that a message's signature validates: that
|
|
212
|
+
needs relaxed/simple canonicalisation, header selection and body hashing,
|
|
213
|
+
which is `mailauth`'s job and a dependency this package does not take. A
|
|
214
|
+
half-built verifier that can answer "pass" is a security control that
|
|
215
|
+
controls nothing, so there is no partial one here.
|
|
216
|
+
- **Walking SPF includes.** `dnsTermsInThisRecord` counts the DNS-querying
|
|
217
|
+
terms in the record it read, and says so. The RFC's limit of ten counts
|
|
218
|
+
the whole evaluation, with its own sub-limits for `a` and `mx` and a
|
|
219
|
+
separate void-lookup cap; a walker that got those wrong would report a
|
|
220
|
+
confident total that is not the receiver's. It would also mean querying
|
|
221
|
+
domains the caller never named.
|
|
222
|
+
- **Reading messages.** Nothing here receives; it only sends.
|
|
223
|
+
- **Vendor SDKs.** `SmsSend`, `PushNotify` and `DeliveryCheck` speak a provider
|
|
224
|
+
shape the operator writes in `tool_config`. Changing gateway is a config
|
|
225
|
+
diff, not a release.
|
|
226
|
+
|
|
227
|
+
## Known gaps
|
|
228
|
+
|
|
229
|
+
Stated rather than papered over:
|
|
230
|
+
|
|
231
|
+
- **A successful STARTTLS upgrade is not covered by a test.** The refusals
|
|
232
|
+
are: a server offering no STARTTLS, a handshake that cannot complete, and an
|
|
233
|
+
untrusted certificate all end the session with the password unsent and the
|
|
234
|
+
message undelivered. But building a STARTTLS *server* needs an
|
|
235
|
+
already-connected plaintext socket handed to a TLS server, and this
|
|
236
|
+
runtime's `node:tls` shim does not support that — such a test would hang
|
|
237
|
+
rather than assert. See `src/smtp.test.ts`.
|
|
238
|
+
- **Implicit TLS (port 465) is untested** for the same reason.
|
|
239
|
+
- **The idempotency ledger is per-process.** A retry after a restart will send
|
|
240
|
+
again. The ledger is the second line of defence; the key is also passed to
|
|
241
|
+
the provider, which is the first.
|
|
242
|
+
- **`ChatUpdate` does not return the previous text,** because the platform
|
|
243
|
+
does not give it back.
|
|
244
|
+
- **`EmailSendPreflight` does not fold provider-specific aliases.** Two
|
|
245
|
+
recipients count as duplicates on their exact spelling, case aside. Gmail
|
|
246
|
+
treats `a.b@` and `ab@` as one mailbox and strips `+tags`; most providers
|
|
247
|
+
do neither, so folding them here would report two separate people as one
|
|
248
|
+
on every domain that keeps them apart. Which domains fold is configuration
|
|
249
|
+
this package does not carry. Two spellings of one mailbox that differ only
|
|
250
|
+
in case are reported as a **warning that they will each get their own
|
|
251
|
+
`RCPT TO`** — `composeMessage` builds its envelope from the exact
|
|
252
|
+
addresses, so that is what the send does, and the preflight reports the
|
|
253
|
+
composer's count rather than its own comparison key's.
|
|
254
|
+
- **`DeliverabilityCheck` does not walk up to the organizational domain.**
|
|
255
|
+
RFC 7489 6.6.3 has a receiver that finds no `_dmarc` record fall back to
|
|
256
|
+
the organizational domain's, so a subdomain with nothing of its own can
|
|
257
|
+
still be covered. Naming that domain needs the Public Suffix List —
|
|
258
|
+
`example.co.uk` is an organizational domain and `mail.example.com` is not,
|
|
259
|
+
and nothing in the name says which — which is a dependency this package
|
|
260
|
+
does not take. So a domain with more than two labels and no record of its
|
|
261
|
+
own gets a note saying which lookup was not made, and the answer stops at
|
|
262
|
+
the name that was asked.
|
|
263
|
+
|
|
264
|
+
## Layout
|
|
265
|
+
|
|
266
|
+
`src/lib/` holds the pure functions — escaping and block rendering, templates,
|
|
267
|
+
digests, quiet hours, rate limiting, MIME, the preflight rules and the SPF,
|
|
268
|
+
DMARC and DKIM record parsers — and is where the behaviour is tested. `src/net.ts` is the outbound gate, `src/paths.ts` the path gate,
|
|
269
|
+
`src/smtp.ts` the SMTP client, and `src/index.ts` wraps all of it as tools. A
|
|
270
|
+
bug in quoted-printable encoding reads better as a failing unit than as a
|
|
271
|
+
failing tool call.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
2
|
+
export { NotifyPermissionError, _resetIdempotencyLedger, _resetNotifyConfig, _setDnsLookup, _setDnsTxtResolver, _setRawFetch, __setPrivateHostsAllowedForTest, buildNotifyConfig, canonicalizeOrigin, getNotifyConfig, registerNotifyConfig, } from "./net";
|
|
3
|
+
export { ToolPermissionError } from "./paths";
|
|
4
|
+
export declare const chatPost: RegisteredTool;
|
|
5
|
+
export declare const chatUpdate: RegisteredTool;
|
|
6
|
+
export declare const chatDelete: RegisteredTool;
|
|
7
|
+
export declare const chatReact: RegisteredTool;
|
|
8
|
+
export declare const emailCompose: RegisteredTool;
|
|
9
|
+
export declare const emailSend: RegisteredTool;
|
|
10
|
+
export declare const emailSendPreflight: RegisteredTool;
|
|
11
|
+
export declare const webhookPost: RegisteredTool;
|
|
12
|
+
export declare const smsSend: RegisteredTool;
|
|
13
|
+
export declare const pushNotify: RegisteredTool;
|
|
14
|
+
export declare const deliveryCheck: RegisteredTool;
|
|
15
|
+
export declare const deliverabilityCheck: RegisteredTool;
|
|
16
|
+
export declare const notifyDigest: RegisteredTool;
|
|
17
|
+
export declare const quietHours: RegisteredTool;
|
|
18
|
+
export declare const rateLimitGate: RegisteredTool;
|
|
19
|
+
export declare const messageTemplate: RegisteredTool;
|
|
20
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
21
|
+
export declare const NOTIFY_TOOLS: ReadonlyArray<RegisteredTool>;
|