@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 +16 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +65 -0
- package/dist/port.d.ts +14 -0
- package/dist/port.js +40 -0
- package/package.json +5 -3
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
|
+
"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.
|
|
32
|
+
"express-rate-limit": "^8.7.0"
|
|
32
33
|
},
|
|
33
34
|
"devDependencies": {
|
|
34
35
|
"@types/express": "^5.0.0",
|
|
35
|
-
"
|
|
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",
|