fix(GRO-982): address all QA blocking failures
- #7: Extract validateTelnyxSignature in sms.ts as standalone exported fn, reuse in TelnyxProvider.validateWebhookSignature and telnyx.ts route - #1: Replace uuid v4 import with crypto.randomUUID() (built-in, no dep) - #2: Remove updatedAt from messages update in handleMessageFinalized (no such column exists) - #3: Fix test import path ../../ → ../../../ for telnyx route import - #4: validateTelnyxSignature accepts string | undefined | null to match Hono c.req.header() return type - #5&6: Add null guards for .returning() results in findOrCreateConversation and upsertMessage - #8: Remove dead buildFindOrCreateConversationParams function - #9: Remove unused imports (messageDirectionEnum, messageStatusEnum, resolveBusinessIdByMessagingNumber in test) - #10: Wrap upsertMessage insert in try/catch; unique violation returns {isNew: false} instead of crashing - #11: Add EOF newlines to all modified files Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,40 +1,13 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createHmac } from "crypto";
|
import { validateTelnyxSignature } from "../../services/sms.js";
|
||||||
import {
|
import {
|
||||||
handleMessageReceived,
|
handleMessageReceived,
|
||||||
handleMessageFinalized,
|
handleMessageFinalized,
|
||||||
resolveBusinessIdByMessagingNumber,
|
|
||||||
TelnyxMessageReceivedPayload,
|
TelnyxMessageReceivedPayload,
|
||||||
} from "../../services/messaging/inbound.js";
|
} from "../../services/messaging/inbound.js";
|
||||||
|
|
||||||
export const telnyxWebhooksRouter = new Hono();
|
export const telnyxWebhooksRouter = new Hono();
|
||||||
|
|
||||||
function validateTelnyxSignature(rawBody: string, signature: string | null): boolean {
|
|
||||||
if (!signature) return false;
|
|
||||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
|
||||||
if (!secret) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hmac = createHmac("sha256", secret);
|
|
||||||
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
|
|
||||||
|
|
||||||
const sigBuf = Buffer.from(signature);
|
|
||||||
const expBuf = Buffer.from(expected);
|
|
||||||
|
|
||||||
if (sigBuf.length !== expBuf.length) return false;
|
|
||||||
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < sigBuf.length; i++) {
|
|
||||||
const sigByte = sigBuf[i] ?? 0;
|
|
||||||
const expByte = expBuf[i] ?? 0;
|
|
||||||
diff |= sigByte ^ expByte;
|
|
||||||
}
|
|
||||||
return diff === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
telnyxWebhooksRouter.post("/messaging", async (c) => {
|
telnyxWebhooksRouter.post("/messaging", async (c) => {
|
||||||
const signature = c.req.header("telnyx-signature");
|
const signature = c.req.header("telnyx-signature");
|
||||||
|
|
||||||
@@ -83,4 +56,4 @@ telnyxWebhooksRouter.post("/messaging", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ received: true });
|
return c.json({ received: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
import {
|
import {
|
||||||
findOrCreateConversation,
|
findOrCreateConversation,
|
||||||
upsertMessage,
|
upsertMessage,
|
||||||
resolveBusinessIdByMessagingNumber,
|
|
||||||
handleMessageReceived,
|
handleMessageReceived,
|
||||||
handleMessageFinalized,
|
handleMessageFinalized,
|
||||||
TelnyxMessageReceivedPayload,
|
TelnyxMessageReceivedPayload,
|
||||||
@@ -53,8 +52,12 @@ const makePayload = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("signature validation via route", () => {
|
describe("signature validation via route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 401 when telnyx-signature header is missing", async () => {
|
it("returns 401 when telnyx-signature header is missing", async () => {
|
||||||
const { telnyxWebhooksRouter } = await import("../../routes/webhooks/telnyx.js");
|
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
||||||
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
||||||
const req = new Request("http://localhost/api/webhooks/telnyx/messaging", {
|
const req = new Request("http://localhost/api/webhooks/telnyx/messaging", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -67,7 +70,7 @@ describe("signature validation via route", () => {
|
|||||||
|
|
||||||
it("returns 401 when signature does not match", async () => {
|
it("returns 401 when signature does not match", async () => {
|
||||||
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
|
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
|
||||||
const { telnyxWebhooksRouter } = await import("../../routes/webhooks/telnyx.js");
|
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
||||||
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
||||||
const req = new Request("http://localhost/api/webhooks/telnyx/messaging", {
|
const req = new Request("http://localhost/api/webhooks/telnyx/messaging", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -273,4 +276,4 @@ describe("handleMessageFinalized", () => {
|
|||||||
const result = await handleMessageFinalized(payload);
|
const result = await handleMessageFinalized(payload);
|
||||||
expect(result?.newStatus).toBe("delivered");
|
expect(result?.newStatus).toBe("delivered");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { getDb, conversations, messages, businessSettings, eq, and, sql } from "@groombook/db";
|
import { getDb, conversations, messages, businessSettings, eq, and, sql } from "@groombook/db";
|
||||||
import { messageDirectionEnum, messageStatusEnum } from "@groombook/db";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
|
|
||||||
export interface TelnyxMessageReceivedPayload {
|
export interface TelnyxMessageReceivedPayload {
|
||||||
data: {
|
data: {
|
||||||
@@ -20,14 +18,6 @@ export interface TelnyxMessageReceivedPayload {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFindOrCreateConversationParams(businessId: string, clientPhone: string, businessNumber: string) {
|
|
||||||
return {
|
|
||||||
businessId,
|
|
||||||
externalNumber: clientPhone,
|
|
||||||
businessNumber,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function findOrCreateConversation(
|
export async function findOrCreateConversation(
|
||||||
businessId: string,
|
businessId: string,
|
||||||
clientPhone: string,
|
clientPhone: string,
|
||||||
@@ -57,12 +47,12 @@ export async function findOrCreateConversation(
|
|||||||
.where(eq(businessSettings.id, businessId))
|
.where(eq(businessSettings.id, businessId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const clientId = business?.primaryClientId ?? uuidv4();
|
const clientId = business?.primaryClientId ?? crypto.randomUUID();
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(conversations)
|
.insert(conversations)
|
||||||
.values({
|
.values({
|
||||||
id: uuidv4(),
|
id: crypto.randomUUID(),
|
||||||
businessId,
|
businessId,
|
||||||
clientId,
|
clientId,
|
||||||
channel: "sms",
|
channel: "sms",
|
||||||
@@ -73,6 +63,8 @@ export async function findOrCreateConversation(
|
|||||||
})
|
})
|
||||||
.returning({ id: conversations.id, clientId: conversations.clientId });
|
.returning({ id: conversations.id, clientId: conversations.clientId });
|
||||||
|
|
||||||
|
if (!created) throw new Error("Failed to create conversation");
|
||||||
|
|
||||||
return { id: created.id, clientId: created.clientId };
|
return { id: created.id, clientId: created.clientId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,20 +88,33 @@ export async function upsertMessage(
|
|||||||
return { id: existing.id, isNew: false };
|
return { id: existing.id, isNew: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [inserted] = await db
|
try {
|
||||||
.insert(messages)
|
const [inserted] = await db
|
||||||
.values({
|
.insert(messages)
|
||||||
id: uuidv4(),
|
.values({
|
||||||
conversationId,
|
id: crypto.randomUUID(),
|
||||||
direction,
|
conversationId,
|
||||||
body,
|
direction,
|
||||||
status,
|
body,
|
||||||
providerMessageId,
|
status,
|
||||||
sentByStaffId: sentByStaffId ?? null,
|
providerMessageId,
|
||||||
})
|
sentByStaffId: sentByStaffId ?? null,
|
||||||
.returning({ id: messages.id });
|
})
|
||||||
|
.returning({ id: messages.id });
|
||||||
|
|
||||||
return { id: inserted.id, isNew: true };
|
if (!inserted) throw new Error("Failed to insert message");
|
||||||
|
return { id: inserted.id, isNew: true };
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.message.includes("unique")) {
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ id: messages.id })
|
||||||
|
.from(messages)
|
||||||
|
.where(eq(messages.providerMessageId, providerMessageId))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) return { id: existing.id, isNew: false };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
|
export async function resolveBusinessIdByMessagingNumber(toNumber: string): Promise<string | null> {
|
||||||
@@ -179,9 +184,9 @@ export async function handleMessageFinalized(payload: TelnyxMessageReceivedPaylo
|
|||||||
if (newStatus !== existing.status) {
|
if (newStatus !== existing.status) {
|
||||||
await db
|
await db
|
||||||
.update(messages)
|
.update(messages)
|
||||||
.set({ status: newStatus, deliveredAt: new Date(), updatedAt: new Date() })
|
.set({ status: newStatus, deliveredAt: new Date() })
|
||||||
.where(eq(messages.id, existing.id));
|
.where(eq(messages.id, existing.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { messageId: existing.id, newStatus };
|
return { messageId: existing.id, newStatus };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,35 @@ function isE164(phone: string): boolean {
|
|||||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
return /^\+[1-9]\d{7,14}$/.test(phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateTelnyxSignature(
|
||||||
|
rawBody: string,
|
||||||
|
signature: string | undefined | null
|
||||||
|
): boolean {
|
||||||
|
if (!signature) return false;
|
||||||
|
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
||||||
|
if (!secret) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hmac = createHmac("sha256", secret);
|
||||||
|
const expected = `sha256=${hmac.update(rawBody).digest("hex")}`;
|
||||||
|
|
||||||
|
const sigBuf = Buffer.from(signature);
|
||||||
|
const expBuf = Buffer.from(expected);
|
||||||
|
|
||||||
|
if (sigBuf.length !== expBuf.length) return false;
|
||||||
|
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < sigBuf.length; i++) {
|
||||||
|
const sigByte = sigBuf[i] ?? 0;
|
||||||
|
const expByte = expBuf[i] ?? 0;
|
||||||
|
diff |= sigByte ^ expByte;
|
||||||
|
}
|
||||||
|
return diff === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendSms(
|
export async function sendSms(
|
||||||
to: string,
|
to: string,
|
||||||
body: string,
|
body: string,
|
||||||
@@ -74,33 +103,7 @@ export class TelnyxProvider implements SmsProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateWebhookSignature(req: Request): boolean {
|
validateWebhookSignature(req: Request): boolean {
|
||||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
return validateTelnyxSignature(JSON.stringify(req.body), req.headers.get("telnyx-signature"));
|
||||||
if (!secret) return false;
|
|
||||||
|
|
||||||
const signature = req.headers.get("telnyx-signature");
|
|
||||||
if (!signature) return false;
|
|
||||||
|
|
||||||
const payload = JSON.stringify(req.body);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hmac = createHmac("sha256", secret);
|
|
||||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
|
||||||
|
|
||||||
const sigBuf = Buffer.from(signature);
|
|
||||||
const expBuf = Buffer.from(expected);
|
|
||||||
|
|
||||||
if (sigBuf.length !== expBuf.length) return false;
|
|
||||||
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < sigBuf.length; i++) {
|
|
||||||
const sigByte = sigBuf[i] ?? 0;
|
|
||||||
const expByte = expBuf[i] ?? 0;
|
|
||||||
diff |= sigByte ^ expByte;
|
|
||||||
}
|
|
||||||
return diff === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user