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) +})