Skip to content

Commit bd25fb3

Browse files
nickmeinholdclaude
andauthored
feat(api): add webhook delivery utility and card event integration (#392)
* feat(api): add webhook delivery utility and card event integration Add the core webhook delivery logic and wire it into card mutations: - Add sendWebhookToUrl() with HMAC-SHA256 signing, 10s timeout - Add sendWebhooksForWorkspace() for fan-out delivery (fire-and-forget) - Add createCardWebhookPayload() for building webhook payloads - Fire webhooks on card create, update, move, and delete events - Add unit tests for webhook utility functions Depends on #391 (DB schema & repository). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): use correct boardId in webhook payloads and add rejection safety - Fix bug where workspaceId was incorrectly passed as boardId in all webhook payloads — now uses board's publicId via boardPublicId - Replace void sendWebhooksForWorkspace() with .catch() to prevent unhandled promise rejections if the DB query inside fails Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(api): add SSRF protection to webhook delivery Block webhook URLs targeting internal networks: - Require HTTPS (reject HTTP) - Block localhost, 127.0.0.1, ::1, 0.0.0.0 - Block cloud metadata endpoints (169.254.169.254, metadata.google.internal) - Block private IP ranges (10.x, 172.16-31.x, 192.168.x) - Add tests for all blocked URL patterns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): use WebhookEvent type from schema instead of duplicating Replace the hardcoded WebhookEventType union with the canonical WebhookEvent type from @kan/db/schema, addressing reviewer feedback on PR #392. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(api): improve webhook delivery safety and validation Cherry-pick delivery-related changes from b2cc9ac: - Extract URL validation into reusable webhookUrlSchema zod validator for SSRF checks - Wrap sendWebhooksForWorkspace in try/catch to prevent unhandled promise rejections - Document SSRF risk mitigation on sendWebhookToUrl - Add corresponding tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 93f2816 commit bd25fb3

File tree

3 files changed

+899
-0
lines changed

3 files changed

+899
-0
lines changed

packages/api/src/routers/card.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { mergeActivities } from "../utils/activities";
1313
import { sendMentionEmails } from "../utils/notifications";
1414
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
1515
import { generateAttachmentUrl, generateAvatarUrl } from "@kan/shared/utils";
16+
import {
17+
createCardWebhookPayload,
18+
sendWebhooksForWorkspace,
19+
} from "../utils/webhook";
1620

1721
export const cardRouter = createTRPCRouter({
1822
create: protectedProcedure
@@ -165,6 +169,32 @@ export const cardRouter = createTRPCRouter({
165169
});
166170
}
167171

172+
// Fire webhooks (non-blocking)
173+
sendWebhooksForWorkspace(
174+
ctx.db,
175+
list.workspaceId,
176+
createCardWebhookPayload(
177+
"card.created",
178+
{
179+
id: String(newCard.id),
180+
title: input.title,
181+
description: input.description,
182+
dueDate: input.dueDate ?? null,
183+
listId: String(newCard.listId),
184+
},
185+
{
186+
boardId: list.boardPublicId,
187+
boardName: list.boardName,
188+
listName: list.name,
189+
user: ctx.user
190+
? { id: ctx.user.id, name: ctx.user.name }
191+
: undefined,
192+
},
193+
),
194+
).catch((error) => {
195+
console.error("Webhook delivery failed:", error);
196+
});
197+
168198
return newCard;
169199
}),
170200
addComment: protectedProcedure
@@ -1007,6 +1037,59 @@ export const cardRouter = createTRPCRouter({
10071037
await cardActivityRepo.bulkCreate(ctx.db, activities);
10081038
}
10091039

1040+
// Build changes object for webhook
1041+
const webhookChanges: Record<string, { from: unknown; to: unknown }> = {};
1042+
if (input.title && existingCard.title !== input.title) {
1043+
webhookChanges.title = { from: existingCard.title, to: input.title };
1044+
}
1045+
if (input.description && existingCard.description !== input.description) {
1046+
webhookChanges.description = {
1047+
from: existingCard.description,
1048+
to: input.description,
1049+
};
1050+
}
1051+
if (
1052+
input.dueDate !== undefined &&
1053+
previousDueDate?.getTime() !== input.dueDate?.getTime()
1054+
) {
1055+
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
1056+
}
1057+
if (newListId && existingCard.listId !== newListId) {
1058+
webhookChanges.listId = { from: existingCard.listId, to: newListId };
1059+
}
1060+
1061+
// Fire webhooks (non-blocking)
1062+
sendWebhooksForWorkspace(
1063+
ctx.db,
1064+
card.workspaceId,
1065+
createCardWebhookPayload(
1066+
newListId && existingCard.listId !== newListId
1067+
? "card.moved"
1068+
: "card.updated",
1069+
{
1070+
id: String(result.id),
1071+
title: result.title,
1072+
description: result.description,
1073+
dueDate: result.dueDate,
1074+
listId: String(newListId ?? existingCard.listId),
1075+
},
1076+
{
1077+
boardId: card.boardPublicId,
1078+
boardName: card.boardName,
1079+
listName: card.listName,
1080+
user: ctx.user
1081+
? { id: ctx.user.id, name: ctx.user.name }
1082+
: undefined,
1083+
changes:
1084+
Object.keys(webhookChanges).length > 0
1085+
? webhookChanges
1086+
: undefined,
1087+
},
1088+
),
1089+
).catch((error) => {
1090+
console.error("Webhook delivery failed:", error);
1091+
});
1092+
10101093
return result;
10111094
}),
10121095
delete: protectedProcedure
@@ -1054,6 +1137,9 @@ export const cardRouter = createTRPCRouter({
10541137
card.createdBy,
10551138
);
10561139

1140+
// Fetch full card data before delete for webhook
1141+
const fullCard = await cardRepo.getByPublicId(ctx.db, input.cardPublicId);
1142+
10571143
const deletedAt = new Date();
10581144

10591145
await cardRepo.softDelete(ctx.db, {
@@ -1068,6 +1154,34 @@ export const cardRouter = createTRPCRouter({
10681154
createdBy: userId,
10691155
});
10701156

1157+
// Fire webhooks (non-blocking)
1158+
if (fullCard) {
1159+
sendWebhooksForWorkspace(
1160+
ctx.db,
1161+
card.workspaceId,
1162+
createCardWebhookPayload(
1163+
"card.deleted",
1164+
{
1165+
id: String(fullCard.id),
1166+
title: fullCard.title,
1167+
description: fullCard.description,
1168+
dueDate: fullCard.dueDate,
1169+
listId: String(fullCard.listId),
1170+
},
1171+
{
1172+
boardId: card.boardPublicId,
1173+
boardName: card.boardName,
1174+
listName: card.listName,
1175+
user: ctx.user
1176+
? { id: ctx.user.id, name: ctx.user.name }
1177+
: undefined,
1178+
},
1179+
),
1180+
).catch((error) => {
1181+
console.error("Webhook delivery failed:", error);
1182+
});
1183+
}
1184+
10711185
return { success: true };
10721186
}),
10731187
});

0 commit comments

Comments
 (0)