Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token compare); POST verifies X-Hub-Signature-256 over the RAW body before any parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid = dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers media processing via the after() idiom. Rejected and rate-limited content always acks 200 and lands as skipped/error rows, never a retryable status. Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256- GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp; unknown senders get one throttled M1 greeting (1/h, 3/day) behind the sender-quota RPC, with no media download and no content persistence. Intake worker: atomic claim on the message row (the durable job record), company resolution (default -> sole membership -> M6 fallback, no item), per-company inbox quota (ack-and-drop, M17 once per 10 min per sender), MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate check, then the shared uploadAndExtract funnel (source 'whatsapp', channel_context caption, whatsapp_message_id) and the M4 ack with extracted merchant/total/date. Failures wrap to 'error' + error_message + one M18. uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId; email/upload paths behaviorally unchanged. Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice (M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM call, sweep cron, retention cron. Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
48 lines
1.7 KiB
TypeScript
48 lines
1.7 KiB
TypeScript
/**
|
|
* Meta webhook authenticity checks.
|
|
*
|
|
* POST: X-Hub-Signature-256 is 'sha256=' + hex(HMAC-SHA256(rawBody, app secret)),
|
|
* computed over the RAW request body. Verify BEFORE any JSON parse: a body that
|
|
* fails the HMAC is untrusted input and must never reach a parser.
|
|
*
|
|
* GET: the one-time subscription handshake sends hub.verify_token; echo
|
|
* hub.challenge only when the token matches.
|
|
*
|
|
* Both compares are length-guarded timingSafeEqual (same shape as
|
|
* lib/webhooks/signing.ts): Buffer.from(x, 'hex') silently drops invalid hex,
|
|
* so lengths are compared AFTER decoding to keep a malformed header from
|
|
* throwing RangeError instead of returning false.
|
|
*/
|
|
|
|
import crypto from 'crypto'
|
|
|
|
const SIGNATURE_PREFIX = 'sha256='
|
|
|
|
export function verifyMetaSignature(
|
|
rawBody: string,
|
|
header: string | null | undefined,
|
|
appSecret: string,
|
|
): boolean {
|
|
if (!header || !appSecret) return false
|
|
const provided = header.startsWith(SIGNATURE_PREFIX)
|
|
? header.slice(SIGNATURE_PREFIX.length)
|
|
: header
|
|
const expected = crypto.createHmac('sha256', appSecret).update(rawBody, 'utf8').digest('hex')
|
|
const expectedBuf = Buffer.from(expected, 'hex')
|
|
const providedBuf = Buffer.from(provided, 'hex')
|
|
if (expectedBuf.length !== providedBuf.length) return false
|
|
return crypto.timingSafeEqual(expectedBuf, providedBuf)
|
|
}
|
|
|
|
/** Constant-time compare of the GET handshake's hub.verify_token. */
|
|
export function verifyChallengeToken(
|
|
token: string | null | undefined,
|
|
expected: string | null | undefined,
|
|
): boolean {
|
|
if (!token || !expected) return false
|
|
const a = Buffer.from(token, 'utf8')
|
|
const b = Buffer.from(expected, 'utf8')
|
|
if (a.length !== b.length) return false
|
|
return crypto.timingSafeEqual(a, b)
|
|
}
|