feat: Express webhook handler v2 with priority detection and structured logging#72
Conversation
|
|
||
| try { | ||
| verifyCommuneWebhook({ | ||
| rawBody: JSON.stringify(req.body), // BUG-SEC-1: should be the original Buffer, not re-serialised object |
There was a problem hiding this comment.
BUG-SEC-1 — HMAC verification against re-serialised object instead of raw bytes
req.body at this point is a parsed JavaScript object — Express's express.json() middleware already ran and consumed the raw bytes. JSON.stringify(req.body) produces a string, but it will not match the original request body: whitespace is normalised, key insertion order may differ from what Commune sent, and any non-ASCII characters may be escaped differently. The HMAC computed by Commune was over the exact bytes on the wire — this re-serialised string will never produce the same digest, causing every valid webhook to be rejected (or, if the error is swallowed, every request to pass regardless of authenticity).
Fix: Register express.raw({ type: 'application/json' }) on the /webhook route before the global express.json() middleware. Then pass req.body directly as a Buffer (or call .toString('utf8') on it after verifying). The corrected route setup looks like:
// Mount raw-body parser on /webhook BEFORE the global json parser
app.use('/webhook', express.raw({ type: 'application/json' }));
app.use(express.json()); // safe for all other routes
app.post('/webhook', async (req, res) => {
const rawBody = (req.body as Buffer).toString('utf8'); // now the original bytes
verifyCommuneWebhook({ rawBody, timestamp: ts, signature: sig, secret });
const payload = JSON.parse(rawBody); // parse separately after verification
...
});| // Accessing a non-existent property returns undefined silently in JS, | ||
| // so history entries will all have content: "undefined" — the LLM will | ||
| // receive corrupted context without any runtime error or type error. | ||
| content: m.body ?? '', |
There was a problem hiding this comment.
BUG-CORRECT-2 — message.body does not exist; use message.content
The Message type from commune-ai has no .body field. The correct fields are:
.content— plain-text body.contentHtml— HTML body (when available)
Accessing a non-existent property in JavaScript/TypeScript returns undefined at runtime without throwing. Because the nullish coalescing operator (?? '') converts undefined to an empty string, every message in the history array will silently have content: ''. The LLM receives a full conversation history where every message appears blank — it has no context and will generate a generic, unhelpful reply. This bug produces no runtime error and no TypeScript compile error (unless noUncheckedIndexedAccess or a strict custom type is used), so it can go undetected in testing if replies "look OK" on simple prompts.
Fix:
content: m.content ?? '', // .content is the correct field| subject: `Re: ${message.metadata?.subject ?? ''}`, | ||
| text: reply, | ||
| inboxId: payload.inboxId, | ||
| // threadId intentionally omitted — BUG-CORRECT-1 |
There was a problem hiding this comment.
BUG-CORRECT-1 — Missing threadId breaks thread continuity
messages.send() is called without threadId. Every reply is sent as a brand-new top-level email, not as a reply in the existing thread. From the customer's perspective:
- Each support reply arrives as an unrelated email with no
In-Reply-ToorReferencesheader - Their email client shows them as separate conversations, not a thread
- The Commune dashboard also shows disconnected threads, making it impossible to follow the conversation history
message.threadId is available in the webhook payload — it just needs to be passed through.
Fix:
await commune.messages.send({
to: sender,
subject: `Re: ${message.metadata?.subject ?? ''}`,
text: reply,
inboxId: payload.inboxId,
threadId: message.threadId, // add this line
});
shanjairaj7
left a comment
There was a problem hiding this comment.
Code Review — 3 issues, 2 correctness + 1 security
[SECURITY] BUG-SEC-1: HMAC verification uses re-serialised object, not raw bytes
Severity: High
express.json() is registered globally before /webhook. By the time the handler runs, req.body is a parsed JS object. JSON.stringify(req.body) does not reproduce the original bytes: whitespace is normalised, key order may differ, and non-ASCII characters may be escaped differently. The HMAC computed by Commune was over the exact wire bytes — this will never match. In practice this means either (a) every valid webhook returns 401 and the handler is completely non-functional, or (b) the catch is accidentally bypassed and every request passes regardless of authenticity. Either outcome is a serious bug.
Fix: mount express.raw({ type: 'application/json' }) on /webhook before express.json(), and use (req.body as Buffer).toString('utf8') as the rawBody. See the v1 handler (src/index.ts) for the correct pattern.
[CORRECTNESS] BUG-CORRECT-1: Missing threadId — every reply opens a new thread
Severity: High
commune.messages.send() is called without threadId. Every AI reply is delivered as a new top-level email with no In-Reply-To header. The customer sees a series of unrelated emails rather than a continuous conversation. The Commune dashboard also shows each reply as a disconnected thread, making support history impossible to audit.
message.threadId is present in the webhook payload at the point of the call — it simply needs to be included.
Fix: threadId: message.threadId in the messages.send() call.
[CORRECTNESS] BUG-CORRECT-2: message.body is not a valid field — use message.content
Severity: Medium
m.body is accessed on each thread message but .body does not exist on the Message type. The expression evaluates to undefined, which the ?? '' coalesces to an empty string. All history entries are passed to OpenAI with content: ''. The LLM has no conversation history and produces generic, context-free replies. There is no runtime error and no TypeScript compile error — the bug is invisible until you notice reply quality degrades on multi-turn conversations.
Fix: m.content ?? '' (plain text) or m.contentHtml ?? '' (HTML).
Please address all three before merging. The security issue in particular must be fixed before this handler can be used in any environment that handles real customer data.
…ld, and thread continuity
- BUG-SEC-1: register express.raw({ type: 'application/json' }) on /webhook before
express.json() so req.body remains a raw Buffer; pass Buffer.toString('utf8') to
verifyCommuneWebhook() — re-serialising a parsed object with JSON.stringify() changes
whitespace/key-order and breaks HMAC verification, allowing spoofed requests
- BUG-CORRECT-2: change m.body to m.content — .body does not exist on the Message type;
accessing it returned undefined silently, corrupting all thread history entries
- BUG-CORRECT-1: add threadId: message.threadId to messages.send() — omitting it caused
each AI reply to create a brand-new email thread instead of continuing the customer's
existing conversation
Summary
Refactors the TypeScript Express webhook handler with two new capabilities:
urgency: highfrom extracted data tags the thread asurgentin the dashboardThe core handler logic is unchanged: verify signature, acknowledge immediately, load thread history, generate reply, send.
Changes
typescript/webhook-handler/src/handler_v2.ts— new handler implementationTest plan
COMMUNE_WEBHOOK_SECRETand send a test webhook — verify 200 responseX-Commune-Signatureheader — verify 401 response