From 07dee97d1495d2033d99b1ba027136a78d3e0c78 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 09:27:44 +0800 Subject: [PATCH] Let the transport, not the payload, decide an inbound room share applyInboundRoomShare took the inviter's home and handle from the envelope before the transport, and then used the same two values as the gate: a share naming a room of mine from a home the room does not belong to matched that room, rewrote its sharedFrom, and a bare line envelope created a room of its own. The author fields were read the same way (#909). Now event.fromHome / event.fromHandle decide and data.inviter* is display only, as #873 already wanted for the visible name. A share that names a room this home holds from another home is refused 'not-inviter' instead of merged through a fallback lookup; only op join projects a room, so a line for an unknown remote room is refused 'unknown-room'; and a line's authorId is kept only when the handle traces to the transport sender, a remote seat of that room, or its inviter, else the line is shown as the sending Bot with unverified: true and still kept. Tests: three cases in core/test/remote-room.test.js, all red on 093ff4d4. The two touched paths are files FlyBot adds, so patches/ds-bot/files/ carries the same content the generator would write (no git on this box to run it). --- core/src/host/room-membership.js | 59 +++++++---- core/test/remote-room.test.js | 99 +++++++++++++++++++ docs/subsystems/core-host.md | 2 +- .../ds-bot/files/src/host/room-membership.js | 59 +++++++---- patches/ds-bot/files/test/remote-room.test.js | 99 +++++++++++++++++++ 5 files changed, 281 insertions(+), 37 deletions(-) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 03ea1847..ffda33ad 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -1757,6 +1757,22 @@ export function install(rt) { await rt.save() } + // Who may be shown as a line's author (#909). A handle is a claim the payload makes, so it is shown only + // when it can be traced here: the transport sender itself, a remote seat this room holds, or its inviter. + // Anything else is shown as the sender (a Bot), marked unverified. A non-handle id is a person or a Bot of + // the other home (#873): this side never resolves those, so the claim stands as it did before. + function verifiedAuthor(data, room, event) { + const claim = typeof data.authorId === 'string' ? data.authorId : '' + const kind = data.authorKind === 'bot' ? 'bot' : 'human' + const sender = String(event.fromHandle ?? '') + if (!claim) return sender ? { authorId: sender, authorKind: 'bot' } : { authorId: undefined, authorKind: 'human' } + if (!claim.startsWith('@')) return { authorId: claim, authorKind: kind } + const traced = claim === sender + || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim)) + || room.sharedFrom?.inviterHandle === claim + return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true } + } + function inboundOf(remoteRoomId, inviterHome, inviterHandle) { return Object.values(state.rooms || {}).find((room) => { if (room.origin !== 'inbound-share') return false @@ -1802,11 +1818,11 @@ export function install(rt) { rt.applyInboundRoomShare = async function applyInboundRoomShare(event = {}, actor) { const data = parseRoomShare(event.text) if (!data) return undefined - // The envelope names the person who invited; the event names the Bot that carried the line here (#873). - // Prefer the envelope for the identity, and keep the transport only as the fallback, so the room reads - // "Shared room from Alice" rather than after the sender's Bot. - const fromHandle = data.inviterHandle || event.fromHandle - const fromHome = data.inviterHome || event.fromHome + // The transport names the Bot that delivered this, and that is the only identity this side can check: + // the envelope's inviter fields are display (#873 keeps its "Shared room from Alice"), never the gate + // that decides which room a share may touch (#909). + const fromHandle = String(event.fromHandle ?? '') + const fromHome = String(event.fromHome ?? '') const fromName = cleanPeerName(data.inviterName || event.fromName, 'a friend') // A chat line is attributed to its speaker (`who`) and otherwise to the sender, never to the inviter: // this fix is about the room's identity, not about who wrote inside it. @@ -1827,16 +1843,16 @@ export function install(rt) { // A line for a room of mine from someone it does not seat is dropped: it must not open a copy here. const mine = rt.roomOf(String(data.roomId ?? '')) if (data.op === 'line' && mine && mine.origin !== 'inbound-share' && !isFaceRoom(mine)) return { handled: true, refused: 'not-seated' } + // Matched by the transport only (#909): a share that names a room of mine while arriving from a home + // that room does not belong to is refused, not merged in through a fallback lookup. let room = inboundOf(data.roomId, fromHome, fromHandle) if (!room) { - // A share recorded before this fix stores the transport's identity, so the envelope's home would not - // match it and the same remote room would fork into a second `inbound-share` room. Match the values - // the room already has as well; the envelope still wins for what gets recorded. - const storedHome = event.fromHome || '' - const storedHandle = event.fromHandle || '' - if ((storedHome && storedHome !== fromHome) || (storedHandle && storedHandle !== fromHandle)) { - room = inboundOf(data.roomId, storedHome, storedHandle) - } + const claimed = Object.values(state.rooms || {}).find(record => record.origin === 'inbound-share' + && String(record.sharedFrom?.remoteRoomId) === String(data.roomId)) + if (claimed) return { handled: true, refused: 'not-inviter', room: claimed } + // Only a join projects a room here. A line for a remote room nobody shared with this home used to + // create one, named from its own payload (#909). + if (data.op !== 'join') return { handled: true, refused: 'unknown-room' } } const ticket = typeof data.ticket === 'string' && TICKET_SHAPE.test(data.ticket) ? data.ticket : undefined // #884: a join that says it comes through a link is one this home claimed: that inviter, that room, @@ -1870,8 +1886,8 @@ export function install(rt) { room = await rt.recordInboundSharedRoom({ name: data.name, inviterName: fromName, - inviterHome: fromHome, - inviterHandle: fromHandle, + inviterHome: fromHome || undefined, + inviterHandle: fromHandle || undefined, invitedHandle: data.invitedHandle, localBotId, ownerId, @@ -1906,6 +1922,8 @@ export function install(rt) { } } if (data.name) room.name = cleanPeerName(data.name, room.name) + // The name is display and follows the envelope (#873); the identity follows the transport, so this + // path can never rewrite whose room it is (#909). room.sharedFrom = { ...room.sharedFrom, inviterName: fromName, @@ -1920,14 +1938,19 @@ export function install(rt) { if (data.op === 'line') { const who = cleanPeerName(data.who || transportName, transportName) const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0 + // The author claim follows the same rule (#909): a handle this room cannot trace — to the transport + // sender, to one of its own remote seats, or to its inviter — is shown as the Bot that carried the + // envelope, never as that handle. The line itself is kept: a forged claim must not silence a message. + const author = verifiedAuthor(data, room, event) const posted = append(room, rt.authoredRoomLine(room, { who, text: data.text || '', - authorId: data.authorId || fromHandle, - authorKind: data.authorKind || 'human', + authorId: author.authorId, + authorKind: author.authorKind, + ...(author.unverified ? { unverified: true } : {}), remote: true, hop, - ...(data.authorKind === 'bot' ? { botId: data.authorId || fromHandle } : {}), + ...(author.authorKind === 'bot' ? { botId: author.authorId } : {}), kind: data.kind === 'message' ? undefined : data.kind, })) // #825: the line names Bots, and one of them is mine. The remote home resolved the call where diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 27cc443f..c39880f6 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -1476,3 +1476,102 @@ test('#852: the knock asks the room record, and only the accept admits one home' assert.deepEqual(rt.state.rooms.war.localInvites.find(i => i.ticket === fresh.ticket).homes, {}, 'the refused add left no home on the link') assert.equal(seats().length, 1, 'and seated nobody') }) + + +// #909: an inbound envelope may say who invited a room for display, but it never decides which room it +// may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it, +// and that is the only identity this side can check. +test('#909: a join from another home cannot rewrite or enter a mirror room it does not own', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1, + }) + const { room } = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', + text: join, localBotId: 'spec-b', + }, actorB) + const seats = () => rt.state.rooms[room.id].participants.length + const before = seats() + const at = rt.state.rooms[room.id].sharedFrom.inviterName + // Same remote room id, claiming to be the room's inviter, delivered by a home that is not it. + const forged = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Mallory', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@mallory.bot.home', seq: 2, + }) + const result = await rt.applyInboundRoomShare({ + fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory', + text: forged, localBotId: 'spec-b', + }, actorB) + assert.equal(result.refused, 'not-inviter', 'the transport home is not the inviter of this room') + assert.equal(rt.state.rooms[room.id].sharedFrom.inviterName, at, 'the room still says who invited it') + assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHandle, '@chief.alice.home') + assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHome, '@alice.home') + assert.equal(seats(), before, 'nobody was seated by the refused share') + assert.equal(Object.values(rt.state.rooms).filter(r => r.origin === 'inbound-share' && r.sharedFrom?.remoteRoomId === 'alice-war').length, 1, 'still one mirror room') +}) + +test('#909: a bare line envelope projects no room', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const rooms = () => Object.values(rt.state.rooms).filter(room => room.origin === 'inbound-share').length + const before = rooms() + // A room id that exists only on the sender's side: a line must not open a copy here, whichever name it + // gives itself as the inviter. + const line = encodeRoomShare({ + op: 'line', roomId: 'nobody-shared-this', name: 'Pantry', inviterName: 'Mallory', + inviterHome: '@mallory.home', inviterHandle: '@mallory.bot.home', invitedHandle: '@coder.bob.home', + seq: 1, who: 'Mallory', authorKind: 'bot', authorId: '@mallory.bot.home', text: 'Mallory: hi', + }) + const result = await rt.applyInboundRoomShare({ + fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory', + text: line, localBotId: 'spec-b', + }, actorB) + assert.equal(result.refused, 'unknown-room') + assert.equal(result.room, undefined, 'nothing to write into') + assert.equal(rooms(), before, 'no room appeared') +}) + +test('#909: a line is never shown under a handle the room cannot trace', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1, + }) + const { room } = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', + text: join, localBotId: 'spec-b', + }, actorB) + const delivery = { fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', localBotId: 'spec-b' } + // A handle that is neither the sender, a seat of this room, nor its inviter. + await rt.applyInboundRoomShare({ + ...delivery, + text: encodeRoomShare({ + op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home', + invitedHandle: '@coder.bob.home', seq: 2, who: 'The CEO', authorKind: 'bot', authorId: '@ceo.alice.home', text: 'The CEO: ship it', + }), + }, actorB) + const forged = rt.state.rooms[room.id].log.find(entry => /ship it/.test(entry.text || '')) + assert.equal(forged.authorId, '@chief.alice.home', 'the Bot that delivered it, not the claimed handle') + assert.equal(forged.authorKind, 'bot') + assert.equal(forged.unverified, true, 'the claim is marked, and the line is still there') + // The sender's own claim is traced, so a room line still reads as its Bot wrote it. + await rt.applyInboundRoomShare({ + ...delivery, + text: encodeRoomShare({ + op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home', + invitedHandle: '@coder.bob.home', seq: 3, authorKind: 'bot', authorId: '@chief.alice.home', text: 'mine', + }), + }, actorB) + const own = rt.state.rooms[room.id].log.find(entry => /mine/.test(entry.text || '')) + assert.equal(own.authorId, '@chief.alice.home') + assert.equal(own.unverified, undefined) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index f49c3fd9..c449d702 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -149,7 +149,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### Inbound shared rooms -- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. Which identity a projection records is the envelope's, not the transport's (#873): `applyInboundRoomShare` prefers `data.inviterName` / `inviterHome` / `inviterHandle` and keeps `event.from*` only as the fallback, so a room carried here by a Chief's Bot is still named after the person who invited it. The lookup matches the values a room already stores as well, so a projection recorded under the transport's identity before this fix resolves to the same room instead of forking a second `inbound-share` room; a line keeps its own `who`, or the sender when it names nobody, never the inviter. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. +- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. A projection is **named** from the envelope and **matched** by the transport (#873, #909): `applyInboundRoomShare` still shows `data.inviterName` (so a room carried here by a Chief's Bot reads after the person who invited it), but which room a share may touch is decided by `event.fromHome` / `event.fromHandle` — a share naming a room of mine while arriving from a home that room does not belong to is refused `not-inviter`, and the recorded `sharedFrom` identity only ever follows the transport, so an envelope can be displayed but never re-write whose room it is. Only `op: 'join` projects a room: a `line` envelope for an unknown remote room is refused `unknown-room` instead of opening one named by its own payload. A line keeps its own `who`, or the sender when it names nobody, never the inviter; its `authorId` follows the payload only when the handle traces to the transport sender, a remote seat of that room, or its inviter — otherwise the line is shown as the Bot that delivered it with `unverified: true`, and it is still kept so a forged claim cannot silence a message. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode. - A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the handles a line calls in the envelope's `calls` (resolved where the room's mentions are resolved, so a fenced or quoted `@name` is literal text), plus its `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence. ### A local room's invite link (#852) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 03ea1847..ffda33ad 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -1757,6 +1757,22 @@ export function install(rt) { await rt.save() } + // Who may be shown as a line's author (#909). A handle is a claim the payload makes, so it is shown only + // when it can be traced here: the transport sender itself, a remote seat this room holds, or its inviter. + // Anything else is shown as the sender (a Bot), marked unverified. A non-handle id is a person or a Bot of + // the other home (#873): this side never resolves those, so the claim stands as it did before. + function verifiedAuthor(data, room, event) { + const claim = typeof data.authorId === 'string' ? data.authorId : '' + const kind = data.authorKind === 'bot' ? 'bot' : 'human' + const sender = String(event.fromHandle ?? '') + if (!claim) return sender ? { authorId: sender, authorKind: 'bot' } : { authorId: undefined, authorKind: 'human' } + if (!claim.startsWith('@')) return { authorId: claim, authorKind: kind } + const traced = claim === sender + || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim)) + || room.sharedFrom?.inviterHandle === claim + return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true } + } + function inboundOf(remoteRoomId, inviterHome, inviterHandle) { return Object.values(state.rooms || {}).find((room) => { if (room.origin !== 'inbound-share') return false @@ -1802,11 +1818,11 @@ export function install(rt) { rt.applyInboundRoomShare = async function applyInboundRoomShare(event = {}, actor) { const data = parseRoomShare(event.text) if (!data) return undefined - // The envelope names the person who invited; the event names the Bot that carried the line here (#873). - // Prefer the envelope for the identity, and keep the transport only as the fallback, so the room reads - // "Shared room from Alice" rather than after the sender's Bot. - const fromHandle = data.inviterHandle || event.fromHandle - const fromHome = data.inviterHome || event.fromHome + // The transport names the Bot that delivered this, and that is the only identity this side can check: + // the envelope's inviter fields are display (#873 keeps its "Shared room from Alice"), never the gate + // that decides which room a share may touch (#909). + const fromHandle = String(event.fromHandle ?? '') + const fromHome = String(event.fromHome ?? '') const fromName = cleanPeerName(data.inviterName || event.fromName, 'a friend') // A chat line is attributed to its speaker (`who`) and otherwise to the sender, never to the inviter: // this fix is about the room's identity, not about who wrote inside it. @@ -1827,16 +1843,16 @@ export function install(rt) { // A line for a room of mine from someone it does not seat is dropped: it must not open a copy here. const mine = rt.roomOf(String(data.roomId ?? '')) if (data.op === 'line' && mine && mine.origin !== 'inbound-share' && !isFaceRoom(mine)) return { handled: true, refused: 'not-seated' } + // Matched by the transport only (#909): a share that names a room of mine while arriving from a home + // that room does not belong to is refused, not merged in through a fallback lookup. let room = inboundOf(data.roomId, fromHome, fromHandle) if (!room) { - // A share recorded before this fix stores the transport's identity, so the envelope's home would not - // match it and the same remote room would fork into a second `inbound-share` room. Match the values - // the room already has as well; the envelope still wins for what gets recorded. - const storedHome = event.fromHome || '' - const storedHandle = event.fromHandle || '' - if ((storedHome && storedHome !== fromHome) || (storedHandle && storedHandle !== fromHandle)) { - room = inboundOf(data.roomId, storedHome, storedHandle) - } + const claimed = Object.values(state.rooms || {}).find(record => record.origin === 'inbound-share' + && String(record.sharedFrom?.remoteRoomId) === String(data.roomId)) + if (claimed) return { handled: true, refused: 'not-inviter', room: claimed } + // Only a join projects a room here. A line for a remote room nobody shared with this home used to + // create one, named from its own payload (#909). + if (data.op !== 'join') return { handled: true, refused: 'unknown-room' } } const ticket = typeof data.ticket === 'string' && TICKET_SHAPE.test(data.ticket) ? data.ticket : undefined // #884: a join that says it comes through a link is one this home claimed: that inviter, that room, @@ -1870,8 +1886,8 @@ export function install(rt) { room = await rt.recordInboundSharedRoom({ name: data.name, inviterName: fromName, - inviterHome: fromHome, - inviterHandle: fromHandle, + inviterHome: fromHome || undefined, + inviterHandle: fromHandle || undefined, invitedHandle: data.invitedHandle, localBotId, ownerId, @@ -1906,6 +1922,8 @@ export function install(rt) { } } if (data.name) room.name = cleanPeerName(data.name, room.name) + // The name is display and follows the envelope (#873); the identity follows the transport, so this + // path can never rewrite whose room it is (#909). room.sharedFrom = { ...room.sharedFrom, inviterName: fromName, @@ -1920,14 +1938,19 @@ export function install(rt) { if (data.op === 'line') { const who = cleanPeerName(data.who || transportName, transportName) const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0 + // The author claim follows the same rule (#909): a handle this room cannot trace — to the transport + // sender, to one of its own remote seats, or to its inviter — is shown as the Bot that carried the + // envelope, never as that handle. The line itself is kept: a forged claim must not silence a message. + const author = verifiedAuthor(data, room, event) const posted = append(room, rt.authoredRoomLine(room, { who, text: data.text || '', - authorId: data.authorId || fromHandle, - authorKind: data.authorKind || 'human', + authorId: author.authorId, + authorKind: author.authorKind, + ...(author.unverified ? { unverified: true } : {}), remote: true, hop, - ...(data.authorKind === 'bot' ? { botId: data.authorId || fromHandle } : {}), + ...(author.authorKind === 'bot' ? { botId: author.authorId } : {}), kind: data.kind === 'message' ? undefined : data.kind, })) // #825: the line names Bots, and one of them is mine. The remote home resolved the call where diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 27cc443f..c39880f6 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -1476,3 +1476,102 @@ test('#852: the knock asks the room record, and only the accept admits one home' assert.deepEqual(rt.state.rooms.war.localInvites.find(i => i.ticket === fresh.ticket).homes, {}, 'the refused add left no home on the link') assert.equal(seats().length, 1, 'and seated nobody') }) + + +// #909: an inbound envelope may say who invited a room for display, but it never decides which room it +// may touch, which room gets created, or who wrote a line. The transport names the Bot that delivered it, +// and that is the only identity this side can check. +test('#909: a join from another home cannot rewrite or enter a mirror room it does not own', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1, + }) + const { room } = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', + text: join, localBotId: 'spec-b', + }, actorB) + const seats = () => rt.state.rooms[room.id].participants.length + const before = seats() + const at = rt.state.rooms[room.id].sharedFrom.inviterName + // Same remote room id, claiming to be the room's inviter, delivered by a home that is not it. + const forged = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Mallory', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@mallory.bot.home', seq: 2, + }) + const result = await rt.applyInboundRoomShare({ + fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory', + text: forged, localBotId: 'spec-b', + }, actorB) + assert.equal(result.refused, 'not-inviter', 'the transport home is not the inviter of this room') + assert.equal(rt.state.rooms[room.id].sharedFrom.inviterName, at, 'the room still says who invited it') + assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHandle, '@chief.alice.home') + assert.equal(rt.state.rooms[room.id].sharedFrom.inviterHome, '@alice.home') + assert.equal(seats(), before, 'nobody was seated by the refused share') + assert.equal(Object.values(rt.state.rooms).filter(r => r.origin === 'inbound-share' && r.sharedFrom?.remoteRoomId === 'alice-war').length, 1, 'still one mirror room') +}) + +test('#909: a bare line envelope projects no room', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const rooms = () => Object.values(rt.state.rooms).filter(room => room.origin === 'inbound-share').length + const before = rooms() + // A room id that exists only on the sender's side: a line must not open a copy here, whichever name it + // gives itself as the inviter. + const line = encodeRoomShare({ + op: 'line', roomId: 'nobody-shared-this', name: 'Pantry', inviterName: 'Mallory', + inviterHome: '@mallory.home', inviterHandle: '@mallory.bot.home', invitedHandle: '@coder.bob.home', + seq: 1, who: 'Mallory', authorKind: 'bot', authorId: '@mallory.bot.home', text: 'Mallory: hi', + }) + const result = await rt.applyInboundRoomShare({ + fromHandle: '@mallory.bot.home', fromHome: '@mallory.home', fromName: 'Mallory', + text: line, localBotId: 'spec-b', + }, actorB) + assert.equal(result.refused, 'unknown-room') + assert.equal(result.room, undefined, 'nothing to write into') + assert.equal(rooms(), before, 'no room appeared') +}) + +test('#909: a line is never shown under a handle the room cannot trace', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1, + }) + const { room } = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', + text: join, localBotId: 'spec-b', + }, actorB) + const delivery = { fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Chief', localBotId: 'spec-b' } + // A handle that is neither the sender, a seat of this room, nor its inviter. + await rt.applyInboundRoomShare({ + ...delivery, + text: encodeRoomShare({ + op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home', + invitedHandle: '@coder.bob.home', seq: 2, who: 'The CEO', authorKind: 'bot', authorId: '@ceo.alice.home', text: 'The CEO: ship it', + }), + }, actorB) + const forged = rt.state.rooms[room.id].log.find(entry => /ship it/.test(entry.text || '')) + assert.equal(forged.authorId, '@chief.alice.home', 'the Bot that delivered it, not the claimed handle') + assert.equal(forged.authorKind, 'bot') + assert.equal(forged.unverified, true, 'the claim is marked, and the line is still there') + // The sender's own claim is traced, so a room line still reads as its Bot wrote it. + await rt.applyInboundRoomShare({ + ...delivery, + text: encodeRoomShare({ + op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', inviterHome: '@alice.home', + invitedHandle: '@coder.bob.home', seq: 3, authorKind: 'bot', authorId: '@chief.alice.home', text: 'mine', + }), + }, actorB) + const own = rt.state.rooms[room.id].log.find(entry => /mine/.test(entry.text || '')) + assert.equal(own.authorId, '@chief.alice.home') + assert.equal(own.unverified, undefined) +})