fix(api): skip group membership sweep on partial participant rosters

Group inbound webhooks often report only the sender in participants,
not the full roster. Sweeping on any non-empty list incorrectly marked
approved members as left, silently dropping them from channel fan-out.

Gate the sweep on participantsAuthoritative so adapters opt in only when
the provider supplies a complete roster.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-09-22 03:42:24 +00:00 committed by Ingo Wagner
parent c8b06ec7a8
commit 18934e03c9
3 changed files with 36 additions and 2 deletions

View File

@ -939,7 +939,7 @@ describe("createMessagingInboundHandler channel routing", () => {
}; };
const deps = createDeps({ members: [alice, carol] }); const deps = createDeps({ members: [alice, carol] });
const handle = createMessagingInboundHandler(deps); const handle = createMessagingInboundHandler(deps);
await handle(groupEvent); await handle({ ...groupEvent, participantsAuthoritative: true });
expect(deps.prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith( expect(deps.prisma.messagingChannelMember.updateMany).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
@ -1004,6 +1004,35 @@ describe("createMessagingInboundHandler channel routing", () => {
expect(sparse.prisma.messagingChannelMember.updateMany).not.toHaveBeenCalled(); 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 () => { it("sanitizes attacker-controlled channel names before storing them", async () => {
const deps = createDeps(); const deps = createDeps();
const handle = createMessagingInboundHandler(deps); const handle = createMessagingInboundHandler(deps);

View File

@ -537,7 +537,7 @@ async function handleChannelEvent(
// Someone removed from the group must stop receiving its content. // Someone removed from the group must stop receiving its content.
// A webhook without a participants roster says nothing about membership — // A webhook without a participants roster says nothing about membership —
// never sweep on partial data. // never sweep on partial data.
if (event.participants.length > 0) { if (event.participantsAuthoritative) {
await deps.prisma.messagingChannelMember.updateMany({ await deps.prisma.messagingChannelMember.updateMany({
where: { where: {
channelId: channel.id, channelId: channel.id,

View File

@ -587,6 +587,11 @@ export interface MessagingInboundMessage {
channelName: string | null; channelName: string | null;
/** Group roster addresses when the platform reports them; often empty. */ /** Group roster addresses when the platform reports them; often empty. */
participants: string[]; 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; content: string;
mediaUrl: string | null; mediaUrl: string | null;
/** Team-room workspace/team id when the platform reports one (Slack team_id, …). */ /** Team-room workspace/team id when the platform reports one (Slack team_id, …). */