diff --git a/changelog.d/2652.security.md b/changelog.d/2652.security.md new file mode 100644 index 00000000..33792161 --- /dev/null +++ b/changelog.d/2652.security.md @@ -0,0 +1 @@ +A room request naming one room in `id` and another in `roomId` no longer mints, reads or withdraws the other member's link, nor returns another member's live round: on the invite and round-read endpoints the permission gate and the handler resolve the room the same way, and such a request is refused ([#2652](https://github.com/ThinkFlowLab/FlyBot/issues/2652)). diff --git a/core/src/host/authz.js b/core/src/host/authz.js index 990c87d3..d2cb8c9b 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -13,7 +13,7 @@ // Install order matters: right after member-auth, so rt.authzEnforced sees the same // state.authSessions the sign-in surface writes, and before any module it gates. -import { currentParticipants, roomVisibleTo } from './room-membership.js' +import { currentParticipants, roomTargetOf, roomVisibleTo } from './room-membership.js' import { hasAnyGrant } from './reach.js' // Endpoints a request without an actor may still reach. auth-invite keeps the @@ -385,17 +385,17 @@ export function install(rt) { return deny('bot/forbidden', 'You do not own that room or one of its Bots') } if (ep === 'room-history' || ep === 'room-progress' || ep === 'room-post') { - const id = body?.id ?? body?.roomId + const id = roomTargetOf(body) return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } if (ep === 'room-invite') { - const id = body?.id ?? body?.roomId + const id = roomTargetOf(body) const room = state.rooms?.[id] const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') { - const id = body?.id ?? body?.roomId + const id = roomTargetOf(body) if (roomOwnedBy(actor, id)) return ALLOW const room = state.rooms?.[id] const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 980f0265..07326e8a 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -4,7 +4,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { isApprovalWaiting } from '../shared/approval-waiting.js' import { questionReply } from './parts.js' import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' -import { isFaceRoom } from './room-membership.js' +import { isFaceRoom, roomTargetOf } from './room-membership.js' import { readQuietHours } from '../shared/quiet-hours.js' import { readTurnBudget } from './turn-budget.js' import { faceMentions } from './face-mentions.js' @@ -359,8 +359,10 @@ export function install(rt) { case 'usage': await rt.load() return ok(await rt.usageView({ timeZone: typeof body.timeZone === 'string' ? body.timeZone.slice(0, 64) : undefined })) - case 'room-progress': - return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) + case 'room-progress': { + const id = roomTargetOf(body) + return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === id).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) + } case 'create-bot': { await rt.bootstrap() const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort, workDir: body.workDir, ownerId: rt.authzEnforced() ? actor.memberId : undefined }) @@ -428,21 +430,23 @@ export function install(rt) { } // #876: the room's own link. Reading it is what the Members card shows (copy and the // countdown); minting replaces the live ticket, withdrawing stops answering at once. + // The gate checks the room this body resolves to (#2652): the handler must resolve it the + // same way, or `{ id: , roomId: }` passes on one room and writes the other. case 'room-invite': { await rt.load() - const roomId = String(body.roomId ?? body.id ?? '') + const roomId = String(roomTargetOf(body) ?? '') const invite = rt.localInviteOf(roomId) // #878: every open link of the room; `invite` stays the newest, for the card that shows one. return ok({ invite: invite ?? null, invites: rt.localInvitesOf(roomId) }) } case 'room-invite-mint': { await rt.load() - return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? ''), actor) }) + return ok({ invite: await rt.mintRoomInvite(String(roomTargetOf(body) ?? ''), actor) }) } case 'room-invite-revoke': { await rt.load() const ticket = typeof body.ticket === 'string' && body.ticket ? body.ticket : undefined - return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''), ticket)) + return ok(await rt.revokeRoomInvite(String(roomTargetOf(body) ?? ''), ticket)) } // The invitee's side: what the link is (Bot Social's record when there is one), before Join. case 'room-invite-preview': { @@ -459,7 +463,7 @@ export function install(rt) { // that did not leave). Same read as the poll, with the retries reset. case 'room-invite-seat': { await rt.load() - const roomId = String(body.roomId ?? body.id ?? '') + const roomId = String(roomTargetOf(body) ?? '') const seated = await rt.seatOpenClaims({ roomId, force: true }) return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) }) } diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 945274bf..54543b51 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -287,6 +287,13 @@ export function presentRoom(room, actor, state) { } } +// The room a request body names (#2652). Clients send `roomId` on the invite surface and `id` +// elsewhere, so both are accepted; the permission gate and the handler it gates must resolve them +// the same way, or a body naming two rooms passes on one and writes the other. +export function roomTargetOf(body) { + return body?.roomId ?? body?.id +} + // A signed-in human may see a room when they are a current human participant, // they own an invited Bot in it, or (legacy / single-owner) every bot belongs to them. export function roomVisibleTo(state, actor, roomId) { diff --git a/core/test/authz.test.js b/core/test/authz.test.js index 8949edc6..007545c2 100644 --- a/core/test/authz.test.js +++ b/core/test/authz.test.js @@ -115,6 +115,14 @@ test('owner cannot touch another owner Bot or room', async (t) => { assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true) assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required') + // #2652: `id` and `roomId` resolve to one room, so a body naming two is judged on the room the + // handler acts on (`roomId`) and cannot pass on the actor's own. + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) { + const gate = rt.authorize(actorA, ep, { id: 'ra', roomId: 'rb' }) + assert.equal(gate.ok, false, ep) + assert.equal(gate.code, 'bot/forbidden', ep) + } + const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] }) assert.equal(mixed.ok, false) assert.equal(mixed.code, 'bot/forbidden') diff --git a/core/test/room-membership.test.js b/core/test/room-membership.test.js index 5d1ab579..07133e05 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -619,3 +619,73 @@ test('#2612: a junk wire seq never lands in state.json and never empties the roo assert.deepEqual(floors(stored).filter(value => typeof value !== 'number'), [], `every stored member floor is a local number, saw ${JSON.stringify(floors(stored))}`) } }) + +test('#2652: the invite gate and the invite handler act on the same room', async (t) => { + const { call, rt } = startChannel(t, TEAM) + const first = (await call('auth-invite', {})).value + const alice = (await call('auth-redeem', { token: first.token, displayName: 'Alice' })).value + const second = (await call('auth-invite', {}, alice.sessionToken)).value + const bob = (await call('auth-redeem', { token: second.token, displayName: 'Bob' })).value + rt.state.members[bob.memberId].designatedChiefId = 'chief-b' + rt.state.bots['chief-b'].ownerId = bob.memberId + rt.state.bots['spec-b'].ownerId = bob.memberId + // A Bot Social handle on each Chief, so a link mints without the network (as #852 does). + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } } + rt.state.rooms.ra = { id: 'ra', name: 'RA', members: ['chief-a'], admin: 'chief-a', named: true, log: [], seq: 0, seen: {} } + rt.state.rooms.rb = { id: 'rb', name: 'RB', members: ['chief-b'], admin: 'chief-b', named: true, log: [], seq: 0, seen: {} } + await rt.save() + + assert.equal((await call('room-invite-mint', { roomId: 'ra' }, alice.sessionToken)).ok, true) + const bobLink = (await call('room-invite-mint', { roomId: 'rb' }, bob.sessionToken)).value.invite + assert.equal(typeof bobLink.ticket, 'string', "Bob's room starts with its own link") + + // `id` names the room the actor may use and `roomId` names another member's room: the gate and + // the handler must look at one room, so every one of these is refused, and Bob's link survives. + for (const endpoint of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) { + const answer = await call(endpoint, { id: 'ra', roomId: 'rb' }, alice.sessionToken) + assert.equal(answer.ok, false, endpoint) + assert.equal(answer.error.code, 'bot/forbidden', endpoint) + } + assert.deepEqual(rt.localInvitesOf('rb').map(invite => invite.ticket), [bobLink.ticket], "the other member's link is untouched") + + // The single-field bodies the client sends keep working exactly as they did. + assert.equal((await call('room-invite', { id: 'ra' }, alice.sessionToken)).ok, true, 'id alone names her own room') + assert.equal((await call('room-invite', { roomId: 'rb' }, bob.sessionToken)).ok, true, 'roomId alone names his own room') + assert.equal((await call('room-invite-mint', { id: 'rb' }, alice.sessionToken)).ok, false, "id alone on another member's room is still refused") + assert.equal((await call('room-invite-mint', { roomId: 'rb' }, alice.sessionToken)).ok, false, "roomId alone on another member's room is still refused") +}) + +test('#2652: the round-read gate and the room-progress handler act on the same room', async (t) => { + const { call, rt } = startChannel(t, TEAM) + const first = (await call('auth-invite', {})).value + const alice = (await call('auth-redeem', { token: first.token, displayName: 'Alice' })).value + const second = (await call('auth-invite', {}, alice.sessionToken)).value + const bob = (await call('auth-redeem', { token: second.token, displayName: 'Bob' })).value + rt.state.members[bob.memberId].designatedChiefId = 'chief-b' + rt.state.bots['chief-b'].ownerId = bob.memberId + rt.state.rooms.ra = { id: 'ra', name: 'RA', members: ['chief-a'], admin: 'chief-a', named: true, log: [], seq: 0, seen: {} } + rt.state.rooms.rb = { id: 'rb', name: 'RB', members: ['chief-b'], admin: 'chief-b', named: true, log: [], seq: 0, seen: {} } + await rt.save() + + // A live round in each room; a round's replies carry the human text of the room (groups.js:889). + rt.roomProgress.set('call-a', { roomId: 'ra', replies: [{ kind: 'human', text: 'ALICE-OWN' }], speaking: { botId: 'chief-a' } }) + rt.roomProgress.set('call-b', { roomId: 'rb', replies: [{ kind: 'human', text: 'BOB-ONLY-SECRET' }], speaking: { botId: 'chief-b' } }) + + // `id` names the room the actor may read and `roomId` names another member's room: the gate and + // the handler must look at one room, so this is refused and Bob's round never leaves his room. + const crossed = await call('room-progress', { id: 'ra', roomId: 'rb' }, alice.sessionToken) + assert.equal(crossed.ok, false) + assert.equal(crossed.error.code, 'bot/forbidden') + assert.equal((await call('room-progress', { roomId: 'rb' }, alice.sessionToken)).ok, false, "roomId alone on another member's room is still refused") + assert.equal((await call('room-progress', { id: 'rb' }, alice.sessionToken)).ok, false, "id alone on another member's room is still refused") + + // Her own room resolves by either field, and returns her own round and nothing else. + for (const body of [{ roomId: 'ra' }, { id: 'ra' }]) { + const own = await call('room-progress', body, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(body)) + assert.deepEqual(own.value.map(round => round.callId), ['call-a'], JSON.stringify(body)) + } + // Bob still reads his own room. + assert.deepEqual((await call('room-progress', { roomId: 'rb' }, bob.sessionToken)).value.map(round => round.callId), ['call-b']) +}) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index bc4e3a4a..9bc73ef7 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -274,7 +274,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js +import { isApprovalWaiting } from '../shared/approval-waiting.js' import { questionReply } from './parts.js' import { COLORS, EXTRA_COLORS, ROUTE } from './constants.js' -+import { isFaceRoom } from './room-membership.js' ++import { isFaceRoom, roomTargetOf } from './room-membership.js' +import { readQuietHours } from '../shared/quiet-hours.js' +import { readTurnBudget } from './turn-budget.js' +import { faceMentions } from './face-mentions.js' @@ -618,8 +618,16 @@ diff --git a/src/host/channel.js b/src/host/channel.js case 'duplicate-bot': await rt.load() return ok(await rt.duplicateBot(body.id)) -@@ -141,7 +141,7 @@ export function install(rt) { - return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) +@@ -137,11 +137,13 @@ export function install(rt) { + case 'usage': + await rt.load() + return ok(await rt.usageView({ timeZone: typeof body.timeZone === 'string' ? body.timeZone.slice(0, 64) : undefined })) +- case 'room-progress': +- return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === body.roomId).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) ++ case 'room-progress': { ++ const id = roomTargetOf(body) ++ return ok([...rt.roomProgress.entries()].filter(([, entry]) => entry.roomId === id).map(([callId, entry]) => ({ callId, replies: entry.replies, speaking: entry.speaking ?? null }))) ++ } case 'create-bot': { await rt.bootstrap() - const bot = await rt.createBot({ name: body.name, role: body.role, color: body.color, instructions: body.instructions, brief: body.brief, avatar: body.avatar, model: body.model, effort: body.effort, workDir: body.workDir }) @@ -655,7 +663,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js return ok(room) } case 'update-room': { -@@ -171,12 +171,70 @@ export function install(rt) { +@@ -171,12 +171,72 @@ export function install(rt) { notice: body.notice, mode: body.mode, admin: body.admin, @@ -691,21 +699,23 @@ diff --git a/src/host/channel.js b/src/host/channel.js + } + // #876: the room's own link. Reading it is what the Members card shows (copy and the + // countdown); minting replaces the live ticket, withdrawing stops answering at once. ++ // The gate checks the room this body resolves to (#2652): the handler must resolve it the ++ // same way, or `{ id: , roomId: }` passes on one room and writes the other. + case 'room-invite': { + await rt.load() -+ const roomId = String(body.roomId ?? body.id ?? '') ++ const roomId = String(roomTargetOf(body) ?? '') + const invite = rt.localInviteOf(roomId) + // #878: every open link of the room; `invite` stays the newest, for the card that shows one. + return ok({ invite: invite ?? null, invites: rt.localInvitesOf(roomId) }) + } + case 'room-invite-mint': { + await rt.load() -+ return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? ''), actor) }) ++ return ok({ invite: await rt.mintRoomInvite(String(roomTargetOf(body) ?? ''), actor) }) + } + case 'room-invite-revoke': { + await rt.load() + const ticket = typeof body.ticket === 'string' && body.ticket ? body.ticket : undefined -+ return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? ''), ticket)) ++ return ok(await rt.revokeRoomInvite(String(roomTargetOf(body) ?? ''), ticket)) + } + // The invitee's side: what the link is (Bot Social's record when there is one), before Join. + case 'room-invite-preview': { @@ -722,7 +732,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js + // that did not leave). Same read as the poll, with the retries reset. + case 'room-invite-seat': { + await rt.load() -+ const roomId = String(body.roomId ?? body.id ?? '') ++ const roomId = String(roomTargetOf(body) ?? '') + const seated = await rt.seatOpenClaims({ roomId, force: true }) + return ok({ ...seated, invite: rt.localInviteOf(roomId) ?? null, invites: rt.localInvitesOf(roomId) }) } diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 990c87d3..d2cb8c9b 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -13,7 +13,7 @@ // Install order matters: right after member-auth, so rt.authzEnforced sees the same // state.authSessions the sign-in surface writes, and before any module it gates. -import { currentParticipants, roomVisibleTo } from './room-membership.js' +import { currentParticipants, roomTargetOf, roomVisibleTo } from './room-membership.js' import { hasAnyGrant } from './reach.js' // Endpoints a request without an actor may still reach. auth-invite keeps the @@ -385,17 +385,17 @@ export function install(rt) { return deny('bot/forbidden', 'You do not own that room or one of its Bots') } if (ep === 'room-history' || ep === 'room-progress' || ep === 'room-post') { - const id = body?.id ?? body?.roomId + const id = roomTargetOf(body) return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } if (ep === 'room-invite') { - const id = body?.id ?? body?.roomId + const id = roomTargetOf(body) const room = state.rooms?.[id] const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room') } if (ep === 'room-invite-mint' || ep === 'room-invite-revoke' || ep === 'room-invite-seat') { - const id = body?.id ?? body?.roomId + const id = roomTargetOf(body) if (roomOwnedBy(actor, id)) return ALLOW const room = state.rooms?.[id] const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 945274bf..54543b51 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -287,6 +287,13 @@ export function presentRoom(room, actor, state) { } } +// The room a request body names (#2652). Clients send `roomId` on the invite surface and `id` +// elsewhere, so both are accepted; the permission gate and the handler it gates must resolve them +// the same way, or a body naming two rooms passes on one and writes the other. +export function roomTargetOf(body) { + return body?.roomId ?? body?.id +} + // A signed-in human may see a room when they are a current human participant, // they own an invited Bot in it, or (legacy / single-owner) every bot belongs to them. export function roomVisibleTo(state, actor, roomId) { diff --git a/patches/ds-bot/files/test/authz.test.js b/patches/ds-bot/files/test/authz.test.js index 8949edc6..007545c2 100644 --- a/patches/ds-bot/files/test/authz.test.js +++ b/patches/ds-bot/files/test/authz.test.js @@ -115,6 +115,14 @@ test('owner cannot touch another owner Bot or room', async (t) => { assert.equal(rt.authorize(actorA, 'room-invite-ask', { link: 'https://social.botawaken.com/r/@chief.b.home/AbCdEfGhIjKlMnOpQrStUv' }).ok, true) assert.equal(rt.authorize(null, 'room-invite-ask', {}).ok, false, 'a signed-in actor is still required') + // #2652: `id` and `roomId` resolve to one room, so a body naming two is judged on the room the + // handler acts on (`roomId`) and cannot pass on the actor's own. + for (const ep of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) { + const gate = rt.authorize(actorA, ep, { id: 'ra', roomId: 'rb' }) + assert.equal(gate.ok, false, ep) + assert.equal(gate.code, 'bot/forbidden', ep) + } + const mixed = rt.authorize(actorA, 'create-room', { members: ['a1', 'b1'] }) assert.equal(mixed.ok, false) assert.equal(mixed.code, 'bot/forbidden') diff --git a/patches/ds-bot/files/test/room-membership.test.js b/patches/ds-bot/files/test/room-membership.test.js index 5d1ab579..07133e05 100644 --- a/patches/ds-bot/files/test/room-membership.test.js +++ b/patches/ds-bot/files/test/room-membership.test.js @@ -619,3 +619,73 @@ test('#2612: a junk wire seq never lands in state.json and never empties the roo assert.deepEqual(floors(stored).filter(value => typeof value !== 'number'), [], `every stored member floor is a local number, saw ${JSON.stringify(floors(stored))}`) } }) + +test('#2652: the invite gate and the invite handler act on the same room', async (t) => { + const { call, rt } = startChannel(t, TEAM) + const first = (await call('auth-invite', {})).value + const alice = (await call('auth-redeem', { token: first.token, displayName: 'Alice' })).value + const second = (await call('auth-invite', {}, alice.sessionToken)).value + const bob = (await call('auth-redeem', { token: second.token, displayName: 'Bob' })).value + rt.state.members[bob.memberId].designatedChiefId = 'chief-b' + rt.state.bots['chief-b'].ownerId = bob.memberId + rt.state.bots['spec-b'].ownerId = bob.memberId + // A Bot Social handle on each Chief, so a link mints without the network (as #852 does). + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + rt.state.bots['chief-b'].fields = { social: { member: { home: '@bob.home', handle: '@chief.b.home' } } } + rt.state.rooms.ra = { id: 'ra', name: 'RA', members: ['chief-a'], admin: 'chief-a', named: true, log: [], seq: 0, seen: {} } + rt.state.rooms.rb = { id: 'rb', name: 'RB', members: ['chief-b'], admin: 'chief-b', named: true, log: [], seq: 0, seen: {} } + await rt.save() + + assert.equal((await call('room-invite-mint', { roomId: 'ra' }, alice.sessionToken)).ok, true) + const bobLink = (await call('room-invite-mint', { roomId: 'rb' }, bob.sessionToken)).value.invite + assert.equal(typeof bobLink.ticket, 'string', "Bob's room starts with its own link") + + // `id` names the room the actor may use and `roomId` names another member's room: the gate and + // the handler must look at one room, so every one of these is refused, and Bob's link survives. + for (const endpoint of ['room-invite', 'room-invite-mint', 'room-invite-revoke', 'room-invite-seat']) { + const answer = await call(endpoint, { id: 'ra', roomId: 'rb' }, alice.sessionToken) + assert.equal(answer.ok, false, endpoint) + assert.equal(answer.error.code, 'bot/forbidden', endpoint) + } + assert.deepEqual(rt.localInvitesOf('rb').map(invite => invite.ticket), [bobLink.ticket], "the other member's link is untouched") + + // The single-field bodies the client sends keep working exactly as they did. + assert.equal((await call('room-invite', { id: 'ra' }, alice.sessionToken)).ok, true, 'id alone names her own room') + assert.equal((await call('room-invite', { roomId: 'rb' }, bob.sessionToken)).ok, true, 'roomId alone names his own room') + assert.equal((await call('room-invite-mint', { id: 'rb' }, alice.sessionToken)).ok, false, "id alone on another member's room is still refused") + assert.equal((await call('room-invite-mint', { roomId: 'rb' }, alice.sessionToken)).ok, false, "roomId alone on another member's room is still refused") +}) + +test('#2652: the round-read gate and the room-progress handler act on the same room', async (t) => { + const { call, rt } = startChannel(t, TEAM) + const first = (await call('auth-invite', {})).value + const alice = (await call('auth-redeem', { token: first.token, displayName: 'Alice' })).value + const second = (await call('auth-invite', {}, alice.sessionToken)).value + const bob = (await call('auth-redeem', { token: second.token, displayName: 'Bob' })).value + rt.state.members[bob.memberId].designatedChiefId = 'chief-b' + rt.state.bots['chief-b'].ownerId = bob.memberId + rt.state.rooms.ra = { id: 'ra', name: 'RA', members: ['chief-a'], admin: 'chief-a', named: true, log: [], seq: 0, seen: {} } + rt.state.rooms.rb = { id: 'rb', name: 'RB', members: ['chief-b'], admin: 'chief-b', named: true, log: [], seq: 0, seen: {} } + await rt.save() + + // A live round in each room; a round's replies carry the human text of the room (groups.js:889). + rt.roomProgress.set('call-a', { roomId: 'ra', replies: [{ kind: 'human', text: 'ALICE-OWN' }], speaking: { botId: 'chief-a' } }) + rt.roomProgress.set('call-b', { roomId: 'rb', replies: [{ kind: 'human', text: 'BOB-ONLY-SECRET' }], speaking: { botId: 'chief-b' } }) + + // `id` names the room the actor may read and `roomId` names another member's room: the gate and + // the handler must look at one room, so this is refused and Bob's round never leaves his room. + const crossed = await call('room-progress', { id: 'ra', roomId: 'rb' }, alice.sessionToken) + assert.equal(crossed.ok, false) + assert.equal(crossed.error.code, 'bot/forbidden') + assert.equal((await call('room-progress', { roomId: 'rb' }, alice.sessionToken)).ok, false, "roomId alone on another member's room is still refused") + assert.equal((await call('room-progress', { id: 'rb' }, alice.sessionToken)).ok, false, "id alone on another member's room is still refused") + + // Her own room resolves by either field, and returns her own round and nothing else. + for (const body of [{ roomId: 'ra' }, { id: 'ra' }]) { + const own = await call('room-progress', body, alice.sessionToken) + assert.equal(own.ok, true, JSON.stringify(body)) + assert.deepEqual(own.value.map(round => round.callId), ['call-a'], JSON.stringify(body)) + } + // Bob still reads his own room. + assert.deepEqual((await call('room-progress', { roomId: 'rb' }, bob.sessionToken)).value.map(round => round.callId), ['call-b']) +})