From d6c0ecf0739f1abae8d6ebbfbdd73ab948af5465 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 08:14:02 +0800 Subject: [PATCH 1/3] fix(core): resolve the room-invite target once so the gate and the handler agree `authz.js` read `body.id ?? body.roomId` while `channel.js` read `body.roomId ?? body.id`, so `{ id: , roomId: }` passed the permission gate on the actor's own room and then minted, read or withdrew the other member's link. Both sides now use `roomTargetOf()`, one resolver that prefers `roomId`, so the gate checks the room the handler writes. Single-field bodies behave exactly as before (#2652). Docs-not-needed: a permission fix making the gate and the handler resolve a room body the same way; no architecture or subsystem contract changes. --- changelog.d/2652.security.md | 1 + core/src/host/authz.js | 6 ++-- core/src/host/channel.js | 12 ++++--- core/src/host/room-membership.js | 7 ++++ core/test/authz.test.js | 8 +++++ core/test/room-membership.test.js | 36 +++++++++++++++++++ patches/ds-bot/0005-social-seams.patch | 14 ++++---- patches/ds-bot/files/src/host/authz.js | 6 ++-- .../ds-bot/files/src/host/room-membership.js | 7 ++++ patches/ds-bot/files/test/authz.test.js | 8 +++++ .../ds-bot/files/test/room-membership.test.js | 36 +++++++++++++++++++ 11 files changed, 124 insertions(+), 17 deletions(-) create mode 100644 changelog.d/2652.security.md diff --git a/changelog.d/2652.security.md b/changelog.d/2652.security.md new file mode 100644 index 000000000..ce4c2cb5b --- /dev/null +++ b/changelog.d/2652.security.md @@ -0,0 +1 @@ +A room-invite request naming one room in `id` and another in `roomId` no longer mints, reads or withdraws the other member's link: 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 990c87d32..1ed85fb77 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 @@ -389,13 +389,13 @@ export function install(rt) { 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 980f02654..846e08a3e 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' @@ -428,21 +428,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 +461,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 945274bf1..54543b51f 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 8949edc62..007545c2f 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 5d1ab5799..ab2460695 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -619,3 +619,39 @@ 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") +}) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index bc4e3a4a9..860a11cdb 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' @@ -655,7 +655,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 +691,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 +724,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 990c87d32..1ed85fb77 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 @@ -389,13 +389,13 @@ export function install(rt) { 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 945274bf1..54543b51f 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 8949edc62..007545c2f 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 5d1ab5799..ab2460695 100644 --- a/patches/ds-bot/files/test/room-membership.test.js +++ b/patches/ds-bot/files/test/room-membership.test.js @@ -619,3 +619,39 @@ 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") +}) From 3f6f1c6614a5893b6bfe94fb573c54f82bf8a790 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 08:28:47 +0800 Subject: [PATCH 2/3] fix(core): read the round-progress room through the same resolver as its gate The gate branch for room-history / room-progress / room-post still read `body.id` first while the room-progress handler filtered on `body.roomId`, so a body of `{ id: , roomId: }` passed the gate on the actor's own room and returned the other member's live round, whose replies carry human text (groups.js:889). Both sides now resolve the room with `roomTargetOf(body)`, the resolver #2652 introduced for the invite endpoints, so the room the gate judges is the room the handler reads; single-field bodies behave as before. The new case in test/room-membership.test.js seeds a round in each of two members' rooms and is red with only these two source files reverted. Ran at this tip: cd core && TZ=UTC depth test run of test/authz.test.js and test/room-membership.test.js (43/43); node scripts/ds-bot-patches.mjs check; node .github/scripts/check-mojibake.mjs --check. Not run: the root npm run verify and the smoke test. Docs-not-needed: one permission fix making the gate and the handler resolve a room body the same way; no architecture or subsystem contract change. --- changelog.d/2652.security.md | 2 +- core/src/host/authz.js | 2 +- core/src/host/channel.js | 6 ++-- core/test/room-membership.test.js | 34 +++++++++++++++++++ patches/ds-bot/0005-social-seams.patch | 9 +++-- patches/ds-bot/files/src/host/authz.js | 2 +- .../ds-bot/files/test/room-membership.test.js | 34 +++++++++++++++++++ 7 files changed, 82 insertions(+), 7 deletions(-) diff --git a/changelog.d/2652.security.md b/changelog.d/2652.security.md index ce4c2cb5b..337921612 100644 --- a/changelog.d/2652.security.md +++ b/changelog.d/2652.security.md @@ -1 +1 @@ -A room-invite request naming one room in `id` and another in `roomId` no longer mints, reads or withdraws the other member's link: 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)). +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 1ed85fb77..d2cb8c9be 100644 --- a/core/src/host/authz.js +++ b/core/src/host/authz.js @@ -385,7 +385,7 @@ 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') { diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 846e08a3e..07326e8aa 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.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 }) diff --git a/core/test/room-membership.test.js b/core/test/room-membership.test.js index ab2460695..07133e05f 100644 --- a/core/test/room-membership.test.js +++ b/core/test/room-membership.test.js @@ -655,3 +655,37 @@ test('#2652: the invite gate and the invite handler act on the same room', async 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 860a11cdb..9f4ed581e 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -618,8 +618,13 @@ 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 }))) +@@ -140,8 +140,10 @@ export function install(rt) { +- 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 }) diff --git a/patches/ds-bot/files/src/host/authz.js b/patches/ds-bot/files/src/host/authz.js index 1ed85fb77..d2cb8c9be 100644 --- a/patches/ds-bot/files/src/host/authz.js +++ b/patches/ds-bot/files/src/host/authz.js @@ -385,7 +385,7 @@ 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') { diff --git a/patches/ds-bot/files/test/room-membership.test.js b/patches/ds-bot/files/test/room-membership.test.js index ab2460695..07133e05f 100644 --- a/patches/ds-bot/files/test/room-membership.test.js +++ b/patches/ds-bot/files/test/room-membership.test.js @@ -655,3 +655,37 @@ test('#2652: the invite gate and the invite handler act on the same room', async 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']) +}) From aa77bb45427768a35ec29f222282e7139c0050d7 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 08:33:03 +0800 Subject: [PATCH 3/3] fix(patches): shape the round-progress hunk the way generate writes it The canonical gate (#1890) compares the committed split with what scripts/ds-bot-patches.mjs generate writes, and generate takes the diff from git: a hunk carries three context lines on each side and merges changes whose gap is at most six unchanged lines. The room-progress hunk was hand-written with no leading context because generate cannot run on this box (needs git), so `package` failed at "generate is a no-op on the committed patch split". The hunk now reads @@ -137,11 +137,13 @@ with the three lines before the changed block and the same three trailing lines, which is the shape git's diff of ds-bot/src/host/channel.js against core/src/host/channel.js produces. No file content changes. Ran: node scripts/ds-bot-patches.mjs check (10 patches apply, core/ equals ds-bot/ + patches/ds-bot/). Not run: generate/canonical, both need git. Docs-not-needed: patch-shape only; no architecture or subsystem contract change. --- patches/ds-bot/0005-social-seams.patch | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index 9f4ed581e..9bc73ef7e 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -618,7 +618,10 @@ 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)) -@@ -140,8 +140,10 @@ export function install(rt) { +@@ -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': {