diff --git a/apps/api/src/messaging-inbound.test.ts b/apps/api/src/messaging-inbound.test.ts index b20083e..5f6991d 100644 --- a/apps/api/src/messaging-inbound.test.ts +++ b/apps/api/src/messaging-inbound.test.ts @@ -939,7 +939,7 @@ describe("createMessagingInboundHandler channel routing", () => { }; const deps = createDeps({ members: [alice, carol] }); const handle = createMessagingInboundHandler(deps); - await handle(groupEvent); + await handle({ ...groupEvent, participantsAuthoritative: true }); expect(deps.prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith( expect.objectContaining({ @@ -1004,6 +1004,35 @@ describe("createMessagingInboundHandler channel routing", () => { expect(sparse.prisma.messagingChannelMember.updateMany).not.toHaveBeenCalled(); }); + it("does not sweep approved members when the webhook roster is partial", async () => { + const alice = { + id: "cm-1", + channelId: "ch-1", + address: "+15551111111", + identityId: "mi-1", + status: "approved", + }; + const bob = { + id: "cm-2", + channelId: "ch-1", + address: "+15552222222", + identityId: "mi-2", + status: "approved", + }; + const deps = createDeps({ members: [alice, bob] }); + const handle = createMessagingInboundHandler(deps); + // Sendblue often reports only the sender — not a complete roster. + await handle({ + ...groupEvent, + participants: ["+15551111111"], + }); + + expect(deps.prisma.messagingChannelMember.updateMany).not.toHaveBeenCalledWith( + expect.objectContaining({ data: { status: "left" } }), + ); + expect(bob.status).toBe("approved"); + }); + it("sanitizes attacker-controlled channel names before storing them", async () => { const deps = createDeps(); const handle = createMessagingInboundHandler(deps); diff --git a/apps/api/src/messaging-inbound.ts b/apps/api/src/messaging-inbound.ts index f536e64..0a00b7a 100644 --- a/apps/api/src/messaging-inbound.ts +++ b/apps/api/src/messaging-inbound.ts @@ -537,7 +537,7 @@ async function handleChannelEvent( // Someone removed from the group must stop receiving its content. // A webhook without a participants roster says nothing about membership — // never sweep on partial data. - if (event.participants.length > 0) { + if (event.participantsAuthoritative) { await deps.prisma.messagingChannelMember.updateMany({ where: { channelId: channel.id, diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index cfb3a73..4e060be 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -587,6 +587,11 @@ export interface MessagingInboundMessage { channelName: string | null; /** Group roster addresses when the platform reports them; often empty. */ participants: string[]; + /** + * True only when {@link participants} is a complete group roster from the + * provider. Partial rosters must not trigger membership sweeps. + */ + participantsAuthoritative?: boolean; content: string; mediaUrl: string | null; /** Team-room workspace/team id when the platform reports one (Slack team_id, …). */