@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 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
- * Three result shapes:
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.2",
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.3.2"
18
+ "@crewhaus/errors": "0.4.2"
19
19
  },
20
20
  "license": "Apache-2.0",
21
21
  "author": {