From 713b50afad2a9564f9a8400ec612892c9ae6a5b0 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:03:53 +0000 Subject: [PATCH 1/3] feat(task T03): implement via codex --- .../architecture/chat-connectors/whatsapp.md | 12 +- docs/settings/chat-connectors/whatsapp.md | 38 +- .../chat-connectors/providers/whatsapp.ts | 488 +++++++++++++++++- .../domain/chat-connectors/registry.test.ts | 9 +- .../domain/chat-connectors/whatsapp.test.ts | 450 ++++++++++++++++ 5 files changed, 966 insertions(+), 31 deletions(-) create mode 100644 tests/backend/domain/chat-connectors/whatsapp.test.ts diff --git a/docs-web/architecture/chat-connectors/whatsapp.md b/docs-web/architecture/chat-connectors/whatsapp.md index 1b3d8740ef..45b412feb6 100644 --- a/docs-web/architecture/chat-connectors/whatsapp.md +++ b/docs-web/architecture/chat-connectors/whatsapp.md @@ -1,5 +1,13 @@ # WhatsApp Connector Profile -WhatsApp is registered with `managed_bridge` and `webhook` transports. Its module owns the unchanged managed-plugin and webhook setup schemas, Cloud API-shaped inbound normalizer, bridge authentication metadata, outbound URL and credential mapping, configuration verification, and official reference metadata. +WhatsApp implements `managed_bridge`, `webhook`, and direct Meta Cloud API `official_api` transport. The legacy schemas and bridge mappings retain their original meaning; official mode is additive. -The baseline profile has no live test and does not implement `official_api`. Registry construction never contacts WhatsApp. +The official profile fixes Graph traffic to `https://graph.facebook.com/{version}/{phoneNumberId}` and validates both path components before building a request. Outbound messages use the original inbound sender WhatsApp ID as `to`, while `metadata.phone_number_id` remains the channel binding. Replies map the inbound `wamid` to `context.message_id`, and successful response `wamid` values become outbound delivery IDs. + +Webhook hooks implement Meta's `hub.mode`, `hub.verify_token`, and `hub.challenge` GET handshake and verify POST `X-Hub-Signature-256` values over exact raw bytes with the app secret. Message and status payloads are discriminated before normalization so delivery receipts never become inbound conversation messages. Text and media-caption message bodies are supported. + +The profile exposes read-only verification of the configured phone-number resource. It uses a bounded timeout, classifies retryable HTTP and structured Meta errors, and returns bounded metadata without access tokens or recipient values. Normal verification never sends a message; the separate opted-in Meta test-number path owns any future send-based test. + +Credentials (`accessToken`, `appSecret`, and `webhookVerifyToken`) remain secret-schema fields and are not exposed through public connection records. Official mode cannot use a custom Graph host or silently fall back to the generic webhook URL. + +References: [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Meta Webhooks](https://developers.facebook.com/docs/graph-api/webhooks/getting-started), and [Meta's official Postman collection](https://www.postman.com/meta/whatsapp-business-platform/overview). diff --git a/docs/settings/chat-connectors/whatsapp.md b/docs/settings/chat-connectors/whatsapp.md index 6fe46cafe6..a6c7097e84 100644 --- a/docs/settings/chat-connectors/whatsapp.md +++ b/docs/settings/chat-connectors/whatsapp.md @@ -1,9 +1,39 @@ # WhatsApp Chat Connector -The baseline WhatsApp profile supports `managed_bridge` and `webhook`. Managed delivery uses the existing bridge contract; webhook ingress retains HMAC authentication and WhatsApp Cloud API payload normalization. +The WhatsApp profile supports the direct Meta Cloud API as `official_api` while retaining the existing `managed_bridge` and generic `webhook` records unchanged. -Setup remains compatible with stored connections: managed setup uses `pluginName` and optional `workspaceId` with `bridgeApiKey`; webhook setup uses `webhookUrl`, optional `verifyTokenName`, `webhookSecret`, and optional `verifyToken`. +## Official Cloud API setup -Live provider testing and direct `official_api` transport are not implemented by this baseline profile. +Configure these non-secret fields: -Official reference: [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api). +- `graphApiVersion`: a version in `v{major}.{minor}` form, such as `v23.0`. +- `phoneNumberId`: the numeric ID of the business phone number that receives and sends messages. +- `appId` and `businessAccountId`: optional Meta application metadata. + +Configure `accessToken`, `appSecret`, and `webhookVerifyToken` as secret fields. They are write-only connection credentials and are never returned in connection responses. Official mode always uses `https://graph.facebook.com`; it does not accept a custom Graph host or fall back to a configured webhook URL. + +## Webhooks + +For Meta's GET subscription check, the profile accepts only `hub.mode=subscribe` with a constant-time match against the configured `webhookVerifyToken`, then returns `hub.challenge`. Other modes, missing values, and token mismatches fail closed. + +For POST callbacks, validate `X-Hub-Signature-256` as an HMAC-SHA256 over the exact raw request bytes with `appSecret`. Parsing or reserializing JSON before verification changes the signed bytes and must fail validation. + +Message callbacks and delivery/status callbacks are normalized separately. Only message callbacks enter the inbound conversation flow. The business `metadata.phone_number_id` is the external channel, the inbound message `wamid` is the idempotency key, and `messages[].from` is retained as the sender and future outbound recipient. Text bodies and image, video, or document captions are supported. + +## Outbound replies and verification + +Official text and reply requests are sent only to: + +```text +https://graph.facebook.com/{graphApiVersion}/{phoneNumberId}/messages +``` + +Requests include `messaging_product: whatsapp`; replies also include `context.message_id`. The recipient is the original sender WhatsApp ID, never the business phone-number channel ID. Successful `messages[].id` values are retained as outbound `wamid` delivery IDs. Retryable HTTP and Meta error classifications are bounded and do not echo tokens or recipient data. + +Connection verification is read-only. It performs a GET for the configured test or registered phone-number resource and checks that Meta returns the same ID. It never sends a WhatsApp message. Send-based testing remains reserved for the separately opted-in Meta test-number workflow. + +## Legacy compatibility + +Managed setup still uses `pluginName`, optional `workspaceId`, and `bridgeApiKey`. Generic webhook setup still uses `webhookUrl`, optional `verifyTokenName`, `webhookSecret`, and optional `verifyToken`. Their URL fallback, credential lookup, payload, response parsing, and stored record meanings are unchanged. + +Official references: [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Meta Webhooks](https://developers.facebook.com/docs/graph-api/webhooks/getting-started), and [Meta's WhatsApp Business Platform Postman collection](https://www.postman.com/meta/whatsapp-business-platform/overview). diff --git a/src/domain/chat-connectors/providers/whatsapp.ts b/src/domain/chat-connectors/providers/whatsapp.ts index 77d017eb65..26b1d687fd 100644 --- a/src/domain/chat-connectors/providers/whatsapp.ts +++ b/src/domain/chat-connectors/providers/whatsapp.ts @@ -1,5 +1,15 @@ -import type { ChatConnectorProfile } from "../types.js"; +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { ChatProviderBridgeMode } from "../../../contracts/chat-provider-types.js"; +import { redactText } from "../../../shared/security/redaction.js"; +import type { + ChatConnectorOutboundContext, + ChatConnectorOutboundResult, + ChatConnectorProfile, + ChatConnectorVerificationResult, + PartialNormalizedChatConnectorInbound, +} from "../types.js"; import { + DEFAULT_CONNECTOR_TIMEOUT_MS, buildLegacyHttpOutboundRequest, isLegacyRetryableHttpStatus, parseLegacyOutboundResponse, @@ -10,6 +20,10 @@ import { verifyConnectorConfiguration, } from "../types.js"; +const WHATSAPP_GRAPH_API_ORIGIN = "https://graph.facebook.com"; +const PHONE_NUMBER_FIELDS = "id,display_phone_number,verified_name,quality_rating"; +const RETRYABLE_GRAPH_ERROR_CODES = new Set([1, 2, 4, 17, 32, 341, 613, 80004, 130429, 131048, 131056]); + const setupSchema = { kind: "whatsapp", label: "WhatsApp", @@ -38,13 +52,424 @@ const setupSchema = { { key: "verifyToken", label: "Verify token", required: false }, ], }, + { + mode: "official_api", + label: "WhatsApp Cloud API", + integration: "official_api", + setupFields: [ + { key: "graphApiVersion", label: "Graph API version", type: "string", required: true }, + { key: "phoneNumberId", label: "Phone-number ID", type: "string", required: true }, + { key: "appId", label: "Meta app ID", type: "string", required: false }, + { key: "businessAccountId", label: "WhatsApp Business Account ID", type: "string", required: false }, + ], + secretFields: [ + { key: "accessToken", label: "Access token", required: true }, + { key: "appSecret", label: "Meta app secret", required: true }, + { key: "webhookVerifyToken", label: "Webhook verify token", required: true }, + ], + }, ], } as const; -export const whatsappChatConnectorProfile: ChatConnectorProfile = { +export interface WhatsAppWebhookChallengeResult { + verified: boolean; + statusCode: 200 | 403; + body: string; +} + +export interface WhatsAppStatusEvent { + externalChannelId?: string; + externalMessageId?: string; + recipientId?: string; + status?: string; + timestamp?: unknown; +} + +export type NormalizedWhatsAppWebhook = + | { kind: "message"; message: PartialNormalizedChatConnectorInbound } + | { kind: "status"; statuses: readonly WhatsAppStatusEvent[] } + | { kind: "unsupported" }; + +export interface WhatsAppGraphErrorClassification { + retryable: boolean; + statusCode: number; + code: number | null; + subcode: number | null; + type: string | null; + isTransient: boolean; + message: string; +} + +export interface WhatsAppVerifiedPhoneNumber { + id: string; + displayPhoneNumber: string | null; + verifiedName: string | null; + qualityRating: string | null; +} + +export interface WhatsAppOfficialConnectionVerificationResult extends ChatConnectorVerificationResult { + retryable: boolean; + resource: WhatsAppVerifiedPhoneNumber | null; +} + +export interface WhatsAppChatConnectorProfile extends ChatConnectorProfile { + officialApi: { + webhook: { + verifyChallenge: typeof verifyWhatsAppWebhookChallenge; + verifySignature: typeof verifyWhatsAppWebhookSignature; + normalize: typeof normalizeWhatsAppWebhook; + }; + outbound: { + classifyError: typeof classifyWhatsAppGraphError; + }; + verification: { + verifyConnection: typeof verifyWhatsAppOfficialConnection; + }; + }; +} + +export function verifyWhatsAppWebhookChallenge( + query: Record, + webhookVerifyToken: string, +): WhatsAppWebhookChallengeResult { + const mode = readExactString(query["hub.mode"]); + const actualToken = readExactString(query["hub.verify_token"]); + const challenge = readExactString(query["hub.challenge"]); + const verified = mode === "subscribe" + && challenge !== null + && challenge.length > 0 + && actualToken !== null + && webhookVerifyToken.length > 0 + && constantTimeEquals(actualToken, webhookVerifyToken); + + return verified + ? { verified: true, statusCode: 200, body: challenge } + : { verified: false, statusCode: 403, body: "Forbidden" }; +} + +export function verifyWhatsAppWebhookSignature( + rawBody: string | Uint8Array, + signatureHeader: string | undefined, + appSecret: string, +): boolean { + const match = signatureHeader?.trim().match(/^sha256=([a-f0-9]{64})$/i); + if (!match || !appSecret) { + return false; + } + const expected = createHmac("sha256", appSecret).update(rawBody).digest("hex"); + return constantTimeEquals(match[1].toLowerCase(), expected); +} + +export function normalizeWhatsAppWebhook(payload: Record): NormalizedWhatsAppWebhook { + const value = readRecord(readArray(readRecord(readArray(payload.entry)?.[0])?.changes)?.[0])?.value; + const valueRecord = readRecord(value); + if (!valueRecord) { + return { kind: "unsupported" }; + } + + const metadata = readRecord(valueRecord.metadata); + const message = readRecord(readArray(valueRecord.messages)?.[0]); + if (message) { + const contact = readRecord(readArray(valueRecord.contacts)?.[0]); + return { + kind: "message", + message: { + externalChannelId: readString(metadata?.phone_number_id, payload.phone_number_id), + externalChannelName: readString(metadata?.display_phone_number, metadata?.phone_number_id), + externalSenderId: readString(message.from, contact?.wa_id), + externalSenderName: readString(readRecord(contact?.profile)?.name, contact?.wa_id), + textBody: readString( + readRecord(message.text)?.body, + readRecord(message.image)?.caption, + readRecord(message.video)?.caption, + readRecord(message.document)?.caption, + readRecord(message.button)?.text, + message.body, + ), + externalMessageId: readString(message.id), + timestamp: message.timestamp, + }, + }; + } + + const statuses = readArray(valueRecord.statuses); + if (statuses) { + return { + kind: "status", + statuses: statuses.map((candidate) => { + const status = readRecord(candidate); + return { + externalChannelId: readString(metadata?.phone_number_id, payload.phone_number_id), + externalMessageId: readString(status?.id), + recipientId: readString(status?.recipient_id), + status: readString(status?.status), + timestamp: status?.timestamp, + }; + }), + }; + } + + return { kind: "unsupported" }; +} + +export function classifyWhatsAppGraphError( + statusCode: number, + responseBody: string, +): WhatsAppGraphErrorClassification { + const payload = parseJsonRecord(responseBody); + const error = readRecord(payload?.error); + const code = readFiniteNumber(error?.code); + const subcode = readFiniteNumber(error?.error_subcode); + const isTransient = error?.is_transient === true; + const retryable = isLegacyRetryableHttpStatus(statusCode) + || isTransient + || (code !== null && RETRYABLE_GRAPH_ERROR_CODES.has(code)); + const identifiers = [ + `HTTP ${statusCode}`, + code === null ? null : `code ${code}`, + subcode === null ? null : `subcode ${subcode}`, + ].filter((value): value is string => value !== null); + + return { + retryable, + statusCode, + code, + subcode, + type: sanitizeGraphErrorType(error?.type), + isTransient, + message: `Meta Graph API request failed (${identifiers.join(", ")}).`, + }; +} + +export async function verifyWhatsAppOfficialConnection( + setup: Record, + secrets: Record | null, + fetchImplementation: typeof fetch = globalThis.fetch, +): Promise { + const configuration = verifyWhatsAppConfiguration("official_api", setup, secrets); + if (!configuration.valid) { + return { ...configuration, retryable: false, resource: null }; + } + + const graphApiVersion = requireGraphApiVersion(setup.graphApiVersion); + const phoneNumberId = requirePhoneNumberId(setup.phoneNumberId); + const accessToken = requireConfiguredString(secrets?.accessToken, "accessToken"); + const url = `${WHATSAPP_GRAPH_API_ORIGIN}/${graphApiVersion}/${phoneNumberId}?fields=${PHONE_NUMBER_FIELDS}`; + + let response: Response; + try { + response = await fetchImplementation(url, { + method: "GET", + headers: { authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(DEFAULT_CONNECTOR_TIMEOUT_MS), + }); + } catch (error) { + return { + valid: false, + issues: [`Meta Graph API verification failed: ${sanitizeNetworkError(error, [accessToken])}`], + retryable: true, + resource: null, + }; + } + + const responseBody = await response.text().catch(() => ""); + if (!response.ok) { + const classification = classifyWhatsAppGraphError(response.status, responseBody); + return { + valid: false, + issues: [classification.message], + retryable: classification.retryable, + resource: null, + }; + } + + const resource = parseJsonRecord(responseBody); + const returnedId = readString(resource?.id); + if (returnedId !== phoneNumberId) { + return { + valid: false, + issues: ["Meta Graph API verification returned a different phone-number resource."], + retryable: false, + resource: null, + }; + } + + return { + valid: true, + issues: [], + retryable: false, + resource: { + id: returnedId, + displayPhoneNumber: readString(resource?.display_phone_number) ?? null, + verifiedName: readString(resource?.verified_name) ?? null, + qualityRating: readString(resource?.quality_rating) ?? null, + }, + }; +} + +function verifyWhatsAppConfiguration( + mode: ChatProviderBridgeMode, + setup: Record, + secrets: Record | null, +): ChatConnectorVerificationResult { + const base = verifyConnectorConfiguration(setupSchema, mode, setup, secrets); + if (mode !== "official_api") { + return base; + } + + const issues = [...base.issues]; + if (readString(setup.graphApiVersion) && !isGraphApiVersion(setup.graphApiVersion)) { + issues.push("Graph API version must use the v{major}.{minor} format."); + } + if (readString(setup.phoneNumberId) && !isPhoneNumberId(setup.phoneNumberId)) { + issues.push("Phone-number ID must contain digits only."); + } + return { valid: issues.length === 0, issues }; +} + +function buildOfficialOutboundRequest(context: ChatConnectorOutboundContext) { + const graphApiVersion = requireGraphApiVersion(context.connection.setup.graphApiVersion); + const phoneNumberId = requirePhoneNumberId(context.connection.setup.phoneNumberId); + const recipientId = resolveInboundSenderWhatsAppId(context); + const replyToMessageId = readString(context.payload.replyToExternalMessageId); + + return { + transport: "http" as const, + url: `${WHATSAPP_GRAPH_API_ORIGIN}/${graphApiVersion}/${phoneNumberId}/messages`, + label: "WhatsApp Cloud API messages endpoint", + headers: { + "content-type": "application/json", + "x-correlation-id": context.correlationId, + "x-codeux-provider-kind": "whatsapp", + "x-codeux-bridge-mode": "official_api", + }, + bearerSecretKeys: ["accessToken"], + body: { + messaging_product: "whatsapp", + recipient_type: "individual", + to: recipientId, + ...(replyToMessageId ? { context: { message_id: replyToMessageId } } : {}), + type: "text", + text: { + preview_url: false, + body: context.payload.replyText, + }, + }, + timeoutMs: DEFAULT_CONNECTOR_TIMEOUT_MS, + }; +} + +function resolveInboundSenderWhatsAppId(context: ChatConnectorOutboundContext): string { + const metadata = context.payload.metadata; + const inboundPayload = readRecord(metadata.inboundPayload); + const triggeringMetadata = readRecord(metadata.triggeringMessageMetadata); + const directSender = readRecord(metadata.externalSender); + const recipient = readString( + readRecord(inboundPayload?.externalSender)?.id, + readRecord(triggeringMetadata?.externalSender)?.id, + directSender?.id, + ); + if (!recipient || !/^\d{5,20}$/.test(recipient)) { + throw new Error("Inbound sender WhatsApp ID is unavailable for outbound delivery."); + } + return recipient; +} + +function parseWhatsAppOutboundResponse(responseBody: string): ChatConnectorOutboundResult { + const payload = parseJsonRecord(responseBody); + if (!payload) { + return parseLegacyOutboundResponse(responseBody); + } + if (readRecord(payload.error)) { + throw new Error(classifyWhatsAppGraphError(200, responseBody).message); + } + const messages = readArray(payload.messages); + if (!messages) { + return parseLegacyOutboundResponse(responseBody); + } + const externalMessageId = readString(readRecord(messages[0])?.id) ?? null; + return { + externalMessageId, + responseMetadata: { + messagingProduct: readString(payload.messaging_product) ?? "whatsapp", + messageCount: messages.length, + }, + }; +} + +function parseJsonRecord(value: string): Record | null { + try { + return readRecord(JSON.parse(value) as unknown); + } catch { + return null; + } +} + +function requireGraphApiVersion(value: unknown): string { + const version = readString(value); + if (!version || !isGraphApiVersion(version)) { + throw new Error("WhatsApp Graph API version must use the v{major}.{minor} format."); + } + return version; +} + +function isGraphApiVersion(value: unknown): boolean { + return typeof value === "string" && /^v\d{1,3}\.\d{1,2}$/.test(value.trim()); +} + +function requirePhoneNumberId(value: unknown): string { + const phoneNumberId = readString(value); + if (!phoneNumberId || !isPhoneNumberId(phoneNumberId)) { + throw new Error("WhatsApp phone-number ID must contain digits only."); + } + return phoneNumberId; +} + +function isPhoneNumberId(value: unknown): boolean { + return typeof value === "string" && /^\d+$/.test(value.trim()); +} + +function requireConfiguredString(value: unknown, key: string): string { + const configured = readString(value); + if (!configured) { + throw new Error(`Missing required secret field: ${key}`); + } + return configured; +} + +function readExactString(value: unknown): string | null { + const candidate = Array.isArray(value) ? value[0] : value; + return typeof candidate === "string" ? candidate : null; +} + +function readFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function sanitizeGraphErrorType(value: unknown): string | null { + return typeof value === "string" && /^[A-Za-z0-9_.-]{1,100}$/.test(value) ? value : null; +} + +function sanitizeNetworkError(error: unknown, sensitiveValues: readonly string[]): string { + let message = error instanceof Error ? error.message : String(error); + for (const sensitiveValue of [...sensitiveValues].sort((left, right) => right.length - left.length)) { + if (sensitiveValue) { + message = message.split(sensitiveValue).join("[REDACTED]"); + } + } + return redactText(message).slice(0, 300); +} + +function constantTimeEquals(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +export const whatsappChatConnectorProfile: WhatsAppChatConnectorProfile = { kind: "whatsapp", setupSchema, - supportedTransportModes: ["managed_bridge", "webhook"], + supportedTransportModes: ["managed_bridge", "webhook", "official_api"], ingress: { authentication: { managed_bridge: { @@ -60,24 +485,19 @@ export const whatsappChatConnectorProfile: ChatConnectorProfile = { timestampHeaders: ["x-code-ux-timestamp", "x-provider-timestamp", "x-slack-request-timestamp"], signatureBases: ({ timestamp, rawBody }) => [`${timestamp}.${rawBody}`, `v0:${timestamp}:${rawBody}`, rawBody], }, + official_api: { + type: "hmac_sha256", + secretKeys: ["appSecret"], + signatureHeaders: ["x-hub-signature-256"], + timestampHeaders: [], + signatureBases: ({ rawBody }) => [rawBody], + }, }, handshake: { type: "none" }, acknowledgement: { statusCode: 200, headers: { "content-type": "application/json" }, body: null }, normalize: (body) => { - const value = readRecord(readArray(readRecord(readArray(body.entry)?.[0])?.changes)?.[0])?.value; - const valueRecord = readRecord(value); - const message = readRecord(readArray(valueRecord?.messages)?.[0]); - const contact = readRecord(readArray(valueRecord?.contacts)?.[0]); - const metadata = readRecord(valueRecord?.metadata); - return { - externalChannelId: readString(metadata?.phone_number_id, body.phone_number_id), - externalChannelName: readString(metadata?.display_phone_number, metadata?.phone_number_id), - externalSenderId: readString(message?.from, contact?.wa_id), - externalSenderName: readString(readRecord(contact?.profile)?.name, contact?.wa_id), - textBody: readString(readRecord(message?.text)?.body, message?.body), - externalMessageId: readString(message?.id), - timestamp: message?.timestamp, - }; + const normalized = normalizeWhatsAppWebhook(body); + return normalized.kind === "message" ? normalized.message : {}; }, }, identity: { resolve: resolveLegacyIdentity }, @@ -99,18 +519,38 @@ export const whatsappChatConnectorProfile: ChatConnectorProfile = { label: "webhook bridge URL", }); } + if (context.connection.bridgeMode === "official_api") { + return buildOfficialOutboundRequest(context); + } throw new Error(`Unsupported bridge mode for whatsapp: ${context.connection.bridgeMode}`); }, - parseResponse: parseLegacyOutboundResponse, + parseResponse: parseWhatsAppOutboundResponse, isRetryableStatus: isLegacyRetryableHttpStatus, }, verification: { - strategy: "configuration", - capabilities: ["setup", "authentication", "outbound"], - verifyConfiguration: (mode, setup, secrets) => verifyConnectorConfiguration(setupSchema, mode, setup, secrets), + strategy: "configuration_and_live", + capabilities: ["setup", "authentication", "handshake", "outbound"], + verifyConfiguration: verifyWhatsAppConfiguration, + }, + officialApi: { + webhook: { + verifyChallenge: verifyWhatsAppWebhookChallenge, + verifySignature: verifyWhatsAppWebhookSignature, + normalize: normalizeWhatsAppWebhook, + }, + outbound: { classifyError: classifyWhatsAppGraphError }, + verification: { verifyConnection: verifyWhatsAppOfficialConnection }, }, session: { required: false, scope: "connection", requirements: [] }, - officialDocumentation: [{ label: "WhatsApp Cloud API", url: "https://developers.facebook.com/docs/whatsapp/cloud-api" }], - liveTest: { available: false, modes: [], reason: "Baseline bridge profiles do not invoke provider endpoints." }, - lifecycle: { status: "baseline", profileVersion: 1, introducedIn: "typed-registry" }, + officialDocumentation: [ + { label: "WhatsApp Cloud API", url: "https://developers.facebook.com/docs/whatsapp/cloud-api" }, + { label: "Meta Webhooks", url: "https://developers.facebook.com/docs/graph-api/webhooks/getting-started" }, + { label: "Meta WhatsApp Postman collection", url: "https://www.postman.com/meta/whatsapp-business-platform/overview" }, + ], + liveTest: { + available: false, + modes: [], + reason: "Connection verification is read-only; message sends require the separately opted-in Meta test-number path.", + }, + lifecycle: { status: "preview", profileVersion: 2, introducedIn: "whatsapp-cloud-api" }, }; diff --git a/tests/backend/domain/chat-connectors/registry.test.ts b/tests/backend/domain/chat-connectors/registry.test.ts index 3d8d5429a6..3e48cc257b 100644 --- a/tests/backend/domain/chat-connectors/registry.test.ts +++ b/tests/backend/domain/chat-connectors/registry.test.ts @@ -44,6 +44,12 @@ describe("chat connector registry", () => { modes: [ { mode: "managed_bridge", integration: "managed_plugin", setup: ["pluginName", "workspaceId"], secrets: ["bridgeApiKey"] }, { mode: "webhook", integration: "webhook", setup: ["webhookUrl", "verifyTokenName"], secrets: ["webhookSecret", "verifyToken"] }, + { + mode: "official_api", + integration: "official_api", + setup: ["graphApiVersion", "phoneNumberId", "appId", "businessAccountId"], + secrets: ["accessToken", "appSecret", "webhookVerifyToken"], + }, ], }, imessage: { @@ -92,7 +98,8 @@ describe("chat connector registry", () => { expect(() => getChatConnectorProfileForMode("discord", "managed_bridge")).toThrow( "Unsupported bridge mode for discord: managed_bridge", ); - for (const kind of CHAT_CONNECTOR_KINDS) { + expect(getChatConnectorProfileForMode("whatsapp", "official_api").kind).toBe("whatsapp"); + for (const kind of CHAT_CONNECTOR_KINDS.filter((candidate) => candidate !== "whatsapp")) { expect(() => getChatConnectorProfileForMode(kind, "official_api" as ChatProviderBridgeMode)).toThrow( `Unsupported bridge mode for ${kind}: official_api`, ); diff --git a/tests/backend/domain/chat-connectors/whatsapp.test.ts b/tests/backend/domain/chat-connectors/whatsapp.test.ts new file mode 100644 index 0000000000..c283bf656b --- /dev/null +++ b/tests/backend/domain/chat-connectors/whatsapp.test.ts @@ -0,0 +1,450 @@ +import { createHmac } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + ChatProviderBridgeMode, + ChatProviderChannelBindingRecord, + ChatProviderConnectionInternalRecord, + ChatProviderMessageDeliveryRecord, +} from "../../../../src/contracts/chat-provider-types.js"; +import { + classifyWhatsAppGraphError, + normalizeWhatsAppWebhook, + verifyWhatsAppOfficialConnection, + verifyWhatsAppWebhookChallenge, + verifyWhatsAppWebhookSignature, + whatsappChatConnectorProfile, +} from "../../../../src/domain/chat-connectors/providers/whatsapp.js"; +import type { ChatConnectorOutboundContext } from "../../../../src/domain/chat-connectors/types.js"; +import { + ChatProviderOutboundAdapterError, + ConfiguredChatProviderOutboundAdapter, +} from "../../../../src/services/chat-provider-adapters.js"; + +const ACCESS_TOKEN = "meta-access-token-that-must-stay-private"; +const APP_SECRET = "meta-app-secret"; +const VERIFY_TOKEN = "webhook-verify-token"; +const PHONE_NUMBER_ID = "109876543210987"; +const SENDER_WA_ID = "15551234567"; +const CREATED_AT = "2026-07-13T12:00:00.000Z"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("WhatsApp Cloud API profile", () => { + it("adds the official schema without changing either legacy bridge schema", () => { + const modes = whatsappChatConnectorProfile.setupSchema.bridgeModes.map((schema) => ({ + mode: schema.mode, + setup: schema.setupFields.map((field) => field.key), + secrets: schema.secretFields.map((field) => field.key), + })); + + expect(modes).toEqual([ + { + mode: "managed_bridge", + setup: ["pluginName", "workspaceId"], + secrets: ["bridgeApiKey"], + }, + { + mode: "webhook", + setup: ["webhookUrl", "verifyTokenName"], + secrets: ["webhookSecret", "verifyToken"], + }, + { + mode: "official_api", + setup: ["graphApiVersion", "phoneNumberId", "appId", "businessAccountId"], + secrets: ["accessToken", "appSecret", "webhookVerifyToken"], + }, + ]); + expect(whatsappChatConnectorProfile.supportedTransportModes).toEqual([ + "managed_bridge", + "webhook", + "official_api", + ]); + }); + + it("returns the GET subscription challenge only for a matching subscribe request", () => { + expect(verifyWhatsAppWebhookChallenge({ + "hub.mode": "subscribe", + "hub.verify_token": VERIFY_TOKEN, + "hub.challenge": "987654321", + }, VERIFY_TOKEN)).toEqual({ verified: true, statusCode: 200, body: "987654321" }); + + expect(verifyWhatsAppWebhookChallenge({ + "hub.mode": "subscribe", + "hub.verify_token": "wrong-token", + "hub.challenge": "987654321", + }, VERIFY_TOKEN)).toEqual({ verified: false, statusCode: 403, body: "Forbidden" }); + expect(verifyWhatsAppWebhookChallenge({ + "hub.mode": "unsubscribe", + "hub.verify_token": VERIFY_TOKEN, + "hub.challenge": "987654321", + }, VERIFY_TOKEN).verified).toBe(false); + }); + + it("validates X-Hub-Signature-256 against the exact raw request bytes", () => { + const rawBody = Buffer.from('{"entry": [ {"id":"1"} ]}\n', "utf8"); + const signature = `sha256=${createHmac("sha256", APP_SECRET).update(rawBody).digest("hex")}`; + + expect(verifyWhatsAppWebhookSignature(rawBody, signature, APP_SECRET)).toBe(true); + expect(verifyWhatsAppWebhookSignature(JSON.stringify(JSON.parse(rawBody.toString("utf8"))), signature, APP_SECRET)).toBe(false); + expect(verifyWhatsAppWebhookSignature(rawBody, undefined, APP_SECRET)).toBe(false); + expect(verifyWhatsAppWebhookSignature(rawBody, "sha256=invalid", APP_SECRET)).toBe(false); + }); + + it("normalizes text and caption messages with business channel and sender identities", () => { + const text = normalizeWhatsAppWebhook(messageWebhook({ + id: "wamid.inbound-text", + from: SENDER_WA_ID, + timestamp: "1783963200", + type: "text", + text: { body: "Hello from WhatsApp" }, + })); + const caption = normalizeWhatsAppWebhook(messageWebhook({ + id: "wamid.inbound-image", + from: SENDER_WA_ID, + timestamp: "1783963260", + type: "image", + image: { id: "media-id", caption: "Screenshot caption" }, + })); + + expect(text).toEqual({ + kind: "message", + message: { + externalChannelId: PHONE_NUMBER_ID, + externalChannelName: "+1 555 765 4321", + externalSenderId: SENDER_WA_ID, + externalSenderName: "Example Sender", + textBody: "Hello from WhatsApp", + externalMessageId: "wamid.inbound-text", + timestamp: "1783963200", + }, + }); + expect(caption.kind === "message" ? caption.message.textBody : null).toBe("Screenshot caption"); + }); + + it("separates delivery statuses and filters them from inbound message normalization", () => { + const payload = statusWebhook(); + expect(normalizeWhatsAppWebhook(payload)).toEqual({ + kind: "status", + statuses: [{ + externalChannelId: PHONE_NUMBER_ID, + externalMessageId: "wamid.outbound", + recipientId: SENDER_WA_ID, + status: "delivered", + timestamp: "1783963300", + }], + }); + expect(whatsappChatConnectorProfile.ingress.normalize(payload)).toEqual({}); + }); + + it("builds text and reply requests for the fixed Graph endpoint and inbound sender", () => { + const context = officialContext(); + const request = whatsappChatConnectorProfile.outbound.buildRequest(context); + + expect(request).toMatchObject({ + transport: "http", + url: `https://graph.facebook.com/v23.0/${PHONE_NUMBER_ID}/messages`, + bearerSecretKeys: ["accessToken"], + body: { + messaging_product: "whatsapp", + recipient_type: "individual", + to: SENDER_WA_ID, + context: { message_id: "wamid.inbound-text" }, + type: "text", + text: { preview_url: false, body: "Reply from Code UX" }, + }, + }); + expect(request.url).not.toContain("example.invalid"); + expect((request.body as { to: string }).to).not.toBe(context.binding.externalChannelId); + + const textRequest = whatsappChatConnectorProfile.outbound.buildRequest(officialContext({ replyToExternalMessageId: null })); + expect(textRequest.body).not.toHaveProperty("context"); + }); + + it("sends through the provider-neutral adapter and parses returned wamid values", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + messaging_product: "whatsapp", + contacts: [{ input: SENDER_WA_ID, wa_id: SENDER_WA_ID }], + messages: [{ id: "wamid.outbound-result" }], + }), { status: 200, headers: { "content-type": "application/json" } })); + vi.stubGlobal("fetch", fetchMock); + + const result = await new ConfiguredChatProviderOutboundAdapter().send(officialContext()); + + expect(result).toEqual({ + externalMessageId: "wamid.outbound-result", + responseMetadata: { messagingProduct: "whatsapp", messageCount: 1 }, + }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`https://graph.facebook.com/v23.0/${PHONE_NUMBER_ID}/messages`); + expect(init?.headers).toMatchObject({ authorization: `Bearer ${ACCESS_TOKEN}` }); + expect(JSON.parse(String(init?.body))).toMatchObject({ to: SENDER_WA_ID, messaging_product: "whatsapp" }); + }); + + it("classifies Graph errors and retryable HTTP responses without echoing payload data", () => { + const responseBody = JSON.stringify({ + error: { + message: `Temporary failure for ${SENDER_WA_ID} using ${ACCESS_TOKEN}`, + type: "OAuthException", + code: 130429, + error_subcode: 2494010, + is_transient: true, + }, + }); + const classification = classifyWhatsAppGraphError(400, responseBody); + + expect(classification).toMatchObject({ + retryable: true, + statusCode: 400, + code: 130429, + subcode: 2494010, + type: "OAuthException", + isTransient: true, + }); + expect(classification.message).not.toContain(ACCESS_TOKEN); + expect(classification.message).not.toContain(SENDER_WA_ID); + expect(whatsappChatConnectorProfile.outbound.isRetryableStatus(429)).toBe(true); + expect(whatsappChatConnectorProfile.outbound.isRetryableStatus(503)).toBe(true); + expect(whatsappChatConnectorProfile.outbound.isRetryableStatus(400)).toBe(false); + }); + + it("classifies outbound timeouts as retryable without leaking authorization or recipient data", async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error("The operation timed out")); + vi.stubGlobal("fetch", fetchMock); + + const error = await new ConfiguredChatProviderOutboundAdapter().send(officialContext()).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ChatProviderOutboundAdapterError); + expect((error as ChatProviderOutboundAdapterError).retryable).toBe(true); + expect((error as Error).message).not.toContain(ACCESS_TOKEN); + expect((error as Error).message).not.toContain(SENDER_WA_ID); + }); + + it("verifies the configured phone-number resource with a read-only Graph request", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + id: PHONE_NUMBER_ID, + display_phone_number: "+1 555 765 4321", + verified_name: "Example Business", + quality_rating: "GREEN", + }), { status: 200 })); + + const result = await verifyWhatsAppOfficialConnection(officialSetup(), officialSecrets(), fetchMock); + + expect(result).toEqual({ + valid: true, + issues: [], + retryable: false, + resource: { + id: PHONE_NUMBER_ID, + displayPhoneNumber: "+1 555 765 4321", + verifiedName: "Example Business", + qualityRating: "GREEN", + }, + }); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe( + `https://graph.facebook.com/v23.0/${PHONE_NUMBER_ID}?fields=id,display_phone_number,verified_name,quality_rating`, + ); + expect(init).toMatchObject({ method: "GET", headers: { authorization: `Bearer ${ACCESS_TOKEN}` } }); + expect(init).not.toHaveProperty("body"); + }); + + it("fails closed for invalid official configuration and redacts verification failures", async () => { + const invalid = whatsappChatConnectorProfile.verification.verifyConfiguration("official_api", { + graphApiVersion: "https://example.invalid/v23.0", + phoneNumberId: "123/messages", + }, officialSecrets()); + expect(invalid.valid).toBe(false); + expect(invalid.issues).toContain("Graph API version must use the v{major}.{minor} format."); + expect(invalid.issues).toContain("Phone-number ID must contain digits only."); + + const fetchMock = vi.fn().mockRejectedValue(new Error(`timeout while using ${ACCESS_TOKEN}`)); + const failed = await verifyWhatsAppOfficialConnection(officialSetup(), officialSecrets(), fetchMock); + expect(failed).toMatchObject({ valid: false, retryable: true, resource: null }); + expect(failed.issues.join(" ")).not.toContain(ACCESS_TOKEN); + }); + + it("retains managed and generic webhook outbound bridge behavior", () => { + const managed = whatsappChatConnectorProfile.outbound.buildRequest(contextForMode("managed_bridge", { + bridgeUrl: "https://managed.example.test/send", + })); + const webhook = whatsappChatConnectorProfile.outbound.buildRequest(contextForMode("webhook", { + webhookUrl: "https://webhook.example.test/send", + })); + + expect(managed).toMatchObject({ + url: "https://managed.example.test/send", + bearerSecretKeys: expect.arrayContaining(["bridgeApiKey"]), + body: expect.objectContaining({ replyText: "Reply from Code UX" }), + }); + expect(webhook).toMatchObject({ + url: "https://webhook.example.test/send", + bearerSecretKeys: expect.arrayContaining(["webhookSecret"]), + body: expect.objectContaining({ replyText: "Reply from Code UX" }), + }); + expect(whatsappChatConnectorProfile.outbound.parseResponse('{"messageId":"legacy-message"}')).toMatchObject({ + externalMessageId: "legacy-message", + }); + }); +}); + +function messageWebhook(message: Record): Record { + return { + object: "whatsapp_business_account", + entry: [{ + id: "waba-id", + changes: [{ + field: "messages", + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+1 555 765 4321", + phone_number_id: PHONE_NUMBER_ID, + }, + contacts: [{ profile: { name: "Example Sender" }, wa_id: SENDER_WA_ID }], + messages: [message], + }, + }], + }], + }; +} + +function statusWebhook(): Record { + return { + object: "whatsapp_business_account", + entry: [{ + id: "waba-id", + changes: [{ + field: "messages", + value: { + messaging_product: "whatsapp", + metadata: { phone_number_id: PHONE_NUMBER_ID }, + statuses: [{ + id: "wamid.outbound", + status: "delivered", + timestamp: "1783963300", + recipient_id: SENDER_WA_ID, + }], + }, + }], + }], + }; +} + +function officialSetup(): Record { + return { + graphApiVersion: "v23.0", + phoneNumberId: PHONE_NUMBER_ID, + appId: "1234567890", + businessAccountId: "9876543210", + graphApiHost: "https://example.invalid", + webhookUrl: "https://example.invalid/webhook", + }; +} + +function officialSecrets(): Record { + return { + accessToken: ACCESS_TOKEN, + appSecret: APP_SECRET, + webhookVerifyToken: VERIFY_TOKEN, + }; +} + +function officialContext( + payloadOverrides: Partial = {}, +): ChatConnectorOutboundContext { + return { + connection: connectionForMode("official_api", officialSetup(), officialSecrets()), + binding: binding(), + delivery: delivery(), + correlationId: "correlation-1", + payload: { + providerKind: "whatsapp", + providerConnectionId: "connection-1", + channelId: PHONE_NUMBER_ID, + threadId: "thread-1", + conversationMessageId: "conversation-message-2", + replyText: "Reply from Code UX", + replyToExternalMessageId: "wamid.inbound-text", + metadata: { + inboundPayload: { + externalSender: { id: SENDER_WA_ID, name: "Example Sender" }, + }, + }, + ...payloadOverrides, + }, + }; +} + +function contextForMode( + mode: ChatProviderBridgeMode, + setup: Record, +): ChatConnectorOutboundContext { + const context = officialContext(); + return { + ...context, + connection: connectionForMode(mode, setup, { bridgeApiKey: "bridge-key", webhookSecret: "webhook-secret" }), + }; +} + +function connectionForMode( + bridgeMode: ChatProviderBridgeMode, + setup: Record, + secrets: Record, +): ChatProviderConnectionInternalRecord { + return { + id: "connection-1", + providerKind: "whatsapp", + displayName: "WhatsApp test connection", + bridgeMode, + status: "active", + enabled: true, + setup, + secrets, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }; +} + +function binding(): ChatProviderChannelBindingRecord { + return { + id: "binding-1", + providerConnectionId: "connection-1", + providerKind: "whatsapp", + externalChannelId: PHONE_NUMBER_ID, + externalChannelName: "+1 555 765 4321", + externalChannelMetadata: null, + projectId: "project-1", + agentPresetId: null, + routingHints: null, + enabled: true, + inboundEnabled: true, + outboundEnabled: true, + suppressRichWidgets: true, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }; +} + +function delivery(): ChatProviderMessageDeliveryRecord { + return { + id: "delivery-1", + providerConnectionId: "connection-1", + providerKind: "whatsapp", + channelBindingId: "binding-1", + externalChannelId: PHONE_NUMBER_ID, + externalMessageId: "wamid.inbound-text", + direction: "outbound", + status: "sending", + attemptCount: 1, + lastError: null, + conversationThreadId: "thread-1", + conversationMessageId: "conversation-message-2", + payload: null, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + }; +} From 3e8320adf14a3d4646b241c49035fd1c7b2d0448 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:16:09 +0000 Subject: [PATCH 2/3] fix(task T03): address qa review via codex --- .../architecture/chat-connectors/whatsapp.md | 4 +- docs/settings/chat-connectors/whatsapp.md | 6 +- .../chat-connectors/providers/whatsapp.ts | 27 ++- src/domain/chat-connectors/types.ts | 32 +++- src/server/chat-provider-ingress-routes.ts | 42 +++++ src/services/chat-provider-adapters.ts | 6 +- src/services/chat-provider-ingress-service.ts | 18 +- src/services/chat-provider-security.ts | 20 ++- .../domain/chat-connectors/whatsapp.test.ts | 21 +++ .../chat-provider-ingress-routes.test.ts | 163 ++++++++++++++++++ 10 files changed, 314 insertions(+), 25 deletions(-) diff --git a/docs-web/architecture/chat-connectors/whatsapp.md b/docs-web/architecture/chat-connectors/whatsapp.md index 45b412feb6..8c50d9ed4a 100644 --- a/docs-web/architecture/chat-connectors/whatsapp.md +++ b/docs-web/architecture/chat-connectors/whatsapp.md @@ -4,9 +4,9 @@ WhatsApp implements `managed_bridge`, `webhook`, and direct Meta Cloud API `offi The official profile fixes Graph traffic to `https://graph.facebook.com/{version}/{phoneNumberId}` and validates both path components before building a request. Outbound messages use the original inbound sender WhatsApp ID as `to`, while `metadata.phone_number_id` remains the channel binding. Replies map the inbound `wamid` to `context.message_id`, and successful response `wamid` values become outbound delivery IDs. -Webhook hooks implement Meta's `hub.mode`, `hub.verify_token`, and `hub.challenge` GET handshake and verify POST `X-Hub-Signature-256` values over exact raw bytes with the app secret. Message and status payloads are discriminated before normalization so delivery receipts never become inbound conversation messages. Text and media-caption message bodies are supported. +Webhook hooks implement Meta's `hub.mode`, `hub.verify_token`, and `hub.challenge` GET handshake and verify POST `X-Hub-Signature-256` values over exact raw bytes with the app secret. Official authentication explicitly opts out of the shared timestamp requirement because Meta does not send one; all existing profiles retain timestamp enforcement by default. Message and status payloads are discriminated before normalization, and status-only callbacks return an `ignored` acknowledgement without creating delivery or conversation records. Text and media-caption message bodies are supported. -The profile exposes read-only verification of the configured phone-number resource. It uses a bounded timeout, classifies retryable HTTP and structured Meta errors, and returns bounded metadata without access tokens or recipient values. Normal verification never sends a message; the separate opted-in Meta test-number path owns any future send-based test. +The profile exposes read-only verification of the configured phone-number resource. It uses a bounded timeout and supplies the shared outbound facade with a sanitized classifier for non-2xx HTTP responses and structured Meta error codes. Returned errors and verification metadata omit access tokens and recipient values. Normal verification never sends a message; the separate opted-in Meta test-number path owns any future send-based test. Credentials (`accessToken`, `appSecret`, and `webhookVerifyToken`) remain secret-schema fields and are not exposed through public connection records. Official mode cannot use a custom Graph host or silently fall back to the generic webhook URL. diff --git a/docs/settings/chat-connectors/whatsapp.md b/docs/settings/chat-connectors/whatsapp.md index a6c7097e84..ffe57cd5f6 100644 --- a/docs/settings/chat-connectors/whatsapp.md +++ b/docs/settings/chat-connectors/whatsapp.md @@ -16,9 +16,9 @@ Configure `accessToken`, `appSecret`, and `webhookVerifyToken` as secret fields. For Meta's GET subscription check, the profile accepts only `hub.mode=subscribe` with a constant-time match against the configured `webhookVerifyToken`, then returns `hub.challenge`. Other modes, missing values, and token mismatches fail closed. -For POST callbacks, validate `X-Hub-Signature-256` as an HMAC-SHA256 over the exact raw request bytes with `appSecret`. Parsing or reserializing JSON before verification changes the signed bytes and must fail validation. +For POST callbacks, the runtime validates `X-Hub-Signature-256` as an HMAC-SHA256 over the exact raw request bytes with `appSecret`. Meta does not supply a request timestamp, so official mode explicitly authenticates the raw body without one; timestamp freshness remains required for existing managed and generic webhook authentication modes. Parsing or reserializing JSON before verification changes the signed bytes and must fail validation. -Message callbacks and delivery/status callbacks are normalized separately. Only message callbacks enter the inbound conversation flow. The business `metadata.phone_number_id` is the external channel, the inbound message `wamid` is the idempotency key, and `messages[].from` is retained as the sender and future outbound recipient. Text bodies and image, video, or document captions are supported. +Message callbacks and delivery/status callbacks are normalized separately. Only message callbacks enter the inbound conversation flow; status-only callbacks receive a successful `ignored` acknowledgement without creating a delivery or conversation message. The business `metadata.phone_number_id` is the external channel, the inbound message `wamid` is the idempotency key, and `messages[].from` is retained as the sender and future outbound recipient. Text bodies and image, video, or document captions are supported. ## Outbound replies and verification @@ -28,7 +28,7 @@ Official text and reply requests are sent only to: https://graph.facebook.com/{graphApiVersion}/{phoneNumberId}/messages ``` -Requests include `messaging_product: whatsapp`; replies also include `context.message_id`. The recipient is the original sender WhatsApp ID, never the business phone-number channel ID. Successful `messages[].id` values are retained as outbound `wamid` delivery IDs. Retryable HTTP and Meta error classifications are bounded and do not echo tokens or recipient data. +Requests include `messaging_product: whatsapp`; replies also include `context.message_id`. The recipient is the original sender WhatsApp ID, never the business phone-number channel ID. Successful `messages[].id` values are retained as outbound `wamid` delivery IDs. Non-2xx Graph responses are classified through sanitized status, error-code, subcode, and transient metadata so structured Meta throttling errors can retry without echoing tokens or recipient data. Connection verification is read-only. It performs a GET for the configured test or registered phone-number resource and checks that Meta returns the same ID. It never sends a WhatsApp message. Send-based testing remains reserved for the separately opted-in Meta test-number workflow. diff --git a/src/domain/chat-connectors/providers/whatsapp.ts b/src/domain/chat-connectors/providers/whatsapp.ts index 26b1d687fd..7abf675331 100644 --- a/src/domain/chat-connectors/providers/whatsapp.ts +++ b/src/domain/chat-connectors/providers/whatsapp.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from "node:crypto"; +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import type { ChatProviderBridgeMode } from "../../../contracts/chat-provider-types.js"; import { redactText } from "../../../shared/security/redaction.js"; import type { @@ -461,9 +461,9 @@ function sanitizeNetworkError(error: unknown, sensitiveValues: readonly string[] } function constantTimeEquals(left: string, right: string): boolean { - const leftBuffer = Buffer.from(left); - const rightBuffer = Buffer.from(right); - return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); + const leftDigest = createHash("sha256").update(left).digest(); + const rightDigest = createHash("sha256").update(right).digest(); + return timingSafeEqual(leftDigest, rightDigest); } export const whatsappChatConnectorProfile: WhatsAppChatConnectorProfile = { @@ -490,11 +490,27 @@ export const whatsappChatConnectorProfile: WhatsAppChatConnectorProfile = { secretKeys: ["appSecret"], signatureHeaders: ["x-hub-signature-256"], timestampHeaders: [], + timestampRequirement: "none", signatureBases: ({ rawBody }) => [rawBody], }, }, - handshake: { type: "none" }, + handshake: { + type: "challenge", + modes: ["official_api"], + handle: ({ query, secrets }) => { + const configuredToken = typeof secrets?.webhookVerifyToken === "string" + ? secrets.webhookVerifyToken + : ""; + const result = verifyWhatsAppWebhookChallenge({ ...query }, configuredToken); + return { + statusCode: result.statusCode, + headers: { "content-type": "text/plain; charset=utf-8" }, + body: result.body, + }; + }, + }, acknowledgement: { statusCode: 200, headers: { "content-type": "application/json" }, body: null }, + classify: (body) => normalizeWhatsAppWebhook(body).kind === "status" ? "ignored" : "message", normalize: (body) => { const normalized = normalizeWhatsAppWebhook(body); return normalized.kind === "message" ? normalized.message : {}; @@ -526,6 +542,7 @@ export const whatsappChatConnectorProfile: WhatsAppChatConnectorProfile = { }, parseResponse: parseWhatsAppOutboundResponse, isRetryableStatus: isLegacyRetryableHttpStatus, + classifyError: classifyWhatsAppGraphError, }, verification: { strategy: "configuration_and_live", diff --git a/src/domain/chat-connectors/types.ts b/src/domain/chat-connectors/types.ts index 3ccaab565a..dd9d95c16d 100644 --- a/src/domain/chat-connectors/types.ts +++ b/src/domain/chat-connectors/types.ts @@ -29,6 +29,7 @@ export interface ChatConnectorBearerAuthentication { secretKeys: readonly string[]; tokenHeaders: readonly string[]; timestampHeaders: readonly string[]; + timestampRequirement?: "required" | "none"; } export interface ChatConnectorHmacAuthentication { @@ -36,6 +37,7 @@ export interface ChatConnectorHmacAuthentication { secretKeys: readonly string[]; signatureHeaders: readonly string[]; timestampHeaders: readonly string[]; + timestampRequirement?: "required" | "none"; signatureBases(input: ChatConnectorAuthenticationInput): readonly string[]; } @@ -43,12 +45,24 @@ export type ChatConnectorIngressAuthentication = | ChatConnectorBearerAuthentication | ChatConnectorHmacAuthentication; -export interface ChatConnectorHandshake { - type: "none" | "challenge"; - challengeField?: string; - responseField?: string; +export interface ChatConnectorHandshakeResult { + statusCode: number; + headers: Readonly>; + body: string | null; } +export type ChatConnectorHandshake = + | { type: "none" } + | { + type: "challenge"; + modes: readonly ChatProviderBridgeMode[]; + handle(input: { + query: Readonly>; + setup: Readonly>; + secrets: Readonly> | null; + }): ChatConnectorHandshakeResult; + }; + export interface ChatConnectorAcknowledgement { statusCode: number; headers: Readonly>; @@ -107,6 +121,11 @@ export interface ChatConnectorOutboundResult { responseMetadata?: Record; } +export interface ChatConnectorOutboundErrorClassification { + message: string; + retryable: boolean; +} + export interface ChatConnectorVerificationResult { valid: boolean; issues: readonly string[]; @@ -120,6 +139,7 @@ export interface ChatConnectorProfile { authentication: Readonly>>; handshake: ChatConnectorHandshake; acknowledgement: ChatConnectorAcknowledgement; + classify?(payload: Record): "message" | "ignored"; normalize(payload: Record): PartialNormalizedChatConnectorInbound; }; identity: { @@ -132,6 +152,10 @@ export interface ChatConnectorProfile { buildRequest(context: ChatConnectorOutboundContext): ChatConnectorOutboundRequest; parseResponse(responseBody: string): ChatConnectorOutboundResult; isRetryableStatus(statusCode: number): boolean; + classifyError?( + statusCode: number, + responseBody: string, + ): ChatConnectorOutboundErrorClassification; }; verification: { strategy: "configuration" | "configuration_and_live"; diff --git a/src/server/chat-provider-ingress-routes.ts b/src/server/chat-provider-ingress-routes.ts index 9c75df0c43..5c6e9b16a5 100644 --- a/src/server/chat-provider-ingress-routes.ts +++ b/src/server/chat-provider-ingress-routes.ts @@ -4,6 +4,7 @@ import { asyncRoute } from "./route-utils.js"; import { HttpRouteError } from "./http-errors.js"; import { requireTrimmedString } from "./request-parsers.js"; import { ChatProviderIngressSecurity, ChatProviderIngressSecurityError } from "../services/chat-provider-security.js"; +import { getChatConnectorProfileForMode } from "../domain/chat-connectors/registry.js"; const defaultSecurityVerifier = new ChatProviderIngressSecurity(); @@ -50,8 +51,48 @@ export function registerChatProviderIngressRoutes(router: Express, deps: Dashboa res.status(statusCode).json(result); }); + const handshakeHandler = asyncRoute(async (req, res) => { + const providerConnectionId = requireTrimmedString( + req.params.providerConnectionId ?? req.params.connectionId, + "providerConnectionId", + ); + const connection = deps.chatProviderRepository!.getConnectionInternal(providerConnectionId); + if (!connection) { + throw new HttpRouteError(404, "Chat provider connection not found."); + } + if (!connection.enabled || connection.status !== "active") { + throw new HttpRouteError(403, "Chat provider connection is not enabled."); + } + + const profile = getChatConnectorProfileForMode(connection.providerKind, connection.bridgeMode); + const handshake = profile.ingress.handshake; + if (handshake.type !== "challenge" || !handshake.modes.includes(connection.bridgeMode)) { + throw new HttpRouteError(404, "Chat provider handshake is not configured for this connection."); + } + + const result = handshake.handle({ + query: req.query as Record, + setup: connection.setup, + secrets: connection.secrets, + }); + for (const [name, value] of Object.entries(result.headers)) { + res.setHeader(name, value); + } + if (result.statusCode !== 200) { + deps.logger?.warn("Rejected chat provider webhook handshake", { + logPurpose: "security", + providerConnectionId, + providerKind: connection.providerKind, + statusCode: result.statusCode, + }); + } + res.status(result.statusCode).send(result.body ?? ""); + }); + router.post("/api/chat-providers/ingress/:providerConnectionId", handler); router.post("/api/chat-providers/connections/:connectionId/ingress", handler); + router.get("/api/chat-providers/ingress/:providerConnectionId", handshakeHandler); + router.get("/api/chat-providers/connections/:connectionId/ingress", handshakeHandler); } function buildRequestBodyForSignature(req: Request): string { @@ -70,6 +111,7 @@ function statusCodeForIngressResult(status: string): number { case "accepted": return 202; case "duplicate": + case "ignored": return 200; case "ambiguous": return 409; diff --git a/src/services/chat-provider-adapters.ts b/src/services/chat-provider-adapters.ts index bd9ed3fe3b..d2c210e6d3 100644 --- a/src/services/chat-provider-adapters.ts +++ b/src/services/chat-provider-adapters.ts @@ -110,9 +110,11 @@ export class ConfiguredChatProviderOutboundAdapter implements ChatProviderOutbou const responseText = await response.text().catch(() => ""); if (!response.ok) { + const classification = profile.outbound.classifyError?.(response.status, responseText); throw new ChatProviderOutboundAdapterError( - `${context.connection.bridgeMode} bridge returned HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 500)}` : ""}`, - profile.outbound.isRetryableStatus(response.status), + classification?.message + ?? `${context.connection.bridgeMode} bridge returned HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 500)}` : ""}`, + classification?.retryable ?? profile.outbound.isRetryableStatus(response.status), response.status, ); } diff --git a/src/services/chat-provider-ingress-service.ts b/src/services/chat-provider-ingress-service.ts index ecf094cf0c..8791d1cbbf 100644 --- a/src/services/chat-provider-ingress-service.ts +++ b/src/services/chat-provider-ingress-service.ts @@ -34,6 +34,7 @@ export interface NormalizedChatProviderInboundMessage { export type ChatProviderIngressStatus = | "accepted" | "duplicate" + | "ignored" | "ambiguous" | "unbound" | "rejected"; @@ -90,7 +91,22 @@ export class ChatProviderIngressService { }; } - const normalized = normalizeInboundPayload(connection, input.payload); + const body = requireRecord(input.payload, "payload"); + const profile = getChatConnectorProfileForMode(connection.providerKind, connection.bridgeMode); + if (profile.ingress.classify?.(body) === "ignored") { + this.log("info", "Ignored non-message chat provider ingress", { + providerConnectionId: connection.id, + providerKind: connection.providerKind, + }); + return { + status: "ignored", + message: "Non-message chat provider event ignored.", + providerConnectionId: connection.id, + providerKind: connection.providerKind, + }; + } + + const normalized = normalizeInboundPayload(connection, body); const existing = this.deps.chatProviderRepository.findInboundDelivery(connection.id, normalized.externalMessageId); if (existing) { this.log("info", "Duplicate chat provider ingress ignored", { diff --git a/src/services/chat-provider-security.ts b/src/services/chat-provider-security.ts index e42b4b0b4a..b10a244f1f 100644 --- a/src/services/chat-provider-security.ts +++ b/src/services/chat-provider-security.ts @@ -46,7 +46,9 @@ export class ChatProviderIngressSecurity { throw new ChatProviderIngressSecurityError("unsupported_authentication", "Unsupported chat provider authentication mode.", 403); } const nowMs = (request.now ?? new Date()).getTime(); - const timestamp = this.requireFreshTimestamp(request.headers, authentication.timestampHeaders, nowMs); + const timestamp = authentication.timestampRequirement === "none" + ? null + : this.requireFreshTimestamp(request.headers, authentication.timestampHeaders, nowMs); if (authentication.type === "hmac_sha256") { const hmacSecret = firstConfiguredSecret(connection.secrets, authentication.secretKeys); @@ -118,7 +120,7 @@ export class ChatProviderIngressSecurity { private verifyHmacSignature(input: { connectionId: string; signature: string; - timestamp: { raw: string; value: number }; + timestamp: { raw: string; value: number } | null; rawBody: string; secret: string; nowMs: number; @@ -130,18 +132,20 @@ export class ChatProviderIngressSecurity { } const candidates = input.authentication - .signatureBases({ timestamp: input.timestamp.raw, rawBody: input.rawBody }) + .signatureBases({ timestamp: input.timestamp?.raw ?? "", rawBody: input.rawBody }) .map((base) => createHmac("sha256", input.secret).update(base).digest("hex")); const valid = candidates.some((candidate) => constantTimeEquals(candidate, normalizedSignature)); if (!valid) { throw new ChatProviderIngressSecurityError("signature_mismatch", "Invalid chat provider ingress signature.", 401); } - this.preventReplay({ - connectionId: input.connectionId, - key: `hmac:${input.timestamp.value}:${normalizedSignature}`, - nowMs: input.nowMs, - }); + if (input.timestamp) { + this.preventReplay({ + connectionId: input.connectionId, + key: `hmac:${input.timestamp.value}:${normalizedSignature}`, + nowMs: input.nowMs, + }); + } } private preventReplay(input: { connectionId: string; key: string; nowMs: number }): void { diff --git a/tests/backend/domain/chat-connectors/whatsapp.test.ts b/tests/backend/domain/chat-connectors/whatsapp.test.ts index c283bf656b..8574f90eac 100644 --- a/tests/backend/domain/chat-connectors/whatsapp.test.ts +++ b/tests/backend/domain/chat-connectors/whatsapp.test.ts @@ -210,6 +210,27 @@ describe("WhatsApp Cloud API profile", () => { expect(whatsappChatConnectorProfile.outbound.isRetryableStatus(400)).toBe(false); }); + it("uses sanitized structured Graph errors for non-2xx adapter responses", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + error: { + message: `Rate limited ${SENDER_WA_ID} with ${ACCESS_TOKEN}`, + type: "OAuthException", + code: 130429, + error_subcode: 2494010, + is_transient: false, + }, + }), { status: 400 })); + vi.stubGlobal("fetch", fetchMock); + + const error = await new ConfiguredChatProviderOutboundAdapter().send(officialContext()).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ChatProviderOutboundAdapterError); + expect(error).toMatchObject({ retryable: true, statusCode: 400 }); + expect((error as Error).message).toBe("Meta Graph API request failed (HTTP 400, code 130429, subcode 2494010)."); + expect((error as Error).message).not.toContain(ACCESS_TOKEN); + expect((error as Error).message).not.toContain(SENDER_WA_ID); + }); + it("classifies outbound timeouts as retryable without leaking authorization or recipient data", async () => { const fetchMock = vi.fn().mockRejectedValue(new Error("The operation timed out")); vi.stubGlobal("fetch", fetchMock); diff --git a/tests/backend/server/chat-provider-ingress-routes.test.ts b/tests/backend/server/chat-provider-ingress-routes.test.ts index 14976436f4..44408c7530 100644 --- a/tests/backend/server/chat-provider-ingress-routes.test.ts +++ b/tests/backend/server/chat-provider-ingress-routes.test.ts @@ -142,6 +142,81 @@ describe("chat provider ingress routes", () => { }); }); + it("handles the official WhatsApp subscription challenge with 200 and 403 responses", async () => { + const context = await startTestServer(); + const connection = createOfficialWhatsAppConnection(context); + const endpoint = `${context.baseUrl}/api/chat-providers/ingress/${connection.id}`; + + const accepted = await fetch(`${endpoint}?${new URLSearchParams({ + "hub.mode": "subscribe", + "hub.verify_token": "whatsapp-verify-token", + "hub.challenge": "123456789", + })}`); + expect(accepted.status).toBe(200); + expect(accepted.headers.get("content-type")).toContain("text/plain"); + expect(await accepted.text()).toBe("123456789"); + + const rejected = await fetch(`${endpoint}?${new URLSearchParams({ + "hub.mode": "subscribe", + "hub.verify_token": "wrong-token", + "hub.challenge": "123456789", + })}`); + expect(rejected.status).toBe(403); + expect(await rejected.text()).toBe("Forbidden"); + expect(context.postMessage).not.toHaveBeenCalled(); + }); + + it("authenticates official WhatsApp POST callbacks from exact raw bytes without a timestamp", async () => { + const context = await startTestServer(); + const project = createProject(context, "whatsapp-raw-signature"); + const connection = createOfficialWhatsAppConnection(context); + context.chatProviderRepository.createChannelBinding({ + providerConnectionId: connection.id, + externalChannelId: "109876543210987", + externalChannelName: "WhatsApp business number", + projectId: project.id, + }); + const payload = whatsappMessageWebhook(); + const rawBody = `${JSON.stringify(payload, null, 2)}\n`; + const signature = `sha256=${createHmac("sha256", "whatsapp-app-secret").update(rawBody).digest("hex")}`; + + const reserialized = await postRawIngress(context, connection.id, JSON.stringify(payload), { + "x-hub-signature-256": signature, + }); + expect(reserialized.status).toBe(401); + expect(context.postMessage).not.toHaveBeenCalled(); + + const accepted = await postRawIngress(context, connection.id, rawBody, { + "x-hub-signature-256": signature, + }); + expect(accepted.status).toBe(202); + expect(await accepted.json()).toMatchObject({ + status: "accepted", + providerKind: "whatsapp", + delivery: expect.objectContaining({ externalMessageId: "wamid.route-inbound" }), + }); + expect(context.postMessage).toHaveBeenCalledTimes(1); + }); + + it("acknowledges official WhatsApp status callbacks without creating deliveries or messages", async () => { + const context = await startTestServer(); + const connection = createOfficialWhatsAppConnection(context); + const rawBody = JSON.stringify(whatsappStatusWebhook()); + const signature = `sha256=${createHmac("sha256", "whatsapp-app-secret").update(rawBody).digest("hex")}`; + + const response = await postRawIngress(context, connection.id, rawBody, { + "x-hub-signature-256": signature, + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + status: "ignored", + providerKind: "whatsapp", + }); + expect(context.chatProviderRepository.listDeliveries({ providerConnectionId: connection.id })).toEqual([]); + expect(context.postMessage).not.toHaveBeenCalled(); + }); + it("rejects unauthenticated and stale bridge requests without creating messages", async () => { const context = await startTestServer(); const project = createProject(context, "rejected-ingress"); @@ -168,6 +243,11 @@ describe("chat provider ingress routes", () => { }); expect(missingAuth.status).toBe(401); + const missingTimestamp = await postIngress(context, connection.id, payload, { + Authorization: "Bearer bridge-token", + }); + expect(missingTimestamp.status).toBe(401); + const stale = await postIngress(context, connection.id, payload, { Authorization: "Bearer bridge-token", "x-code-ux-timestamp": "2020-01-01T00:00:00.000Z", @@ -292,3 +372,86 @@ function postIngress( body: JSON.stringify(body), }); } + +function postRawIngress( + context: TestServerContext, + providerConnectionId: string, + rawBody: string, + headers: Record, +): Promise { + return fetch(`${context.baseUrl}/api/chat-providers/ingress/${providerConnectionId}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: rawBody, + }); +} + +function createOfficialWhatsAppConnection(context: TestServerContext) { + return context.chatProviderRepository.createConnection({ + providerKind: "whatsapp", + displayName: "WhatsApp official connection", + bridgeMode: "official_api", + status: "active", + setup: { + graphApiVersion: "v23.0", + phoneNumberId: "109876543210987", + }, + secrets: { + accessToken: "whatsapp-access-token", + appSecret: "whatsapp-app-secret", + webhookVerifyToken: "whatsapp-verify-token", + }, + }); +} + +function whatsappMessageWebhook(): Record { + return { + object: "whatsapp_business_account", + entry: [{ + id: "waba-route", + changes: [{ + field: "messages", + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "+1 555 765 4321", + phone_number_id: "109876543210987", + }, + contacts: [{ profile: { name: "Example Sender" }, wa_id: "15551234567" }], + messages: [{ + from: "15551234567", + id: "wamid.route-inbound", + timestamp: "1783963200", + type: "text", + text: { body: "Route this exact payload" }, + }], + }, + }], + }], + }; +} + +function whatsappStatusWebhook(): Record { + return { + object: "whatsapp_business_account", + entry: [{ + id: "waba-route", + changes: [{ + field: "messages", + value: { + messaging_product: "whatsapp", + metadata: { phone_number_id: "109876543210987" }, + statuses: [{ + id: "wamid.outbound-status", + status: "delivered", + timestamp: "1783963300", + recipient_id: "15551234567", + }], + }, + }], + }], + }; +} From b9971e23e997a025b65fba3f722926155d3964c4 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:27:38 +0000 Subject: [PATCH 3/3] fix(task T03): address qa review via codex --- .../architecture/chat-connectors/whatsapp.md | 2 +- docs/settings/chat-connectors/whatsapp.md | 4 +- .../chat-connectors/providers/whatsapp.ts | 27 ++++++++- src/domain/chat-connectors/types.ts | 14 ++++- src/services/chat-provider-adapters.ts | 25 ++++++-- .../domain/chat-connectors/whatsapp.test.ts | 60 +++++++++++++++++++ 6 files changed, 120 insertions(+), 12 deletions(-) diff --git a/docs-web/architecture/chat-connectors/whatsapp.md b/docs-web/architecture/chat-connectors/whatsapp.md index 8c50d9ed4a..1a10316cfc 100644 --- a/docs-web/architecture/chat-connectors/whatsapp.md +++ b/docs-web/architecture/chat-connectors/whatsapp.md @@ -6,7 +6,7 @@ The official profile fixes Graph traffic to `https://graph.facebook.com/{version Webhook hooks implement Meta's `hub.mode`, `hub.verify_token`, and `hub.challenge` GET handshake and verify POST `X-Hub-Signature-256` values over exact raw bytes with the app secret. Official authentication explicitly opts out of the shared timestamp requirement because Meta does not send one; all existing profiles retain timestamp enforcement by default. Message and status payloads are discriminated before normalization, and status-only callbacks return an `ignored` acknowledgement without creating delivery or conversation records. Text and media-caption message bodies are supported. -The profile exposes read-only verification of the configured phone-number resource. It uses a bounded timeout and supplies the shared outbound facade with a sanitized classifier for non-2xx HTTP responses and structured Meta error codes. Returned errors and verification metadata omit access tokens and recipient values. Normal verification never sends a message; the separate opted-in Meta test-number path owns any future send-based test. +The profile exposes read-only verification of the configured phone-number resource. It uses a bounded timeout and supplies the shared outbound facade with a mode-aware, sanitized classifier for structured Meta error codes, including error envelopes carried by HTTP 200 responses. Response parsing receives the active bridge mode, HTTP status, and headers: only `official_api` interprets Graph envelopes, while managed and webhook modes retain legacy parsing and raw non-2xx error behavior. Returned official errors and verification metadata omit access tokens and recipient values. Normal verification never sends a message; the separate opted-in Meta test-number path owns any future send-based test. Credentials (`accessToken`, `appSecret`, and `webhookVerifyToken`) remain secret-schema fields and are not exposed through public connection records. Official mode cannot use a custom Graph host or silently fall back to the generic webhook URL. diff --git a/docs/settings/chat-connectors/whatsapp.md b/docs/settings/chat-connectors/whatsapp.md index ffe57cd5f6..70c6061248 100644 --- a/docs/settings/chat-connectors/whatsapp.md +++ b/docs/settings/chat-connectors/whatsapp.md @@ -28,12 +28,12 @@ Official text and reply requests are sent only to: https://graph.facebook.com/{graphApiVersion}/{phoneNumberId}/messages ``` -Requests include `messaging_product: whatsapp`; replies also include `context.message_id`. The recipient is the original sender WhatsApp ID, never the business phone-number channel ID. Successful `messages[].id` values are retained as outbound `wamid` delivery IDs. Non-2xx Graph responses are classified through sanitized status, error-code, subcode, and transient metadata so structured Meta throttling errors can retry without echoing tokens or recipient data. +Requests include `messaging_product: whatsapp`; replies also include `context.message_id`. The recipient is the original sender WhatsApp ID, never the business phone-number channel ID. Successful `messages[].id` values are retained as outbound `wamid` delivery IDs. Official responses are classified through sanitized status, error-code, subcode, and transient metadata before generic HTTP handling, including Graph error envelopes returned with HTTP 200. This preserves typed retryability without echoing tokens or recipient data. Connection verification is read-only. It performs a GET for the configured test or registered phone-number resource and checks that Meta returns the same ID. It never sends a WhatsApp message. Send-based testing remains reserved for the separately opted-in Meta test-number workflow. ## Legacy compatibility -Managed setup still uses `pluginName`, optional `workspaceId`, and `bridgeApiKey`. Generic webhook setup still uses `webhookUrl`, optional `verifyTokenName`, `webhookSecret`, and optional `verifyToken`. Their URL fallback, credential lookup, payload, response parsing, and stored record meanings are unchanged. +Managed setup still uses `pluginName`, optional `workspaceId`, and `bridgeApiKey`. Generic webhook setup still uses `webhookUrl`, optional `verifyTokenName`, `webhookSecret`, and optional `verifyToken`. Their URL fallback, credential lookup, payload, response parsing, raw non-2xx error detail, HTTP-status retry rules, and stored record meanings are unchanged. Meta-shaped envelopes are interpreted as Graph responses only in `official_api` mode. Official references: [WhatsApp Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api), [Meta Webhooks](https://developers.facebook.com/docs/graph-api/webhooks/getting-started), and [Meta's WhatsApp Business Platform Postman collection](https://www.postman.com/meta/whatsapp-business-platform/overview). diff --git a/src/domain/chat-connectors/providers/whatsapp.ts b/src/domain/chat-connectors/providers/whatsapp.ts index 7abf675331..433f1355b5 100644 --- a/src/domain/chat-connectors/providers/whatsapp.ts +++ b/src/domain/chat-connectors/providers/whatsapp.ts @@ -2,7 +2,9 @@ import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import type { ChatProviderBridgeMode } from "../../../contracts/chat-provider-types.js"; import { redactText } from "../../../shared/security/redaction.js"; import type { + ChatConnectorOutboundErrorClassification, ChatConnectorOutboundContext, + ChatConnectorOutboundResponseContext, ChatConnectorOutboundResult, ChatConnectorProfile, ChatConnectorVerificationResult, @@ -375,7 +377,28 @@ function resolveInboundSenderWhatsAppId(context: ChatConnectorOutboundContext): return recipient; } -function parseWhatsAppOutboundResponse(responseBody: string): ChatConnectorOutboundResult { +function classifyWhatsAppOutboundError( + statusCode: number, + responseBody: string, + context?: ChatConnectorOutboundResponseContext, +): ChatConnectorOutboundErrorClassification | null { + if (context?.bridgeMode !== "official_api") { + return null; + } + const hasStructuredError = readRecord(parseJsonRecord(responseBody)?.error) !== null; + if (statusCode >= 200 && statusCode < 300 && !hasStructuredError) { + return null; + } + return classifyWhatsAppGraphError(statusCode, responseBody); +} + +function parseWhatsAppOutboundResponse( + responseBody: string, + context?: ChatConnectorOutboundResponseContext, +): ChatConnectorOutboundResult { + if (context?.bridgeMode !== "official_api") { + return parseLegacyOutboundResponse(responseBody); + } const payload = parseJsonRecord(responseBody); if (!payload) { return parseLegacyOutboundResponse(responseBody); @@ -542,7 +565,7 @@ export const whatsappChatConnectorProfile: WhatsAppChatConnectorProfile = { }, parseResponse: parseWhatsAppOutboundResponse, isRetryableStatus: isLegacyRetryableHttpStatus, - classifyError: classifyWhatsAppGraphError, + classifyError: classifyWhatsAppOutboundError, }, verification: { strategy: "configuration_and_live", diff --git a/src/domain/chat-connectors/types.ts b/src/domain/chat-connectors/types.ts index dd9d95c16d..24b2207fcf 100644 --- a/src/domain/chat-connectors/types.ts +++ b/src/domain/chat-connectors/types.ts @@ -126,6 +126,12 @@ export interface ChatConnectorOutboundErrorClassification { retryable: boolean; } +export interface ChatConnectorOutboundResponseContext { + bridgeMode: ChatProviderBridgeMode; + statusCode: number; + headers: Readonly>; +} + export interface ChatConnectorVerificationResult { valid: boolean; issues: readonly string[]; @@ -150,12 +156,16 @@ export interface ChatConnectorProfile { }; outbound: { buildRequest(context: ChatConnectorOutboundContext): ChatConnectorOutboundRequest; - parseResponse(responseBody: string): ChatConnectorOutboundResult; + parseResponse( + responseBody: string, + context?: ChatConnectorOutboundResponseContext, + ): ChatConnectorOutboundResult; isRetryableStatus(statusCode: number): boolean; classifyError?( statusCode: number, responseBody: string, - ): ChatConnectorOutboundErrorClassification; + context?: ChatConnectorOutboundResponseContext, + ): ChatConnectorOutboundErrorClassification | null; }; verification: { strategy: "configuration" | "configuration_and_live"; diff --git a/src/services/chat-provider-adapters.ts b/src/services/chat-provider-adapters.ts index d2c210e6d3..00f5a5af0e 100644 --- a/src/services/chat-provider-adapters.ts +++ b/src/services/chat-provider-adapters.ts @@ -109,17 +109,32 @@ export class ConfiguredChatProviderOutboundAdapter implements ChatProviderOutbou } const responseText = await response.text().catch(() => ""); + const responseContext = { + bridgeMode: context.connection.bridgeMode, + statusCode: response.status, + headers: Object.fromEntries(response.headers.entries()), + } as const; + const classification = profile.outbound.classifyError?.( + response.status, + responseText, + responseContext, + ); + if (classification) { + throw new ChatProviderOutboundAdapterError( + classification.message, + classification.retryable, + response.status, + ); + } if (!response.ok) { - const classification = profile.outbound.classifyError?.(response.status, responseText); throw new ChatProviderOutboundAdapterError( - classification?.message - ?? `${context.connection.bridgeMode} bridge returned HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 500)}` : ""}`, - classification?.retryable ?? profile.outbound.isRetryableStatus(response.status), + `${context.connection.bridgeMode} bridge returned HTTP ${response.status}${responseText ? `: ${responseText.slice(0, 500)}` : ""}`, + profile.outbound.isRetryableStatus(response.status), response.status, ); } - return profile.outbound.parseResponse(responseText); + return profile.outbound.parseResponse(responseText, responseContext); } private async sendNative( diff --git a/tests/backend/domain/chat-connectors/whatsapp.test.ts b/tests/backend/domain/chat-connectors/whatsapp.test.ts index 8574f90eac..0b485076cf 100644 --- a/tests/backend/domain/chat-connectors/whatsapp.test.ts +++ b/tests/backend/domain/chat-connectors/whatsapp.test.ts @@ -231,6 +231,66 @@ describe("WhatsApp Cloud API profile", () => { expect((error as Error).message).not.toContain(SENDER_WA_ID); }); + it.each([ + { + label: "invalid authentication", + error: { message: `Invalid ${ACCESS_TOKEN} for ${SENDER_WA_ID}`, type: "OAuthException", code: 190 }, + retryable: false, + expectedMessage: "Meta Graph API request failed (HTTP 200, code 190).", + }, + { + label: "transient Graph failure", + error: { message: `Retry ${SENDER_WA_ID}`, type: "OAuthException", code: 2, is_transient: true }, + retryable: true, + expectedMessage: "Meta Graph API request failed (HTTP 200, code 2).", + }, + ])("preserves typed retryability for HTTP-200 $label envelopes", async ({ error: graphError, retryable, expectedMessage }) => { + const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: graphError }), { + status: 200, + headers: { "x-meta-request-id": "request-1" }, + })); + vi.stubGlobal("fetch", fetchMock); + + const error = await new ConfiguredChatProviderOutboundAdapter().send(officialContext()).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ChatProviderOutboundAdapterError); + expect(error).toMatchObject({ retryable, statusCode: 200 }); + expect((error as Error).message).toBe(expectedMessage); + expect((error as Error).message).not.toContain(ACCESS_TOKEN); + expect((error as Error).message).not.toContain(SENDER_WA_ID); + }); + + it.each([ + ["managed_bridge", { bridgeUrl: "https://managed.example.test/send" }], + ["webhook", { webhookUrl: "https://webhook.example.test/send" }], + ] as const)("keeps HTTP-200 Meta-shaped envelopes on the %s legacy parser", async (mode, setup) => { + const envelope = { + error: { message: "Legacy bridge metadata", code: 130429, is_transient: true }, + messageId: `legacy-${mode}`, + }; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(envelope), { status: 200 }))); + + const result = await new ConfiguredChatProviderOutboundAdapter().send(contextForMode(mode, setup)); + + expect(result).toMatchObject({ + externalMessageId: `legacy-${mode}`, + responseMetadata: envelope, + }); + }); + + it.each([ + ["managed_bridge", { bridgeUrl: "https://managed.example.test/send" }, 400, false], + ["webhook", { webhookUrl: "https://webhook.example.test/send" }, 503, true], + ] as const)("keeps %s legacy non-2xx error behavior", async (mode, setup, statusCode, retryable) => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("legacy bridge failure", { status: statusCode }))); + + const error = await new ConfiguredChatProviderOutboundAdapter().send(contextForMode(mode, setup)).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ChatProviderOutboundAdapterError); + expect(error).toMatchObject({ retryable, statusCode }); + expect((error as Error).message).toBe(`${mode} bridge returned HTTP ${statusCode}: legacy bridge failure`); + }); + it("classifies outbound timeouts as retryable without leaking authorization or recipient data", async () => { const fetchMock = vi.fn().mockRejectedValue(new Error("The operation timed out")); vi.stubGlobal("fetch", fetchMock);