@mulmobridge/webhook-runtime 1.0.3 → 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/README.md CHANGED
@@ -13,6 +13,22 @@ Messenger, Google Chat).
13
13
  - `verifyHmacSignature(body, signature, secret, algorithm?, encoding?)` —
14
14
  length-guarded, timing-safe HMAC comparison.
15
15
 
16
+ For the Meta platforms (Messenger, WhatsApp), which share one webhook contract:
17
+
18
+ - `registerMetaWebhook(app, { verifyToken, appSecret, label, ackBody?, onBody })` —
19
+ the whole `/webhook` surface in one call:
20
+ - **GET** — the handshake that echoes `hub.challenge` only after the token matches.
21
+ - **POST** — `x-hub-signature-256` check → `401` on failure, otherwise ack `200`
22
+ **before** awaiting `onBody(rawBody)` so a slow handler can't trigger a Meta
23
+ redelivery.
24
+
25
+ Both routes share one rate-limit bucket, built inside the registrar (a flood of
26
+ bogus `hub.challenge` GETs hammers the bridge just as effectively as POSTs).
27
+ - `registerMetaWebhookVerification(app, { rateLimit, verifyToken, label })` — the
28
+ GET half on its own, for a caller that owns the limiter.
29
+ - `verifyMetaHmacSignature(body, signature, appSecret)` — the hex/SHA-256 HMAC
30
+ check with Meta's `sha256=` prefix stripped.
31
+
16
32
  These are security-relevant and hardened through Codex reviews (#1326);
17
33
  keeping one copy means a fix lands once, not once per bridge.
18
34
 
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import type { Server } from "node:http";
2
3
  import { type Express } from "express";
3
4
  import { type RateLimitRequestHandler } from "express-rate-limit";
4
5
  export declare function configureTrustProxy(app: Express, env?: string | undefined): void;
@@ -26,3 +27,27 @@ export interface MetaWebhookVerificationOptions {
26
27
  }
27
28
  export declare function registerMetaWebhookVerification(app: Express, opts: MetaWebhookVerificationOptions): void;
28
29
  export declare function verifyMetaHmacSignature(rawBody: string, signature: string, appSecret: string): boolean;
30
+ export interface MetaWebhookOptions {
31
+ verifyToken: string;
32
+ appSecret: string;
33
+ /** Log prefix, e.g. "messenger" / "whatsapp". */
34
+ label: string;
35
+ /** Body of the 200 ack. Meta ignores it, but each bridge shipped its own
36
+ * string, so it stays configurable rather than silently changing. */
37
+ ackBody?: string;
38
+ /** Runs after the ack, on a signature-verified body. Must not throw — a
39
+ * rejection here lands in an already-answered request. */
40
+ onBody: (rawBody: string) => Promise<void>;
41
+ }
42
+ export declare function registerMetaWebhook(app: Express, opts: MetaWebhookOptions): void;
43
+ export interface ListenWebhookOptions {
44
+ /** The env var that overrides the port. Every message names it — each bridge
45
+ * has a different one, and "which knob do I turn" is the question an
46
+ * operator has at exactly the moment this fails. */
47
+ envVar: string;
48
+ /** Port used when the env var is unset or blank. */
49
+ fallback: number;
50
+ /** Test seam. Production prints the message and exits non-zero. */
51
+ onFatal?: (message: string) => void;
52
+ }
53
+ export declare function listenWebhook(app: Express, opts: ListenWebhookOptions, onReady: (port: number) => void): Server | undefined;
package/dist/index.js CHANGED
@@ -10,6 +10,8 @@
10
10
  import crypto from "crypto";
11
11
  import express from "express";
12
12
  import rateLimit, { ipKeyGenerator } from "express-rate-limit";
13
+ import { hasNumberProp } from "@mulmoclaude/common";
14
+ import { describeListenError, resolveWebhookPort } from "./port.js";
13
15
  // Honour an explicit `trust proxy` setting so `req.ip` (the rate-limit
14
16
  // key) reflects the real client IP rather than the load balancer's.
15
17
  // Default `false` for safety; operators behind a known LB choose from:
@@ -129,3 +131,66 @@ export function registerMetaWebhookVerification(app, opts) {
129
131
  export function verifyMetaHmacSignature(rawBody, signature, appSecret) {
130
132
  return verifyHmacSignature(rawBody, signature.replace("sha256=", ""), appSecret, "sha256", "hex");
131
133
  }
134
+ // Register both halves of a Meta webhook (Messenger, WhatsApp): the GET
135
+ // verification handshake and the POST event delivery. The POST body arrives as
136
+ // raw text (see createWebhookApp) so the HMAC covers exactly the bytes Meta
137
+ // signed.
138
+ //
139
+ // One limiter covers both routes — a flood of bogus `hub.challenge` GET probes
140
+ // hammers the bridge just as effectively as POST traffic, so they share a
141
+ // bucket rather than getting one cap each. It is built HERE rather than taken
142
+ // as an argument because `js/missing-rate-limiting` only recognises the
143
+ // `express-rate-limit` call when it is visible at route setup; behind a
144
+ // parameter CodeQL cannot tell the signature check is throttled.
145
+ export function registerMetaWebhook(app, opts) {
146
+ const webhookRateLimit = createWebhookRateLimit();
147
+ registerMetaWebhookVerification(app, { rateLimit: webhookRateLimit, verifyToken: opts.verifyToken, label: opts.label });
148
+ app.post("/webhook", webhookRateLimit, async (req, res) => {
149
+ const signature = typeof req.headers["x-hub-signature-256"] === "string" ? req.headers["x-hub-signature-256"] : "";
150
+ const rawBody = typeof req.body === "string" ? req.body : "";
151
+ if (!signature || !verifyMetaHmacSignature(rawBody, signature, opts.appSecret)) {
152
+ console.warn(`[${opts.label}] AUTH_FAILED: signature verification failed`);
153
+ res.status(401).send("Invalid signature");
154
+ return;
155
+ }
156
+ // Ack before processing: Meta re-delivers anything it doesn't see
157
+ // acknowledged within seconds, so the reply must not wait on the agent.
158
+ res.status(200).send(opts.ackBody ?? "EVENT_RECEIVED");
159
+ await opts.onBody(rawBody);
160
+ });
161
+ }
162
+ // Bind a bridge's webhook port, and say something useful when that fails.
163
+ //
164
+ // Before #3084 each bridge did `Number(process.env.X) || N` and a bare
165
+ // `app.listen(PORT, cb)`: a typo ran on the default without a word, `X=0` was
166
+ // impossible, and `EADDRINUSE` surfaced as an unhandled error with no mention
167
+ // of which env var to change. The server's port band (3002-3021) overlaps the
168
+ // bridge band (3002-3013), so that collision is routine, not theoretical
169
+ // (#3079).
170
+ export function listenWebhook(app, opts, onReady) {
171
+ const fatal = opts.onFatal ?? exitWithMessage;
172
+ const resolved = resolveWebhookPort(process.env[opts.envVar], opts.fallback, opts.envVar);
173
+ if (!resolved.ok) {
174
+ fatal(resolved.message);
175
+ return undefined;
176
+ }
177
+ const server = app.listen(resolved.port, () => {
178
+ // `server.address()` is the authority on whether the bind happened, and on
179
+ // which port it got (it differs from the requested one for `=0`). Express 5
180
+ // runs this callback even when the bind FAILED — verified against
181
+ // express@5.1: `address()` is null, `listening` is false, and the 'error'
182
+ // event arrives on the next tick. Without this guard a bridge prints its
183
+ // "listening on <port>" banner for a server that never bound, which is the
184
+ // same silence #3084 is about, one step later.
185
+ const address = server.address();
186
+ if (!hasNumberProp(address, "port"))
187
+ return;
188
+ onReady(address.port);
189
+ });
190
+ server.on("error", (err) => fatal(describeListenError(err, resolved.port, opts.envVar)));
191
+ return server;
192
+ }
193
+ function exitWithMessage(message) {
194
+ console.error(message);
195
+ process.exit(1);
196
+ }
package/dist/port.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type PortResolution = {
2
+ readonly ok: true;
3
+ readonly port: number;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly message: string;
7
+ };
8
+ /** Resolve a bridge's listen port from its env var, or say why it cannot be used.
9
+ * `raw` is passed in rather than read here so this stays pure. */
10
+ export declare function resolveWebhookPort(raw: string | undefined, fallback: number, envVar: string): PortResolution;
11
+ /** What to tell the operator when `listen` fails. Names the env var, because
12
+ * every bridge uses a different one and "which knob do I turn" is the whole
13
+ * question at that moment. */
14
+ export declare function describeListenError(err: unknown, port: number, envVar: string): string;
package/dist/port.js ADDED
@@ -0,0 +1,40 @@
1
+ // Port resolution and bind diagnostics for the webhook bridges (#3084).
2
+ //
3
+ // Pure on purpose — no `process`, no sockets — so both decisions can be tested
4
+ // directly: WHICH port a bridge binds, and WHAT a human is told when the bind
5
+ // fails. `listenWebhook` in `index.ts` is the only part that touches the world.
6
+ import { asInt, errorMessage, isErrorWithCode, PORT_RANGE } from "@mulmoclaude/common";
7
+ // A fallback that `asInt` can never produce from a real value, since
8
+ // `PORT_RANGE.min` is 0. Comparing against it turns `asInt`'s "fell back"
9
+ // into "the operator typed something unusable", without changing `asInt`'s
10
+ // contract (it returns the fallback for bad input by design).
11
+ const UNUSABLE = -1;
12
+ /** Resolve a bridge's listen port from its env var, or say why it cannot be used.
13
+ * `raw` is passed in rather than read here so this stays pure. */
14
+ export function resolveWebhookPort(raw, fallback, envVar) {
15
+ // A blank value means "not configured", which is what `Number(x) || N` did
16
+ // for the nine bridges before this — `asInt` alone would read it as 0 and
17
+ // ask the OS for an ephemeral port.
18
+ if (raw === undefined || raw.trim() === "")
19
+ return { ok: true, port: fallback };
20
+ const port = asInt(raw, UNUSABLE, PORT_RANGE);
21
+ if (port === UNUSABLE) {
22
+ return {
23
+ ok: false,
24
+ message: `${envVar}="${raw}" is not a usable port. Set it to an integer from ${PORT_RANGE.min} to ${PORT_RANGE.max} (${envVar}=0 asks the OS for a free port), or unset it to use ${fallback}.`,
25
+ };
26
+ }
27
+ return { ok: true, port };
28
+ }
29
+ /** What to tell the operator when `listen` fails. Names the env var, because
30
+ * every bridge uses a different one and "which knob do I turn" is the whole
31
+ * question at that moment. */
32
+ export function describeListenError(err, port, envVar) {
33
+ if (isErrorWithCode(err) && err.code === "EADDRINUSE") {
34
+ return `Port ${port} is already in use. Set ${envVar} to a free port, or ${envVar}=0 to let the OS pick one.`;
35
+ }
36
+ if (isErrorWithCode(err) && err.code === "EACCES") {
37
+ return `Port ${port} needs elevated privileges. Set ${envVar} to a port above 1023.`;
38
+ }
39
+ return `Failed to listen on port ${port} (set ${envVar} to change it): ${errorMessage(err)}`;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/webhook-runtime",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "Shared HTTP-webhook plumbing (Express app, trust-proxy, rate limit, HMAC verify) for the MulmoClaude messaging bridges",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -27,12 +27,14 @@
27
27
  "license": "MIT",
28
28
  "author": "Receptron Team",
29
29
  "dependencies": {
30
+ "@mulmoclaude/common": "^1.3.0",
30
31
  "express": "^5.1.0",
31
- "express-rate-limit": "^8.6.1"
32
+ "express-rate-limit": "^8.7.0"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/express": "^5.0.0",
35
- "tsx": "^4.23.1",
36
+ "@types/node": "^26.4.1",
37
+ "tsx": "^4.23.13",
36
38
  "typescript": "^6.0.3"
37
39
  },
38
40
  "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/webhook-runtime#readme",