feat(threads): govern roster reads + add a bulk participant primitive

Phase A of channel-roster import (add everyone from another channel).

SECURITY: listParticipants was gated by iios.thread.read, which has no case in
DevOpaPort and so fell through to default-allow — any caller in a scope could
enumerate ANY thread's members, including private channels they aren't in. Since
PlatformModule binds LocalDevPorts unconditionally (no real OPA adapter exists),
that was live. Adds iios.thread.participant.list: members only, public channels
exempt, ungoverned threads unchanged. This is also Zoom's rule for this feature
('you must be a member of the channel to invite all of its members').

Adds MessageService.addParticipants(): many users in ONE governed call, capped at
MAX_BULK_PARTICIPANTS (200), idempotent (already-members are 'skipped'), and
deliberately non-atomic so one unresolvable user can't sink an import — outcome
is reported per user as {added, skipped, failed}. The dm two-person cap now reads
targetCount so it holds for a batch, not just one add at a time. New REST route
POST /v1/threads/:id/participants/bulk.

The kernel stays generic: it takes an explicit userId list and never learns where
that list came from — chat meaning lives only in the policy plane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 16:05:14 +05:30
parent 4f9a252118
commit 0336621c01
5 changed files with 191 additions and 3 deletions
@@ -146,6 +146,61 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(1);
});
it('roster reads are membership-governed: a non-member cannot list a private channels members', async () => {
const s = gov();
const { threadId: priv } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'deals', creatorRole: 'ADMIN' });
// bob is not a member — he must not be able to enumerate who is.
await expect(s.listParticipants(priv, bob)).rejects.toBeInstanceOf(PolicyDeniedError);
await s.addParticipant(priv, alice, 'bob');
expect((await s.listParticipants(priv, bob)).map((m) => m.userId).sort()).toEqual(['alice', 'bob']);
// a PUBLIC channel's roster stays open (it is discoverable anyway)
const { threadId: pub } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'public' }, subject: 'general', creatorRole: 'ADMIN' });
expect((await s.listParticipants(pub, bob)).map((m) => m.userId)).toEqual(['alice']);
});
it('addParticipants: bulk-adds in one governed call, skipping existing members (re-run is a no-op)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, subject: 'ops', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // bob is already in
const res = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
expect(res.added.sort()).toEqual(['carol', 'dave']);
expect(res.skipped).toEqual(['bob']);
expect(res.failed).toEqual([]);
expect(res.participantCount).toBe(4); // alice, bob, carol, dave
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
// idempotent: a second identical import adds nobody
const again = await s.addParticipants(threadId, alice, ['bob', 'carol', 'dave']);
expect(again.added).toEqual([]);
expect(again.skipped.sort()).toEqual(['bob', 'carol', 'dave']);
expect(await prisma.iiosThreadParticipant.count({ where: { threadId } })).toBe(4);
});
it('addParticipants is governed by the same policy as a single add (a plain member is denied)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob'); // bob joins as a plain MEMBER
await expect(s.addParticipants(threadId, bob, ['carol', 'dave'])).rejects.toBeInstanceOf(PolicyDeniedError);
});
it('addParticipants rejects an empty list and anything over the batch cap', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'channel', metadata: { visibility: 'private' }, creatorRole: 'ADMIN' });
await expect(s.addParticipants(threadId, alice, [])).rejects.toThrow(/at least one/i);
const tooMany = Array.from({ length: MessageService.MAX_BULK_PARTICIPANTS + 1 }, (_, n) => `user_${n}`);
await expect(s.addParticipants(threadId, alice, tooMany)).rejects.toThrow(/at most/i);
});
it('addParticipants respects the dm two-person cap for the whole batch', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'dm' });
// alice is alone; adding two at once would make three — policy must deny the batch.
await expect(s.addParticipants(threadId, alice, ['bob', 'carol'])).rejects.toBeInstanceOf(PolicyDeniedError);
expect((await s.addParticipants(threadId, alice, ['bob'])).added).toEqual(['bob']);
});
it('self-join is governed: a non-member cannot open a thread by id; after being added, they can', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });