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
1 change: 1 addition & 0 deletions changelog.d/923-room-link-seat-pair.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A group that a friend's Bot joined through an invite link now reaches that guest instead of asking Bot Social about a friendship the two homes never had: the seat the link admitted is the one pair the link opened, read from the seat itself or from the link's own record of who it admitted, so a group message or update goes out under the room rather than sitting on an approval that could not be approved. A Bot added to the group by hand is not a link pair and is unchanged ([#923](https://github.com/ThinkFlowLab/FlyBot/issues/923)).
33 changes: 30 additions & 3 deletions core/src/host/reach.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,22 @@ function remoteInRoom(state, roomId, peer) {
})
}

// The seats each link of this room admitted: the invite's own `homes` (home → the handles it seated)
// is written on the accept and survives even when the seat record itself did not keep the ticket.
function linkSeats(room) {
const out = new Map()
const invites = [room?.localInvite, ...(Array.isArray(room?.localInvites) ? room.localInvites : [])]
for (const invite of invites) {
if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) continue
for (const handles of Object.values(invite.homes ?? {})) {
for (const handle of Array.isArray(handles) ? handles : []) {
if (typeof handle === 'string' && handle && !out.has(handle)) out.set(handle, invite)
}
}
}
return out
}

// #883, #884: the only pairs a room link opens, as { from: local Bot id, to: remote handle, scope:
// the room id Bot Social knows the room by, ticket? }. A seat in a room of this home is reached from
// the Bot its link named; a room of another home that a ticket seated Bots of mine in is reached from
Expand All @@ -222,10 +238,21 @@ export function ticketPeers(state) {
}
continue
}
const linkOf = linkSeats(room)
for (const p of live) {
if (p.kind !== 'bot' || p.remote !== true || p.admittedBy !== 'ticket' || !p.hostBotId) continue
if (!(room.members ?? []).includes(p.hostBotId)) continue
peers.push({ from: p.hostBotId, to: handleOf(p), scope: String(room.id), ticket: p.ticket })
if (p.kind !== 'bot' || p.remote !== true) continue
// #923: a seat is a pair when a link of this room admitted it, whether the seat itself kept the
// ticket (`admittedBy: 'ticket'`) or the link's own record of who it admitted is the only trace
// — a room opened before the seat carried it must reach that seat the same way, or its group
// envelopes fall back to a friendship the two homes never had. A friend's Bot added by hand is
// named by no link and stays out.
const ticket = p.admittedBy === 'ticket' ? p.ticket : linkOf.get(handleOf(p))?.ticket
if (!ticket) continue
// The seat names the Bot of this home whose handle it knocked on; a seat written before that
// was recorded is reached from the room's own lead, which is the Bot its link was minted for.
const host = p.hostBotId && (room.members ?? []).includes(p.hostBotId) ? p.hostBotId : room.admin
if (!host || !(room.members ?? []).includes(host)) continue
peers.push({ from: host, to: handleOf(p), scope: String(room.id), ticket })
}
}
for (const [ticket, claim] of Object.entries(state.roomClaims ?? {})) {
Expand Down
11 changes: 10 additions & 1 deletion core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,11 @@ export function install(rt) {
}
const kind = draft.kind
const viaCode = opts?.viaCode === true && draft.kind === 'bot' && !isRemoteDraft(draft)
// #923: a seat one of this room's links admitted names the pair reach.js enumerates. The link's
// ticket, and the Bot of this home whose handle the guest knocked on, are what make that pair
// visible; without them the seat's envelopes fall back to a friendship the two homes never had.
const seatTicket = typeof opts?.ticket === 'string' && opts.ticket ? opts.ticket : undefined
const seatHostBotId = seatTicket && typeof opts?.hostBotId === 'string' && opts.hostBotId ? opts.hostBotId : undefined
const published = assertAddable(room, draft, actor, { viaCode })
const role = draft.role === 'admin' ? 'admin' : 'member'
const invitedBy = actor?.memberId
Expand Down Expand Up @@ -646,6 +651,8 @@ export function install(rt) {
invitedAt,
joinedSeq: line.seq,
joinedAt: invitedAt,
...(seatTicket ? { admittedBy: 'ticket', ticket: seatTicket } : {}),
...(seatHostBotId ? { hostBotId: seatHostBotId } : {}),
}
room.participants.push(record)
append(room, {
Expand Down Expand Up @@ -1546,7 +1553,9 @@ export function install(rt) {
void rt.notifyRoomShare?.(room, { op: 'join', handle: asker })
} else {
const ownerId = actor?.memberId ?? Object.keys(state.members || {})[0]
await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined))
// #923: the seat a link admitted keeps the link's ticket and the Bot whose handle it knocked
// on, so `reach.js` can enumerate the one pair this link opened.
await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined), { ticket: invite.ticket, hostBotId: invite.botId ?? room.admin })
}
} catch (error) {
// Nothing was added, so the record must not say it seated anyone.
Expand Down
41 changes: 40 additions & 1 deletion core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
roomVisibleTo,
viewerRole,
} from '../src/host/room-membership.js'
import { install as installReach, reach } from '../src/host/reach.js'
import { install as installReach, reach, ticketPeers } from '../src/host/reach.js'
import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js'
import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../src/shared/room-share.js'
import { fenceUntrusted } from '../src/host/delivery.js'
Expand Down Expand Up @@ -1477,6 +1477,45 @@ test('#852: the knock asks the room record, and only the accept admits one home'
assert.equal(seats().length, 1, 'and seated nobody')
})

// #923: a room link seats a Bot of another home, and that seat is the one pair the link opened: the
// room's own envelopes to it are the room's consent, not a friendship the two homes never had. A seat
// recorded before it kept the ticket (or by a path that did not) is the same pair, read from the link's
// own record of who it admitted; a friend's Bot added by hand is not.
test('#923: a link-admitted seat is the pair the link opened, and a hand-added friend is not', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
const { alice, bob } = await signAliceAndBob(rt)
await rt.authenticate(alice.sessionToken)
publish(rt)
await grantToFriend(rt, 'chief-a')
await grantToFriend(rt, 'chief-b')
rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } }
const invite = await rt.mintRoomInvite('war')
const knock = (payload, fromHandle = '@coder.bob.home', fromHome = '@bob.home') => rt.applyInboundRoomShare({
text: encodeRoomShare(payload), fromHandle, fromHome, fromName: 'Coder',
})
const accepted = await knock({ op: 'ask', ticket: invite.ticket, accept: true })
assert.equal(accepted.accepted, true)
const seat = rt.state.rooms.war.participants.find(p => p.remote === true && p.handle === '@coder.bob.home')
assert.equal(seat.admittedBy, 'ticket', 'the accept keeps the link that seated it')
assert.equal(seat.ticket, invite.ticket)
assert.equal(seat.hostBotId, 'chief-a', 'reached from the Bot whose handle it knocked on')
const pairs = () => ticketPeers(rt.state).filter(p => p.to === '@coder.bob.home')
assert.deepEqual(pairs(), [{ from: 'chief-a', to: '@coder.bob.home', scope: 'war', ticket: invite.ticket }], 'one pair, the room Bot Social can check')

// A room opened before the seat kept the ticket: the link's own `homes` still names it, so the pair
// is the same one, and a group envelope to it is the room's, not a friend's.
delete seat.admittedBy
delete seat.ticket
delete seat.hostBotId
assert.deepEqual(pairs().map(p => [p.from, p.ticket]), [['chief-a', invite.ticket]], 'the link record alone still opens the pair')

// A friend's Bot added by hand is nobody's link seat, whatever else is true about it.
rt.state.rooms.war.participants.push({ kind: 'bot', remote: true, id: '@friend.eve.home', handle: '@friend.eve.home', joinedSeq: 999, joinedAt: 2 })
assert.equal(ticketPeers(rt.state).some(p => p.to === '@friend.eve.home'), false, 'a hand-added friend is not a room pair')
void bob
})


// #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,
Expand Down
Loading
Loading