@crewhaus/channel-adapter-whatsapp 0.3.2 → 0.4.2
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/dist/index.d.ts +36 -4
- package/dist/index.js +31 -2
- package/dist/verify.d.ts +13 -0
- package/dist/verify.js +21 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* `messages[].id` (Meta's per-message identifier) as the gateway
|
|
8
8
|
* dedup key so retries don't double-process.
|
|
9
9
|
*
|
|
10
|
+
* `handshake` answers Meta's unsigned GET callback-URL verification
|
|
11
|
+
* (`hub.mode=subscribe`) by echoing `hub.challenge` once the presented
|
|
12
|
+
* `hub.verify_token` matches the configured one — without it Meta never
|
|
13
|
+
* activates the subscription and no webhook is ever delivered.
|
|
14
|
+
*
|
|
10
15
|
* `parseInbound` handles text + image + audio + button-reply +
|
|
11
16
|
* list-reply payloads from the v22.0 Cloud API. `sendReply` POSTs to
|
|
12
17
|
* `/v22.0/{phoneNumberId}/messages` with the bot's access token.
|
|
@@ -18,7 +23,7 @@
|
|
|
18
23
|
* keys sessions on the contact's phone number (`messages[].from`).
|
|
19
24
|
*/
|
|
20
25
|
import { CrewhausError } from "@crewhaus/errors";
|
|
21
|
-
export { signWhatsAppBody, verifyWhatsAppSignature } from "./verify.js";
|
|
26
|
+
export { signWhatsAppBody, verifyWhatsAppSignature, verifyWhatsAppVerifyToken, } from "./verify.js";
|
|
22
27
|
export declare class WhatsAppAdapterError extends CrewhausError {
|
|
23
28
|
readonly name = "WhatsAppAdapterError";
|
|
24
29
|
constructor(message: string, cause?: unknown);
|
|
@@ -38,12 +43,14 @@ export type InboundEvent = {
|
|
|
38
43
|
readonly subtype: "app_mention" | "message";
|
|
39
44
|
};
|
|
40
45
|
/**
|
|
41
|
-
*
|
|
46
|
+
* Two result shapes — `parseInbound` only ever sees a signed POST body:
|
|
42
47
|
* - `{kind:"event", event}` — a real inbound message to route
|
|
43
|
-
* - `{kind:"verification", challenge}` — Meta's GET-based verification
|
|
44
|
-
* handshake (the gateway echoes back `hub.challenge`)
|
|
45
48
|
* - `{kind:"skip"}` — known-but-uninteresting payload (status update,
|
|
46
49
|
* non-text non-interactive media, etc.)
|
|
50
|
+
*
|
|
51
|
+
* Meta's callback-URL verification is NOT a webhook body — it is an unsigned
|
|
52
|
+
* `GET ?hub.mode=subscribe&hub.verify_token=…&hub.challenge=…`, handled by
|
|
53
|
+
* `handshake` below, not here.
|
|
47
54
|
*/
|
|
48
55
|
export type ParsedInbound = {
|
|
49
56
|
readonly kind: "event";
|
|
@@ -51,10 +58,27 @@ export type ParsedInbound = {
|
|
|
51
58
|
} | {
|
|
52
59
|
readonly kind: "skip";
|
|
53
60
|
};
|
|
61
|
+
/**
|
|
62
|
+
* Meta's unsigned GET subscription handshake (see `handshake` below).
|
|
63
|
+
* Structurally identical to the channel-generic type the emitted gateway
|
|
64
|
+
* uses — the adapter packages deliberately re-declare the shared shapes
|
|
65
|
+
* rather than depend on one another.
|
|
66
|
+
*/
|
|
67
|
+
export type HandshakeRequest = {
|
|
68
|
+
readonly headers: Headers;
|
|
69
|
+
readonly url: URL;
|
|
70
|
+
};
|
|
71
|
+
export type HandshakeResult = {
|
|
72
|
+
readonly kind: "challenge";
|
|
73
|
+
readonly challenge: string;
|
|
74
|
+
} | {
|
|
75
|
+
readonly kind: "reject";
|
|
76
|
+
};
|
|
54
77
|
export interface ChannelAdapter {
|
|
55
78
|
readonly id: string;
|
|
56
79
|
verify(req: RawRequest): boolean;
|
|
57
80
|
parseInbound(req: RawRequest): ParsedInbound;
|
|
81
|
+
handshake(req: HandshakeRequest): HandshakeResult;
|
|
58
82
|
sendReply(args: {
|
|
59
83
|
event: InboundEvent;
|
|
60
84
|
text: string;
|
|
@@ -80,6 +104,14 @@ export type WhatsAppAdapterConfig = {
|
|
|
80
104
|
readonly accessToken: string;
|
|
81
105
|
/** Meta app secret, used to verify X-Hub-Signature-256. */
|
|
82
106
|
readonly appSecret: string;
|
|
107
|
+
/**
|
|
108
|
+
* The verify token configured on the Meta app's webhook callback. Required
|
|
109
|
+
* to complete the GET subscription handshake — Meta will not deliver any
|
|
110
|
+
* webhook to a callback URL that has never passed it. Absent ⇒ `handshake`
|
|
111
|
+
* rejects every attempt (fail closed), and the daemon can still receive
|
|
112
|
+
* webhooks on a subscription verified elsewhere.
|
|
113
|
+
*/
|
|
114
|
+
readonly verifyToken?: string;
|
|
83
115
|
};
|
|
84
116
|
export type WhatsAppAdapterOptions = {
|
|
85
117
|
readonly apiBaseUrl?: string;
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* `messages[].id` (Meta's per-message identifier) as the gateway
|
|
8
8
|
* dedup key so retries don't double-process.
|
|
9
9
|
*
|
|
10
|
+
* `handshake` answers Meta's unsigned GET callback-URL verification
|
|
11
|
+
* (`hub.mode=subscribe`) by echoing `hub.challenge` once the presented
|
|
12
|
+
* `hub.verify_token` matches the configured one — without it Meta never
|
|
13
|
+
* activates the subscription and no webhook is ever delivered.
|
|
14
|
+
*
|
|
10
15
|
* `parseInbound` handles text + image + audio + button-reply +
|
|
11
16
|
* list-reply payloads from the v22.0 Cloud API. `sendReply` POSTs to
|
|
12
17
|
* `/v22.0/{phoneNumberId}/messages` with the bot's access token.
|
|
@@ -18,8 +23,8 @@
|
|
|
18
23
|
* keys sessions on the contact's phone number (`messages[].from`).
|
|
19
24
|
*/
|
|
20
25
|
import { CrewhausError } from "@crewhaus/errors";
|
|
21
|
-
import { verifyWhatsAppSignature } from "./verify.js";
|
|
22
|
-
export { signWhatsAppBody, verifyWhatsAppSignature } from "./verify.js";
|
|
26
|
+
import { verifyWhatsAppSignature, verifyWhatsAppVerifyToken } from "./verify.js";
|
|
27
|
+
export { signWhatsAppBody, verifyWhatsAppSignature, verifyWhatsAppVerifyToken, } from "./verify.js";
|
|
23
28
|
export class WhatsAppAdapterError extends CrewhausError {
|
|
24
29
|
name = "WhatsAppAdapterError";
|
|
25
30
|
constructor(message, cause) {
|
|
@@ -50,6 +55,30 @@ export function createWhatsAppAdapter(config, opts = {}) {
|
|
|
50
55
|
appSecret: config.appSecret,
|
|
51
56
|
}, opts.now !== undefined ? { now: opts.now } : {});
|
|
52
57
|
},
|
|
58
|
+
/**
|
|
59
|
+
* Meta's callback-URL verification handshake:
|
|
60
|
+
* `GET /whatsapp/events?hub.mode=subscribe
|
|
61
|
+
* &hub.verify_token=<shared secret>&hub.challenge=<nonce>`
|
|
62
|
+
* On a token match the caller must echo `hub.challenge` verbatim with 200;
|
|
63
|
+
* anything else marks the callback URL unverified and Meta delivers no
|
|
64
|
+
* webhooks at all. The request is unsigned (no body, no
|
|
65
|
+
* X-Hub-Signature-256), so the verify token IS the authentication — which
|
|
66
|
+
* is why a mismatch, a missing parameter, or an unconfigured
|
|
67
|
+
* `verifyToken` all fail closed.
|
|
68
|
+
*/
|
|
69
|
+
handshake(req) {
|
|
70
|
+
const params = req.url.searchParams;
|
|
71
|
+
if (params.get("hub.mode") !== "subscribe")
|
|
72
|
+
return { kind: "reject" };
|
|
73
|
+
const challenge = params.get("hub.challenge");
|
|
74
|
+
const supplied = params.get("hub.verify_token");
|
|
75
|
+
if (challenge === null || supplied === null)
|
|
76
|
+
return { kind: "reject" };
|
|
77
|
+
if (!verifyWhatsAppVerifyToken({ expected: config.verifyToken ?? "", supplied })) {
|
|
78
|
+
return { kind: "reject" };
|
|
79
|
+
}
|
|
80
|
+
return { kind: "challenge", challenge };
|
|
81
|
+
},
|
|
53
82
|
parseInbound(req) {
|
|
54
83
|
let payload;
|
|
55
84
|
try {
|
package/dist/verify.d.ts
CHANGED
|
@@ -17,6 +17,19 @@ export type WhatsAppVerifyOptions = {
|
|
|
17
17
|
readonly toleranceMs?: number;
|
|
18
18
|
};
|
|
19
19
|
export declare function verifyWhatsAppSignature(args: WhatsAppVerifyArgs, opts?: WhatsAppVerifyOptions): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Constant-time comparison of the `hub.verify_token` Meta presents during the
|
|
22
|
+
* GET subscription handshake against the token this daemon was configured
|
|
23
|
+
* with. Both are shared secrets, so the comparison must not leak length or
|
|
24
|
+
* prefix through timing.
|
|
25
|
+
*
|
|
26
|
+
* An empty `expected` (no `verifyToken` configured) NEVER matches: a daemon
|
|
27
|
+
* that was not told the token must not be verifiable by an arbitrary Meta app.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifyWhatsAppVerifyToken(args: {
|
|
30
|
+
readonly expected: string;
|
|
31
|
+
readonly supplied: string;
|
|
32
|
+
}): boolean;
|
|
20
33
|
/**
|
|
21
34
|
* Sign a body — used by smoke tests and the in-repo integration test to
|
|
22
35
|
* construct fixtures the daemon will accept. Production code should NEVER
|
package/dist/verify.js
CHANGED
|
@@ -76,6 +76,27 @@ export function verifyWhatsAppSignature(args, opts = {}) {
|
|
|
76
76
|
return false;
|
|
77
77
|
return true;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Constant-time comparison of the `hub.verify_token` Meta presents during the
|
|
81
|
+
* GET subscription handshake against the token this daemon was configured
|
|
82
|
+
* with. Both are shared secrets, so the comparison must not leak length or
|
|
83
|
+
* prefix through timing.
|
|
84
|
+
*
|
|
85
|
+
* An empty `expected` (no `verifyToken` configured) NEVER matches: a daemon
|
|
86
|
+
* that was not told the token must not be verifiable by an arbitrary Meta app.
|
|
87
|
+
*/
|
|
88
|
+
export function verifyWhatsAppVerifyToken(args) {
|
|
89
|
+
if (args.expected.length === 0)
|
|
90
|
+
return false;
|
|
91
|
+
const expectedBuf = Buffer.from(args.expected, "utf8");
|
|
92
|
+
const suppliedBuf = Buffer.from(args.supplied, "utf8");
|
|
93
|
+
// timingSafeEqual throws on length mismatch, so compare a fixed-length
|
|
94
|
+
// digest of each instead of the raw bytes — that keeps the comparison
|
|
95
|
+
// constant-time without leaking the configured token's length.
|
|
96
|
+
const expectedMac = createHmac("sha256", expectedBuf).update(expectedBuf).digest();
|
|
97
|
+
const suppliedMac = createHmac("sha256", expectedBuf).update(suppliedBuf).digest();
|
|
98
|
+
return timingSafeEqual(expectedMac, suppliedMac);
|
|
99
|
+
}
|
|
79
100
|
/**
|
|
80
101
|
* Sign a body — used by smoke tests and the in-repo integration test to
|
|
81
102
|
* construct fixtures the daemon will accept. Production code should NEVER
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/channel-adapter-whatsapp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "WhatsApp Business Cloud API channel adapter: X-Hub-Signature-256 verification + text/media/interactive parsing + idempotent retry handling (Section 33)",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"test": "bun test src"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@crewhaus/errors": "0.
|
|
18
|
+
"@crewhaus/errors": "0.4.2"
|
|
19
19
|
},
|
|
20
20
|
"license": "Apache-2.0",
|
|
21
21
|
"author": {
|