Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 41 additions & 18 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
99 changes: 99 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \<inviter\>** 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 \<inviter\>** 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)
Expand Down
Loading
Loading