@usehenri/webhooks 0.0.0 → 1.2.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/CHANGELOG.md ADDED
@@ -0,0 +1,115 @@
1
+ # @usehenri/webhooks
2
+
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#397](https://github.com/usehenri/henri/pull/397) [`1c0dfe8`](https://github.com/usehenri/henri/commit/1c0dfe84a98eff2122512256c4f42ec7ccde4212) Thanks [@reel](https://github.com/reel)! - Call logs, inbound and outbound: `henri.calls`.
8
+
9
+ Two records joined by the request id henri already threads through everything — the call an application answered, and every call it made because of it — so that "what happened during request `X`" is one question with one answer:
10
+
11
+ ```bash
12
+ henri calls 018f5c2e-1f2a-7c31-9f0a-2b7c1d3e4f56
13
+ ```
14
+
15
+ ```
16
+ 2026-09-06T14:22:31.004Z <- 201 84ms POST /orders
17
+ 2026-09-06T14:22:31.019Z -> 200 41ms POST https://api.billing.test/v1/charges
18
+ service billing
19
+ 2026-09-06T14:22:31.062Z -> 200 12ms POST https://hooks.example.test/orders
20
+ service webhooks
21
+ ```
22
+
23
+ Both directions live in one table henri owns (`henri_calls`, a `direction` column), reached through the store adapter's `query()` or a MongoDB collection the way the access trail reaches its own. One table rather than two because the join is the whole point: one `SELECT` on one index instead of two reads and a merge.
24
+
25
+ **It is the deliberate opposite of the access trail, and the guide says so in its first paragraph.** The trail records field _names_, counts and digests and refuses a value; it is hash-chained evidence kept for a year. A call log holds **values** — the body that came in, the body that went out — because one that does not is a slower copy of the web server's access log. It is a debugging instrument: sampled, capped, kept for thirty days, and never evidence of anything. Neither substitutes for the other.
26
+
27
+ Four bounds keep it from being a denial of service, and each of them is a decision rather than a default:
28
+
29
+ - **Off unless configured.** No `config.calls`, no table, no middleware, no allocation. When it is on, the middleware is mounted right after the request id and before everything else, so a request refused by the rate limit, the body parser or the CSRF check — exactly the one worth having — is in the log.
30
+ - **The write never blocks the answer.** A finished call goes onto a bounded buffer and the response goes out; a timer writes with one multi-row `INSERT`. A flush that fails is reported once and dropped rather than retried, because a call log that can fail a request turns a database hiccup into an outage. What the buffer drops is counted, and `henri calls:stats` says so.
31
+ - **The payload is capped before it is stored** (`calls.maxBody`, 8kb, with a truncation marker), and only a body henri can _walk_ is stored at all — a plain object or an array is redacted key by key, a string, a buffer or an HTML page is a size and a shape. That is why the response body is taken from `res.json(value)` rather than off the socket.
32
+ - **What one client can cause is bounded twice.** `calls.sample` bounds the steady state proportionally; `calls.maxPerSecond` is an absolute per-process ceiling, because one percent of a million requests a second is still ten thousand rows a second. The sampling decision is a hash of the request id **seeded with `config.secret`**: a hash so the inbound call and every outbound call it caused agree in every process without carrying state, and seeded because the request id comes from a header a client chooses. `calls.always` keeps the failures sampling dropped, without their bodies.
33
+
34
+ It holds values, so the redaction is the feature. Everything stored goes through the redactor of `config.filterParameters` and the `personal` marks, at every depth, and on top of that `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-csrf-token`, `x-api-key` and `webhook-signature` are masked whatever the configuration says, a url loses its userinfo, and the person is their `externalId` and never an address.
35
+
36
+ `calls.keep` (30 days) is pruned by the retention sweep, and **where the dialect has range partitions it drops periods instead of rows**: `calls.partition: "day"` on PostgreSQL and MySQL makes the sweep a metadata operation whatever the table held, which is the difference between a sweep that works at ten million rows and one that times out. There is always a catch-all partition, so no row is ever refused for want of one. sqlite, SQL Server and MongoDB have no ranges and get a bounded delete loop; asking them to partition fails the boot rather than being ignored.
37
+
38
+ henri wraps nobody's HTTP client: `henri.calls.track()` and `henri.calls.outbound()` are the seam, two lines around whatever an application already uses. The calls henri makes itself are populated without anything to write — every mail send, and every webhook delivery attempt, whose request id is stamped into the delivery job at `emit()` time so a delivery three retries later still joins the request that caused it.
39
+
40
+ `henri calls [<request-id>]`, `henri calls:stats` and `henri calls:sweep --yes` are the commands, `config.calls` is the configuration, and the guide is [Call logs](https://usehenri.io/guides/calls/).
41
+
42
+ - [#422](https://github.com/usehenri/henri/pull/422) [`762062a`](https://github.com/usehenri/henri/commit/762062aadc450d49b1a2d15524f9d579ab4f60e7) Thanks [@reel](https://github.com/reel)! - Multi-tenancy: one column, one ambient tenant, and a refusal when nobody said which.
43
+
44
+ henri had one tenant-shaped thing — every webhook endpoint carries an `owner`, and an `emit` without one reaches the endpoints that have none — and nothing else. An application serving two customers wrote the `where` itself, on every query, forever.
45
+
46
+ There are three ways to be multi-tenant and they are not variations of one thing: a column on every row, a schema per tenant, or a process per tenant. **henri does the first**, and the reason is the model layer rather than a preference — a per-tenant schema is a `search_path` on PostgreSQL, a database name on MySQL, a connection on MongoDB and a file on sqlite, all of which are _connection_ decisions, and a henri store opens one pool at boot. What that buys and this does not is a boundary the database itself enforces; the guide says so, and says who should reach for it instead.
47
+
48
+ Two declarations, and they are separate. A model says its rows belong to one customer:
49
+
50
+ ```js
51
+ // app/models/Invoice.js
52
+ module.exports = { options: { tenant: true }, schema: { ... } };
53
+ ```
54
+
55
+ and the application says where the tenant of a request comes from:
56
+
57
+ ```json
58
+ { "tenancy": { "from": { "subdomain": "example.com", "user": "accountId" } } }
59
+ ```
60
+
61
+ From then on every query henri builds for an `Invoice` carries the condition — `find`, `findOne`, `count`, `paginate`, `exists`, `pluck`, an eager loaded association, a mass update, a mass delete, a soft delete, a restore, and `instance.save()`, which never builds a query at all — and every insert is stamped. `findById` answers `null` for another tenant's identifier, which is the 404 it already answers for one that does not exist.
62
+
63
+ **The default is the refusal, and that is the feature.** A tenanted model touched with _no_ tenant in scope raises `HENRI_TENANT_REQUIRED` rather than falling back to every tenant's rows — the instinct `HENRI_POLICY_SCOPE_REQUIRED` already has, one layer down. A write naming another tenant is `HENRI_TENANT_CROSS_WRITE` rather than a row that quietly appears in somebody else's list. What Mongoose and Sequelize cannot narrow at all — an aggregation pipeline, a `bulkWrite`, an `increment` — is `HENRI_TENANT_UNSCOPABLE` rather than an unscoped answer. There is one way past, and it is an async context and not a setting: `henri.tenancy.unscoped(fn)`, the shape of `henri.encryption.tolerate()`.
64
+
65
+ **The tenant of a request is decided in one place and is visible.** `req.tenant` is the value and `req.tenantSource` says how it was reached — `req.localeSource`'s precedent — over a fixed order: `explicit` (`req.setTenant()`), then the signed-in user's own column, then the subdomain, then a header from a proxy the application listed. Everything a client can name sits _below_ the user's own record, and when the two disagree the request is refused (`HENRI_TENANT_MISMATCH`, 404 by default) rather than served from either: for a signed-in person a client-named tenant is a confirmation, never an election. `POST /login` asks the same question again once passport has authenticated, so signing in on the wrong subdomain opens no session at all. A tenant header with no `from` naming its proxies **fails the boot**, the rule `config.calls.address` already follows, and a `from` covering everything is a high `henri audit` finding.
66
+
67
+ A tenant is a **scope and not a permission**: narrowing only ever removes rows, and what a person may do with the ones that are left stays `app/policies`' question.
68
+
69
+ Around the edges: the idempotency keys are now scoped by tenant (the key is the client's, and one load balancer can present one address for two customers); `henri.webhooks.emit()` defaults its `owner` to the tenant in scope; `henri audit` gained `tenancy.header-from-any` and `tenancy.unmarked-model`; and the user model **cannot** be marked `tenant` — a sign-in reads it before any request has a tenant, so scoping it would answer "no such account" to everybody, and henri fails the boot saying so.
70
+
71
+ The guide is [Multi-tenancy](https://usehenri.io/guides/multi-tenancy/), which also holds the table of what each henri-owned table does about tenants — including the two that are honestly shared for now: the queue row carries no tenant (it rides in the job's arguments, and forgetting it is a loud refusal rather than a leak) and neither does a version row.
72
+
73
+ - [#400](https://github.com/usehenri/henri/pull/400) [`2689779`](https://github.com/usehenri/henri/commit/26897798b840fd28a4bc091c050a83457b36905d) Thanks [@reel](https://github.com/reel)! - OpenTelemetry, out of the box: `henri.telemetry`.
74
+
75
+ henri answers three observability questions already — `pen` says what happened, `henri.reporter` says what failed, `henri.analyze()` says what the boot did — and each of them from one process. None of them says where the time went inside one request, and none of them follows that request into the queue, the mail transport or a webhook receiver. This is the fourth: a trace, through OpenTelemetry, which is the only vendor-neutral way to hand one to whatever the deployment already runs.
76
+
77
+ **henri ships the instrumentation and none of the pipeline.** `@opentelemetry/api` is an **optional peer dependency**; there is no SDK, no exporter, no sampler, no resource and no collector address, because those belong to the deployment that knows the service name, the environment and how much traffic it can afford to keep. The guide shows the smallest bootstrap that works, and it is the application's file.
78
+
79
+ **An application that does not install the api pays nothing**, and nothing here is not a flag tested per request. Nothing is installed at all: the middleware is not mounted, the store adapter is not wrapped, no instrument is created, `@opentelemetry/api` is never required, and there is no boot line, because there is nothing to say. Measured on an empty JSON route at 30 000 requests: the baseline with the package absent, +1.3 to 2.2 µs per request with it present and no SDK registered, +2.4 to 4.6 µs while exporting.
80
+
81
+ **What a span carries is the error reporter's rule, word for word**, because a span attribute is a log field with a different name on it. A request span is named for its route _pattern_ (`GET /artworks/:id`) and carries the method, the route, the status and `henri.request_id` — the four of them, from `requestOf()`, which is literally the reporter's own function, so the two cannot drift. Nothing that came from the client is in a span: no url, no path, no query, no body, no parameters, no headers, no user, no SQL text, no mail recipient, no job arguments and no webhook body. That is a deliberate departure from the HTTP semantic conventions, which ask a server span for `url.path`, and the reason is the reporter's: `/users/ada@example.com` is a path in some applications and a personal field in all of them. Attributes an application passes to `henri.telemetry.span()` go through the masking of a log line.
82
+
83
+ The children are the boundaries henri already knows, without reaching into anybody's driver: `adapter.query()` (the raw SQL henri runs on its own behalf — the queue's claim, the trail's insert), the view render, the mail send, a job run in `@usehenri/jobs` and a delivery attempt in `@usehenri/webhooks`. A model call an application makes is deliberately not one: that is `@opentelemetry/instrumentation-pg` and its siblings, registered in the same bootstrap, and their spans land under henri's request span because the context is already active. The **boot** is a span too, and it is reconstructed from `henri.analyze()` after the fact, with the timings it had already measured — so nothing is timed twice and nothing runs during a boot for the sake of a trace. A boot that failed is emitted with the module that failed carrying the error.
84
+
85
+ **Propagation, and which identifier wins.** An incoming `traceparent` is honoured, so a request that arrives inside somebody else's trace continues it; `henri.telemetry.inject()` writes one onto the requests henri makes for the application, which today is a webhook delivery, inside the span of the attempt the receiver is answering. `traceparent` decides the trace and `X-Request-Id` decides the request id, and **neither is derived from the other**: inventing a trace id from a request id would orphan the parent that is waiting, and a trace usually spans several requests, so a trace id is not unique per request. The span carries `henri.request_id`, which is the join between a log line and a trace. `traceparent` is not written onto the response, because it would hand an internal identifier to whoever asked and `X-Request-Id` already answers that need.
86
+
87
+ **The metrics are the four that mean something**: `http.server.request.duration` by method, route and status — whose count _is_ the request count, which is why there is no counter beside it — `henri.jobs.queue.depth`, `henri.jobs.claim.duration` and `henri.cache.operations`. The last is read straight off the counters `henri.cache.stats()` already kept, and it and the queue depth are observable instruments, so nothing is recorded while a request runs.
88
+
89
+ **When the exporter is down or slow, a span is dropped** — the cache's rule, where a dead backend is a miss. henri owns none of the pipeline: nothing is ever awaited, `forceFlush()` is never called, there is no queue and no map of open spans here, and every span ends in a `finally` or when the response closes. A sampled-out span costs an object. A tracer that throws is logged and, after five failures, telemetry turns itself off for the life of the process and says so once.
90
+
91
+ `config.telemetry` is `{ enabled, metrics, propagate, spans }`, or `false`. Leave it out and telemetry follows the package: on when `@opentelemetry/api` resolves from the application, off when it does not. `enabled: true` says the application requires it, and a boot without the package then fails with `HENRI_TELEMETRY_UNAVAILABLE` rather than going quiet.
92
+
93
+ `henri doctor` reports `deps.declared` when `enabled` is `true` and the package is in no `package.json`, so that boot failure is one a check catches first.
94
+
95
+ - [#383](https://github.com/usehenri/henri/pull/383) [`72cd1d3`](https://github.com/usehenri/henri/commit/72cd1d35ffb99311bbca815c1f6ab41ee3682f64) Thanks [@reel](https://github.com/reel)! - Outbound webhooks, signed and retried, in a new package: `@usehenri/webhooks`.
96
+
97
+ Every application that integrates with anything eventually pushes events to a url a customer typed into a settings form. Everyone writes it by hand, and everyone gets the same two parts wrong: the signature, which is what lets the receiver believe the request, and the url, which is a server-side request forgery with a registration form in front of it. The queue landing in 1.1 made the rest of it nearly free, so here it is.
98
+
99
+ The surface is one call: `await henri.webhooks.emit('invoice.paid', payload)` writes one row per subscribed endpoint and returns. `register()`, `rotate()`, `disable()` and `remove()` manage the endpoints from the application, and `henri webhooks:add|list|show|update|rotate|disable|enable|remove|send|status|install` from the command line. Endpoints carry an `owner`, which is the tenant they belong to: an `emit()` with an owner reaches that owner's endpoints, one without reaches the endpoints that have none, and henri will not fan an event across tenants for you.
100
+
101
+ **Signing** follows [Standard Webhooks](https://www.standardwebhooks.com) to the byte — `webhook-id`, `webhook-timestamp` and `webhook-signature`, HMAC-SHA256 over `id.timestamp.body`, base64, behind a `v1,` scheme prefix — so a receiver with any Standard Webhooks library verifies henri with nothing written. GitHub and Shopify sign only the body, which replays forever and cannot be deduplicated; Stripe adds the timestamp and is what this is closest to. henri differs from Stripe in three ways on purpose: the timestamp is its own header, the signature is base64, and **the delivery id is part of the signed content** — the same `webhook-id` on every attempt of one delivery, so "have I already processed this?" is answered from authenticated bytes rather than from a body nobody has verified yet. Nothing a receiver could route on is sent outside the signature: the event type is in the signed body and in no header. The guide carries the verification snippet a receiver pastes, and `signature.spec.js` runs that exact transcription against what the package signs, so the page cannot drift from the code. Secrets rotate with a grace during which both sign, and are stored encrypted (AES-256-GCM under a key derived from `secret`).
102
+
103
+ **Delivery is a job** and nothing else, so the retries, the exponential backoff, the dead letter queue and the operator's view of all of it are the queue's: `henri jobs:list --queue webhooks`, `henri jobs:dead`, `henri jobs:show <id>`, `henri jobs:retry <id>`. There is no deliveries table. Eight attempts, ten seconds tripling to six hours — about three days. A `2xx` is a delivery; a `5xx`, a timeout, a connection error and any other `4xx` are retried; a `3xx` is a failure that is **not** retried and is never followed, because a redirect is the receiver choosing a url after henri checked the one it was given; a `410 Gone` is not retried either and disables the endpoint.
104
+
105
+ **A url is checked when the request is made**, not when it was registered, because DNS answers differently later. Every address the name resolves to is refused if it is loopback, link-local (where `169.254.169.254` lives), private, carrier-grade NAT, multicast, reserved, documentation, or an IPv4 address wearing an IPv6 costume — and one bad answer refuses the name. The socket then connects to the address that was checked and to no other, so nothing can resolve the name a second time in between. `webhooks.allowPrivate` and `webhooks.allowHttp` lift it for a development configuration, and `henri audit` reports either of them in production (`webhooks.private-addresses-allowed`, a new A10:2021 category in the report, and `webhooks.http-allowed`).
106
+
107
+ Two seams were added to `@usehenri/jobs` for it, and both are useful on their own. `henri.jobs.define(name, definition)` lets a package ship a job of its own without asking every application to write a file that forwards the call — a file of `app/jobs` with the same name still wins. And an error carrying `retryable: false` is buried on the spot with its reason instead of spending every attempt to learn the same thing: a `410 Gone`, an address that must not be reached, a payload a remote API will refuse identically in six hours.
108
+
109
+ `henri new` does not install any of this. The guide is [Webhooks](https://usehenri.io/guides/webhooks/), and it says what is deliberately left out: receiving webhooks, a UI, a subscription policy richer than `invoice.*`, `Retry-After`, ordering guarantees, and anything that needs a message broker.
110
+
111
+ ### Patch Changes
112
+
113
+ - Updated dependencies [[`792a15a`](https://github.com/usehenri/henri/commit/792a15ade614cf8b920d9197586f9866700d458e), [`1e23664`](https://github.com/usehenri/henri/commit/1e23664829bd1a356de28f404cfb21c9ae211388), [`5b627ad`](https://github.com/usehenri/henri/commit/5b627adfa37e9f16bc75af96cc8ff5308a91f688), [`4dff51e`](https://github.com/usehenri/henri/commit/4dff51edc050e29398c793a5aedb48776b7b7119), [`60dbf33`](https://github.com/usehenri/henri/commit/60dbf33c2a4f14e328a0df1cb44be061e15431e5), [`1b316c6`](https://github.com/usehenri/henri/commit/1b316c6f3c5d5eb5752c70b534092d1052956cc6), [`d074e8b`](https://github.com/usehenri/henri/commit/d074e8b482582e25f80d8a14b4735e69a2b7821e), [`7fd13f6`](https://github.com/usehenri/henri/commit/7fd13f631b75f7aa152b73046b50c6902ae3ca93), [`b559fb7`](https://github.com/usehenri/henri/commit/b559fb72b391eeb21a3f6a0cda1515e01ecbfafc), [`1c0dfe8`](https://github.com/usehenri/henri/commit/1c0dfe84a98eff2122512256c4f42ec7ccde4212), [`93060a8`](https://github.com/usehenri/henri/commit/93060a86df795dbbd99bf1895beb0cf14c4b86de), [`e031900`](https://github.com/usehenri/henri/commit/e031900082f28aec72af4cda9cd959f932e2ebc7), [`9173000`](https://github.com/usehenri/henri/commit/91730005efa88f073bfaaf67078c3ec0e137b459), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`b7f33e2`](https://github.com/usehenri/henri/commit/b7f33e28a5e4844391befd75d08e42c4cf6212ed), [`3d6f3fc`](https://github.com/usehenri/henri/commit/3d6f3fc048d05db41be86069608d342e437408cb), [`b278119`](https://github.com/usehenri/henri/commit/b2781190de436eb5838e866446c9a0c8210bb6ca), [`b161e1b`](https://github.com/usehenri/henri/commit/b161e1b8fad94af2d2afc351dc1bc07dabbb1379), [`7cb0b04`](https://github.com/usehenri/henri/commit/7cb0b04b29b61dedaa82fcd1972646fb3765acfc), [`1616e34`](https://github.com/usehenri/henri/commit/1616e343a612be2bffffcfa5b23bfa8ad191bbe3), [`c8f5367`](https://github.com/usehenri/henri/commit/c8f53678b33341d086b467f801e959314afc7860), [`bcf4ce2`](https://github.com/usehenri/henri/commit/bcf4ce22bcd294844504164fac1fa4aef1ffec41), [`ab5a8e4`](https://github.com/usehenri/henri/commit/ab5a8e4a88c80cca070a2b8ad398c80babdaff11), [`43d267f`](https://github.com/usehenri/henri/commit/43d267f0f9d192b2c01e89c3925b7daf5000041b), [`9f868f3`](https://github.com/usehenri/henri/commit/9f868f3d9162fa218e34304110210e6949f97d5c), [`a4d53bf`](https://github.com/usehenri/henri/commit/a4d53bf155d0d6f62800864258fa49615a794a9d), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`89dda62`](https://github.com/usehenri/henri/commit/89dda62da456a0a55600e79cfb65ce89f11258e2), [`62fac46`](https://github.com/usehenri/henri/commit/62fac46fd6cae5581979b73daf99700fd246e0ea), [`a93d6cc`](https://github.com/usehenri/henri/commit/a93d6cc39b33b261089e91f3e757b54fefc9fe15), [`d9f3be4`](https://github.com/usehenri/henri/commit/d9f3be49c5929d929a220220bf6e72fdcb135595), [`c44f025`](https://github.com/usehenri/henri/commit/c44f025acec3d5bbbb57e2310d02184a1053a10d), [`67cfb20`](https://github.com/usehenri/henri/commit/67cfb200ea0e0b31bacf2af183db6467b0fa011d), [`e661f98`](https://github.com/usehenri/henri/commit/e661f98fe8f8acce15aa10ce2dc320c5a2cb006f), [`43a0e1a`](https://github.com/usehenri/henri/commit/43a0e1a6a320baa43298391e1c1e0334d6cd28d5), [`cee57b9`](https://github.com/usehenri/henri/commit/cee57b9d3521a4a70c715222eae1f18ff4a6c128), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`2c8a826`](https://github.com/usehenri/henri/commit/2c8a8265262dbf6ea5c3e73e8e7892a230d4d0f0), [`46d5dbc`](https://github.com/usehenri/henri/commit/46d5dbcc983c03e96ae5a87d7288c5d8a5adbc24), [`ba97ea9`](https://github.com/usehenri/henri/commit/ba97ea968f0b34cd67b7a3e803ecd34543b8aaaf), [`5ccd537`](https://github.com/usehenri/henri/commit/5ccd537b3621b54d11e7f24ccca39643ae7d5cf7), [`d88bf7f`](https://github.com/usehenri/henri/commit/d88bf7fe038a6b58e7bed02ff4c90755f6c0e65e), [`9895cbf`](https://github.com/usehenri/henri/commit/9895cbf4be85b476e341a5be915e3049e5a027de), [`61bf75c`](https://github.com/usehenri/henri/commit/61bf75cbaccda1aecff34408a175b2d85447d7a8), [`5a150d5`](https://github.com/usehenri/henri/commit/5a150d576208571c32b9cd12827d035e31ed4313), [`3c1c5b8`](https://github.com/usehenri/henri/commit/3c1c5b83ea135b000ddd6ffbfd05457b000f2f7c), [`2625067`](https://github.com/usehenri/henri/commit/26250673d91ab70ad024739d02b647754f75267d), [`aa3f90b`](https://github.com/usehenri/henri/commit/aa3f90bd6f42bb05431c33f3e4bf2202cb6bb7c6), [`a1c6769`](https://github.com/usehenri/henri/commit/a1c6769099e3dc28b22b5338a2a57b13bdf69f7a), [`0a8bb41`](https://github.com/usehenri/henri/commit/0a8bb415d352cd75b12d07e591c8ec7c16774a99), [`ec1c8c4`](https://github.com/usehenri/henri/commit/ec1c8c419f4d9063a7617472b2970fdb8a929fa1), [`dd2731d`](https://github.com/usehenri/henri/commit/dd2731d6a20fd96aa1be1aeb5e6ec0155001326b), [`ab52e18`](https://github.com/usehenri/henri/commit/ab52e187c420dfe381f03ed51c5c141fda525acb), [`b7b56e1`](https://github.com/usehenri/henri/commit/b7b56e190ae774abc0096fe2aebaf91f823115af), [`b7038ce`](https://github.com/usehenri/henri/commit/b7038ceaa430f4a0b9eaf7e983fc2844421bf636), [`1ea0f85`](https://github.com/usehenri/henri/commit/1ea0f85066b86fba31f58937cc10abb6359e6a26), [`762062a`](https://github.com/usehenri/henri/commit/762062aadc450d49b1a2d15524f9d579ab4f60e7), [`01a561a`](https://github.com/usehenri/henri/commit/01a561aa58650ec15df1c2659795a5e4c5bbfd53), [`bd1b630`](https://github.com/usehenri/henri/commit/bd1b63083b3817b8c47b8a187de76027458a1b32), [`27b5513`](https://github.com/usehenri/henri/commit/27b5513d1cae8aba734fe27da0ace2a82423e2f4), [`e31a3f7`](https://github.com/usehenri/henri/commit/e31a3f73e7e8facf3cedf7460f115e57995f32c3), [`2689779`](https://github.com/usehenri/henri/commit/26897798b840fd28a4bc091c050a83457b36905d), [`72cd1d3`](https://github.com/usehenri/henri/commit/72cd1d35ffb99311bbca815c1f6ab41ee3682f64), [`a2e1ec2`](https://github.com/usehenri/henri/commit/a2e1ec29df52462f12ebaae9bfbc1ad4f427b27f), [`afead74`](https://github.com/usehenri/henri/commit/afead7489498ed42e1893a25123ea772cac2ca09), [`a4ecba5`](https://github.com/usehenri/henri/commit/a4ecba50c663f4d5c741adbb6cd9bc0eefe0e5cc), [`baec3fd`](https://github.com/usehenri/henri/commit/baec3fd22be92bf8ffbaeb251b0b6c2771f8347a), [`1103628`](https://github.com/usehenri/henri/commit/110362808f8ec6d73a75ff7fc89a77f3e943d773), [`e865d94`](https://github.com/usehenri/henri/commit/e865d945d65419ac676f5a2fe3ba3b6114a1e53d), [`4274567`](https://github.com/usehenri/henri/commit/4274567e20a980657f07df9ec7db25296c7d55f5), [`aea429c`](https://github.com/usehenri/henri/commit/aea429ca99338a62370ab3e3d94bdc6b8c227601), [`8a8e3b3`](https://github.com/usehenri/henri/commit/8a8e3b33d7967b81f66633aa25c3075318f01d60), [`18715f9`](https://github.com/usehenri/henri/commit/18715f90ea8958dc57da1bb029b8209c36b84cc6), [`0d2ebc3`](https://github.com/usehenri/henri/commit/0d2ebc344bfcd80533ef900638083e4c105407bb), [`49398a6`](https://github.com/usehenri/henri/commit/49398a6308f0760f01c6ff2ec98aaa35f484474d), [`ec64e44`](https://github.com/usehenri/henri/commit/ec64e44e7b79a02da5fc587a72a9a6c900836982), [`831aa5c`](https://github.com/usehenri/henri/commit/831aa5c011f3432630c68b8d26755d2582f82f74), [`16824e8`](https://github.com/usehenri/henri/commit/16824e8fe9ccc6a04dab5d9b2481c29ff4f6b64b), [`fda9366`](https://github.com/usehenri/henri/commit/fda9366e9ed2b072764a995c5aa60205ca7a4725), [`1a86acb`](https://github.com/usehenri/henri/commit/1a86acbf15e4a43e5fb81277bb22e101c06e77a4), [`808d824`](https://github.com/usehenri/henri/commit/808d82471d59e64ccc735f617bab293eb572c46b), [`0b32fbd`](https://github.com/usehenri/henri/commit/0b32fbde19c95da8fe07fab76933840a4242c71c), [`c0c16e8`](https://github.com/usehenri/henri/commit/c0c16e873ba440aee9832160553cbb12ab81bd2c), [`41470bf`](https://github.com/usehenri/henri/commit/41470bf378d83ca3d35d00e8c31796fea5eb15e0), [`8e44e7e`](https://github.com/usehenri/henri/commit/8e44e7e882dd8741b3ac632651b453389d76bf2c), [`de1c1e0`](https://github.com/usehenri/henri/commit/de1c1e02ed83d13dcfeb8e44012f309eb663f03e), [`4b4677d`](https://github.com/usehenri/henri/commit/4b4677d4a09d39fe50b1fa4af577600342578daf)]:
114
+ - @usehenri/core@1.2.0
115
+ - @usehenri/jobs@1.2.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016-present, Félix-Antoine Paradis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,10 @@
1
+ <!-- generated by scripts/prepublish.js -->
2
+
1
3
  # @usehenri/webhooks
2
4
 
3
- Placeholder release. Install a real version: see https://usehenri.io.
5
+ Henri outbound webhooks: endpoints an application registers, signed deliveries and the retries of the queue.
6
+
7
+ Part of [henri](https://usehenri.io), the Rails-like React framework for
8
+ Node.js: [documentation](https://usehenri.io),
9
+ [source and issues](https://github.com/usehenri/henri),
10
+ [changelog](https://github.com/usehenri/henri/blob/master/packages/webhooks/CHANGELOG.md).
package/index.js ADDED
@@ -0,0 +1,48 @@
1
+ const address = require('./src/address');
2
+ const config = require('./src/config');
3
+ const errors = require('./src/errors');
4
+ const job = require('./src/job');
5
+ const secrets = require('./src/secrets');
6
+ const signature = require('./src/signature');
7
+ const store = require('./src/store');
8
+
9
+ const WebhooksModule = require('./src/module');
10
+
11
+ const { Webhooks, DELIVERY_JOB, subscribed } = require('./src/webhooks');
12
+ const { deliver } = require('./src/deliver');
13
+
14
+ /**
15
+ * Outbound webhooks for henri.
16
+ *
17
+ * This package ships the henri module itself (`"henri": { "module":
18
+ * "./module.js" }` in its package.json), so an application that depends on
19
+ * it has `henri.webhooks` in its boot. Building one by hand is for a test
20
+ * or a script that has a henri instance but no module.
21
+ *
22
+ * The one thing worth importing from here in an application is
23
+ * `verify`: it is the exact counterpart of what henri signs with, so an
24
+ * application that also *receives* its own webhooks (a test, a second
25
+ * service of the same codebase) checks them with the code that wrote them.
26
+ *
27
+ * @param {object} henri A henri instance
28
+ * @param {object} [options={}] `config` (the `webhooks` block), `adapter`
29
+ * @returns {Webhooks} The endpoints
30
+ */
31
+ const create = (henri, options = {}) => new Webhooks(henri, options);
32
+
33
+ module.exports = create;
34
+ module.exports.DELIVERY_JOB = DELIVERY_JOB;
35
+ module.exports.Webhooks = Webhooks;
36
+ module.exports.WebhooksModule = WebhooksModule;
37
+ module.exports.address = address;
38
+ module.exports.config = config;
39
+ module.exports.create = create;
40
+ module.exports.deliver = deliver;
41
+ module.exports.errors = errors;
42
+ module.exports.job = job;
43
+ module.exports.secrets = secrets;
44
+ module.exports.sign = signature.sign;
45
+ module.exports.signature = signature;
46
+ module.exports.store = store;
47
+ module.exports.subscribed = subscribed;
48
+ module.exports.verify = signature.verify;
package/module.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The henri module this package ships.
3
+ *
4
+ * `package.json` points at this file with `"henri": { "module": "./module.js" }`,
5
+ * which is all core reads: an application depending on `@usehenri/webhooks`
6
+ * has the module in its boot, as `henri.webhooks`.
7
+ */
8
+ module.exports = require('./src/module');
package/package.json CHANGED
@@ -1,15 +1,56 @@
1
1
  {
2
- "description": "Placeholder creating @usehenri/webhooks on npm; the first real version is published by the henri release workflow",
3
- "homepage": "https://usehenri.io",
4
- "license": "MIT",
5
2
  "name": "@usehenri/webhooks",
6
- "publishConfig": {
7
- "access": "public"
8
- },
3
+ "version": "1.2.0",
4
+ "description": "henri outbound webhooks: endpoints an application registers, signed deliveries and the retries of the queue",
5
+ "license": "MIT",
6
+ "author": "Felix-Antoine Paradis",
7
+ "homepage": "https://usehenri.io",
9
8
  "repository": {
10
- "directory": "packages/webhooks",
11
9
  "type": "git",
12
- "url": "git+https://github.com/usehenri/henri.git"
10
+ "url": "git+https://github.com/usehenri/henri.git",
11
+ "directory": "packages/webhooks"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/usehenri/henri/issues"
15
+ },
16
+ "keywords": [
17
+ "henri",
18
+ "webhooks",
19
+ "hmac",
20
+ "signature",
21
+ "ssrf",
22
+ "delivery"
23
+ ],
24
+ "main": "index.js",
25
+ "henri": {
26
+ "module": "./module.js"
27
+ },
28
+ "files": [
29
+ "index.js",
30
+ "module.js",
31
+ "src",
32
+ "CHANGELOG.md"
33
+ ],
34
+ "publishConfig": {
35
+ "access": "public",
36
+ "provenance": true
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "dependencies": {
42
+ "debug": "^4.4.3"
43
+ },
44
+ "peerDependencies": {
45
+ "@usehenri/core": "^1.2.0",
46
+ "@usehenri/jobs": "^1.2.0"
13
47
  },
14
- "version": "0.0.0"
15
- }
48
+ "devDependencies": {
49
+ "@usehenri/core": "^1.2.0",
50
+ "@usehenri/jobs": "^1.2.0",
51
+ "@usehenri/mongoose": "^1.2.0",
52
+ "@usehenri/sequelize": "^1.2.0",
53
+ "mongodb-memory-server": "^11.2.0",
54
+ "sqlite3": "^6.0.1"
55
+ }
56
+ }
package/src/address.js ADDED
@@ -0,0 +1,280 @@
1
+ const dns = require('dns');
2
+ const net = require('net');
3
+
4
+ const { WebhookAddressError } = require('./errors');
5
+
6
+ /**
7
+ * What a delivery is allowed to open.
8
+ *
9
+ * A webhook url is a string an application was handed by someone else, and
10
+ * the process that opens it sits inside the network the receiver would like
11
+ * to reach. That is server-side request forgery with a registration form in
12
+ * front of it: `http://169.254.169.254/latest/meta-data/` is the cloud
13
+ * instance's credentials, `http://localhost:6379/` is the Redis that holds
14
+ * the sessions, `http://10.0.0.5:8080/` is whatever else runs in the
15
+ * cluster.
16
+ *
17
+ * Three rules, and the third is the one that is usually missing:
18
+ *
19
+ * 1. **the scheme**: `https` only, unless the application says otherwise.
20
+ * `http` leaks the payload and the signature to anyone on the path;
21
+ * `file:`, `gopher:`, `ftp:` and the rest are refused outright, and so
22
+ * is a url carrying credentials (`https://user:pass@host/`), which is
23
+ * how a redirect-following client is talked into authenticating.
24
+ * 2. **the address**: every address the name resolves to is checked against
25
+ * the ranges below. One bad answer is enough to refuse: a name with an
26
+ * A record on a public address and an AAAA record on `::1` is a real
27
+ * attack, not a mistake.
28
+ * 3. **at request time, and pinned**. Checking at registration proves
29
+ * nothing, because DNS answers differently later -- a name that resolved
30
+ * publicly when it was registered resolves to `169.254.169.254` when the
31
+ * delivery goes out. Checking at request time and then letting the HTTP
32
+ * client resolve the name again re-opens the same hole through the back
33
+ * door, half a millisecond wide (DNS rebinding). So the address that was
34
+ * checked is the address the socket connects to: `deliver()` hands the
35
+ * agent a `lookup` that answers this one and never asks a resolver.
36
+ *
37
+ * What this does not do: it does not follow a redirect (see `deliver.js`),
38
+ * and it does not pretend an allow list is unnecessary. An application that
39
+ * knows the hosts it sends to should say so at its egress; this is the
40
+ * floor, not the ceiling.
41
+ */
42
+
43
+ /**
44
+ * The ranges a delivery is refused, and what each one is
45
+ *
46
+ * IPv4 first, then IPv6. `2002::/16` (6to4) and `2001::/32` (Teredo) carry
47
+ * an IPv4 address inside them and are refused whole rather than unwrapped,
48
+ * and `64:ff9b::/96` (NAT64) with them.
49
+ */
50
+ const RANGES = [
51
+ ['0.0.0.0/8', 'this network', 4],
52
+ ['10.0.0.0/8', 'a private network', 4],
53
+ ['100.64.0.0/10', 'a carrier-grade NAT network', 4],
54
+ ['127.0.0.0/8', 'the loopback', 4],
55
+ ['169.254.0.0/16', 'the link-local range, where the metadata service is', 4],
56
+ ['172.16.0.0/12', 'a private network', 4],
57
+ ['192.0.0.0/24', 'the IETF protocol assignments range', 4],
58
+ ['192.0.2.0/24', 'a documentation range', 4],
59
+ ['192.168.0.0/16', 'a private network', 4],
60
+ ['198.18.0.0/15', 'a benchmarking range', 4],
61
+ ['198.51.100.0/24', 'a documentation range', 4],
62
+ ['203.0.113.0/24', 'a documentation range', 4],
63
+ ['224.0.0.0/4', 'a multicast range', 4],
64
+ ['240.0.0.0/4', 'a reserved range', 4],
65
+ ['::/128', 'the unspecified address', 6],
66
+ ['::1/128', 'the loopback', 6],
67
+ ['64:ff9b::/96', 'a NAT64 range, which carries an IPv4 address', 6],
68
+ ['100::/64', 'the discard range', 6],
69
+ ['2001::/32', 'the Teredo range, which carries an IPv4 address', 6],
70
+ ['2001:db8::/32', 'a documentation range', 6],
71
+ ['2002::/16', 'the 6to4 range, which carries an IPv4 address', 6],
72
+ ['fc00::/7', 'a unique local range', 6],
73
+ ['fe80::/10', 'the link-local range', 6],
74
+ ['ff00::/8', 'a multicast range', 6],
75
+ ];
76
+
77
+ /**
78
+ * One `net.BlockList` per range, so the refusal can name the range
79
+ *
80
+ * @returns {Array<object>} `{ list, cidr, what, family }` entries
81
+ */
82
+ const lists = RANGES.map(([cidr, what, family]) => {
83
+ const [address, prefix] = cidr.split('/');
84
+ const list = new net.BlockList();
85
+
86
+ list.addSubnet(address, Number(prefix), family === 4 ? 'ipv4' : 'ipv6');
87
+
88
+ return { cidr, family, list, what };
89
+ });
90
+
91
+ /** An IPv4 address written as an IPv6 one: `::ffff:127.0.0.1` */
92
+ const MAPPED = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/iu;
93
+
94
+ /** The same, written in hexadecimal: `::ffff:7f00:1` */
95
+ const MAPPED_HEX = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/iu;
96
+
97
+ /** The schemes a delivery may open */
98
+ const SCHEMES = ['https:', 'http:'];
99
+
100
+ /**
101
+ * An address as it will be checked: an IPv4-mapped IPv6 address is an IPv4
102
+ * address, however it was written, and checking the wrapper is how
103
+ * `::ffff:127.0.0.1` reaches the loopback
104
+ *
105
+ * @param {string} address An IP address
106
+ * @returns {string} The address, unwrapped
107
+ */
108
+ const unwrap = (address) => {
109
+ const dotted = MAPPED.exec(address);
110
+
111
+ if (dotted) {
112
+ return dotted[1];
113
+ }
114
+
115
+ const hex = MAPPED_HEX.exec(address);
116
+
117
+ if (!hex) {
118
+ return address;
119
+ }
120
+
121
+ const high = parseInt(hex[1], 16);
122
+ const low = parseInt(hex[2], 16);
123
+
124
+ return [high >> 8, high & 255, low >> 8, low & 255].join('.');
125
+ };
126
+
127
+ /**
128
+ * Why an address must not be reached, or nothing
129
+ *
130
+ * @param {string} value An IP address
131
+ * @returns {?string} What the range is, or null when it is fine
132
+ */
133
+ const refusal = (value) => {
134
+ const address = unwrap(String(value));
135
+ const family = net.isIP(address);
136
+
137
+ if (family === 0) {
138
+ return 'not an IP address';
139
+ }
140
+
141
+ const type = family === 4 ? 'ipv4' : 'ipv6';
142
+
143
+ for (const range of lists) {
144
+ if (range.family === family && range.list.check(address, type)) {
145
+ return `${range.what} (${range.cidr})`;
146
+ }
147
+ }
148
+
149
+ return null;
150
+ };
151
+
152
+ /**
153
+ * Reads a webhook url, refusing what a delivery must never open
154
+ *
155
+ * @param {string} value The url
156
+ * @param {object} [options={}] `allowHttp`
157
+ * @returns {URL} The url
158
+ * @throws {WebhookAddressError} When the url may not be opened
159
+ */
160
+ const parse = (value, options = {}) => {
161
+ let url;
162
+
163
+ try {
164
+ url = new URL(String(value));
165
+ } catch (error) {
166
+ throw new WebhookAddressError(`"${value}" is not a url`, {
167
+ cause: error,
168
+ url: String(value),
169
+ });
170
+ }
171
+
172
+ if (!SCHEMES.includes(url.protocol)) {
173
+ throw new WebhookAddressError(
174
+ `the ${url.protocol.replace(':', '')} scheme is not delivered to: a webhook url is http or https`,
175
+ { url: url.href }
176
+ );
177
+ }
178
+
179
+ if (url.protocol === 'http:' && !options.allowHttp) {
180
+ throw new WebhookAddressError(
181
+ 'this url is plaintext http, which sends the payload and its signature in the clear',
182
+ {
183
+ hint: 'Register an https url, or set webhooks.allowHttp in a development configuration',
184
+ url: url.href,
185
+ }
186
+ );
187
+ }
188
+
189
+ if (url.username || url.password) {
190
+ throw new WebhookAddressError(
191
+ 'this url carries credentials, which a delivery never sends',
192
+ { url: `${url.protocol}//${url.host}${url.pathname}` }
193
+ );
194
+ }
195
+
196
+ if (!url.hostname) {
197
+ throw new WebhookAddressError('this url names no host', { url: url.href });
198
+ }
199
+
200
+ return url;
201
+ };
202
+
203
+ /**
204
+ * Every address a name answers with
205
+ *
206
+ * @param {string} hostname The host
207
+ * @param {object} [options={}] `lookup`, a `dns.promises.lookup` stand-in
208
+ * @returns {Promise<Array<object>>} `{ address, family }` entries
209
+ * @throws {WebhookAddressError} When the name does not resolve
210
+ */
211
+ const resolve = async (hostname, options = {}) => {
212
+ const lookup = options.lookup || dns.promises.lookup;
213
+
214
+ try {
215
+ const found = await lookup(hostname, { all: true, verbatim: true });
216
+
217
+ return Array.isArray(found) ? found : [found];
218
+ } catch (error) {
219
+ // A name that does not resolve may resolve later (a receiver that has
220
+ // just been deployed, a resolver that blinked), so this one is retried
221
+ throw new WebhookAddressError(`${hostname} does not resolve`, {
222
+ cause: error,
223
+ hostname,
224
+ retryable: true,
225
+ });
226
+ }
227
+ };
228
+
229
+ /**
230
+ * The address a delivery to this url is allowed to connect to
231
+ *
232
+ * Every answer is checked, and one refusal refuses the url: a name with a
233
+ * public A record and a loopback AAAA record is an attack.
234
+ *
235
+ * @param {string} value The url
236
+ * @param {object} [options={}] `allowHttp`, `allowPrivate`, `lookup`
237
+ * @returns {Promise<object>} `{ url, address, family, addresses }`
238
+ * @throws {WebhookAddressError} When the url or an address is refused
239
+ */
240
+ const check = async (value, options = {}) => {
241
+ const url = parse(value, options);
242
+ const hostname = url.hostname.replace(/^\[|\]$/gu, '');
243
+ const addresses = await resolve(hostname, options);
244
+
245
+ if (addresses.length === 0) {
246
+ throw new WebhookAddressError(`${hostname} does not resolve`, {
247
+ hostname,
248
+ retryable: true,
249
+ });
250
+ }
251
+
252
+ if (!options.allowPrivate) {
253
+ for (const answer of addresses) {
254
+ const why = refusal(answer.address);
255
+
256
+ if (why) {
257
+ throw new WebhookAddressError(
258
+ `${hostname} resolves to ${answer.address}, which is ${why}`,
259
+ {
260
+ address: answer.address,
261
+ hint: 'A delivery only reaches a public address. webhooks.allowPrivate lifts this, for a development configuration',
262
+ hostname,
263
+ url: url.href,
264
+ }
265
+ );
266
+ }
267
+ }
268
+ }
269
+
270
+ const [first] = addresses;
271
+
272
+ return {
273
+ address: first.address,
274
+ addresses: addresses.map((answer) => answer.address),
275
+ family: first.family || net.isIP(first.address),
276
+ url,
277
+ };
278
+ };
279
+
280
+ module.exports = { RANGES, SCHEMES, check, parse, refusal, resolve, unwrap };