From 472d11142aa1ddd771b561dd41afea2ae6132ca7 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sat, 24 Oct 2026 10:21:01 +0800 Subject: [PATCH] fix(rooms): list the host's Bots and notice in a guest's copy (#904) A link's join recorded the guest's own Bot and dropped the host's side whole; the join carried no roster and no group notice, so the guest's room listed nobody of the host's and its settings were empty. The join now keeps the Bot that carried it and carries the host's roster and notice; the guest records one remote per entry, shows the unverified and the unconnected honestly, and attributes a line only to a known Bot. Rebased on #909/#910: the transport-only inbound gate and the author rule are main's verifiedAuthor, extended only so a remote seat matches a claim by (home, verified); this branch adds no second gate and no payload fallback. --- changelog.d/904-guest-sees-host-bots.fixed.md | 1 + core/client.js | 9 +- core/src/client/group-settings.js | 10 +- core/src/client/i18n.js | 2 + core/src/host/room-membership.js | 250 ++++++++++- core/src/shared/room-share.js | 33 ++ core/test/remote-room.test.js | 387 ++++++++++++++++++ core/test/room-ticket-seat.test.js | 3 +- docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 4 +- patches/ds-bot/0008-client-bundle.patch | 13 +- patches/ds-bot/0012-room-membership.patch | 12 +- .../ds-bot/files/src/host/room-membership.js | 250 ++++++++++- patches/ds-bot/files/src/shared/room-share.js | 33 ++ patches/ds-bot/files/test/remote-room.test.js | 387 ++++++++++++++++++ .../files/test/room-ticket-seat.test.js | 3 +- 17 files changed, 1369 insertions(+), 32 deletions(-) create mode 100644 changelog.d/904-guest-sees-host-bots.fixed.md diff --git a/changelog.d/904-guest-sees-host-bots.fixed.md b/changelog.d/904-guest-sees-host-bots.fixed.md new file mode 100644 index 00000000..5c474330 --- /dev/null +++ b/changelog.d/904-guest-sees-host-bots.fixed.md @@ -0,0 +1 @@ +A group joined through an invite link now lists the host's Bots instead of only your own: the join notice carries the host room's Bot roster, the Members card shows each Bot by name and owner, and a message keeps the Bot that wrote it. A Bot the host has not connected to Bot Social shows as "Not connected", and a host Bot this side cannot confirm yet shows as "Unconfirmed" rather than as a friend's Bot. diff --git a/core/client.js b/core/client.js index 49294e71..2fcb42e4 100644 --- a/core/client.js +++ b/core/client.js @@ -190,6 +190,8 @@ var ZH = { Retry: "重试", Approve: "批准", "Friend's Bot": "好友的 Bot", + "Unconfirmed": "未确认", + "Not connected": "未接入", "Expand": "展开", "Collapse": "收起", "Setting up your Main Bot…": "正在设置你的主 Bot…", @@ -12574,9 +12576,12 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { { className: "bt-gs-who" }, (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), - room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) + // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is + // labelled as such; a Bot with no Bot Social identity can never be written to at all. + room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) ), - room2.origin === "face-to-face" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. + room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") )), room2.origin === "face-to-face" ? null : adding ? [ ...outsidePeople.map((person) => (0, import_react30.createElement)( diff --git a/core/src/client/group-settings.js b/core/src/client/group-settings.js index 7d6ba2ac..95cfcacb 100644 --- a/core/src/client/group-settings.js +++ b/core/src/client/group-settings.js @@ -186,8 +186,14 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('span', { className: 'bt-gs-who' }, h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), h('span', { className: 'bt-row-title', title: person.handle || undefined }, person.label || remoteBotTitle(person)), - room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), - room.origin === 'face-to-face' || members.length + remotes.length <= 2 + // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is + // labelled as such; a Bot with no Bot Social identity can never be written to at all. + room.origin === 'face-to-face' ? null + : h('span', { className: 'bt-tag' }, person.unconnected ? t('Not connected') + : room.origin === 'inbound-share' && person.verified === false ? t('Unconfirmed') + : t('Friend\'s Bot'))), + // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. + room.origin === 'face-to-face' || room.origin === 'inbound-share' || members.length + remotes.length <= 2 ? null : h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: remoteBotTitle(person) }), title: t('Remove from group'), onClick: () => update({ removeParticipants: [{ kind: 'bot', id: person.handle || person.id, remote: true }] }) }, '×'))), room.origin === 'face-to-face' ? null : adding diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index a109bad5..df96f30f 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -154,6 +154,8 @@ const ZH = { Retry: '重试', Approve: '批准', "Friend's Bot": '好友的 Bot', + 'Unconfirmed': '未确认', + 'Not connected': '未接入', 'Expand': '展开', 'Collapse': '收起', 'Setting up your Main Bot…': '正在设置你的主 Bot…', diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index ffda33ad..e7cd2d7f 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -691,6 +691,7 @@ export function install(rt) { syncMembers(room) await rt.save() if (record?.remote && record.handle) void rt.notifyRoomShare?.(room, { op: 'join', handle: record.handle }) + else if (record?.kind === 'bot' && !record.remote) reshareRoster(room) return room }) } @@ -708,7 +709,9 @@ export function install(rt) { throw forbidden('Only a room admin can remove participants') } const live = currentParticipants(room) - const botsLeft = live.filter(p => p.kind === 'bot' && p.id !== me.id && p.handle !== me.handle) + // A local Bot has no handle: comparing handles alone dropped every local Bot from the count, so + // removing one always tripped the two-Bot rule. Compare by handle only when the entry has one. + const botsLeft = live.filter(p => p.kind === 'bot' && p.id !== me.id && (!me.handle || p.handle !== me.handle)) const ownerLeave = actor && ( (kind === 'human' && me.id === actor.memberId) || (kind === 'bot' && (state.bots?.[me.id]?.ownerId === actor.memberId || me.ownerId === actor.memberId || room.sharedFrom?.ownerId === actor.memberId)) @@ -735,6 +738,8 @@ export function install(rt) { .catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) } else if (kind === 'bot' && room.origin === 'inbound-share' && room.sharedFrom?.ticket) { void leaveTicketRoom(room, me).catch(error => rt.warn?.('ds-bot: leaving %s was not sent: %s', room.name, error)) + } else if (kind === 'bot' && !me.remote) { + reshareRoster(room) } return room }) @@ -953,6 +958,172 @@ export function install(rt) { } } + // #904: a guest's copy learns this home's membership only from a fresh roster. A local add or remove + // therefore has to send one; without this the guest list never changes and a removed Bot keeps writing. + function reshareRoster(room) { + if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) return + if (!(room.participants ?? []).some(p => p.remote)) return + void Promise.resolve(rt.notifyRoomShare?.(room, { op: 'join' })) + .catch(error => rt.warn?.('ds-bot: a membership change was not shared: %s', error)) + } + + // #904: the Bots this home keeps in the room, for the guests' copies of it. Only this home's own Bots: + // another guest's Bot is not this home's to publish. A Bot with no Bot Social identity rides along as + // `connected: false` so the guest can show it as not connected instead of leaving it out entirely. + function roomRoster(room) { + const out = [] + for (const p of currentParticipants(room)) { + if (p.kind !== 'bot' || p.remote) continue + const bot = state.bots?.[p.id] + const member = bot?.fields?.social?.member + const handle = typeof member?.handle === 'string' && /^@[A-Za-z0-9._-]{1,120}$/.test(member.handle) ? member.handle : undefined + const name = bot?.name || p.displayName || p.id + const ownerName = ownerNameSafe(p) || cleanPeerName(member?.ownerName, '') + // #904: (home, handle) is the identity a guest keys on; a handle alone is unique only inside a home. + const home = typeof member?.home === 'string' && /^@[A-Za-z0-9._-]{1,120}$/.test(member.home) ? member.home : '' + const homeField = home ? { home } : {} + out.push(handle ? { handle, name, ...homeField, ...(ownerName ? { ownerName } : {}) } : { name, ...homeField, ...(ownerName ? { ownerName } : {}) }) + } + return out + } + + // #904: a roster is text a stranger wrote. It is read only for the room this mirror is bound to and + // only from that room's own inviter; anything else is dropped whole. Every entry it names stays + // unverified unless this home already holds a fact about that handle (the seat that carried the join, + // or a published friend Bot) — an unverified entry is displayed, never addressed. + function rosterOf(data, room, event) { + if (!Array.isArray(data?.roster)) return undefined + const bound = room?.sharedFrom ?? {} + const same = (a, b) => { + const left = String(a ?? '').trim().toLowerCase() + return left !== '' && left === String(b ?? '').trim().toLowerCase() + } + if (!same(data.roomId, bound.remoteRoomId)) return undefined + if (bound.inviterHome && bound.inviterHandle) { + if (!same(event?.fromHome, bound.inviterHome) || !same(event?.fromHandle, bound.inviterHandle)) return undefined + } else if (bound.inviterHome ? !same(event?.fromHome, bound.inviterHome) : !same(event?.fromHandle, bound.inviterHandle)) return undefined + const seat = String(bound.invitedHandle ?? '') + const out = [] + for (const entry of data.roster.slice(0, 32)) { + const name = cleanPeerName(entry?.name, '') + const ownerName = cleanPeerName(entry?.ownerName, '') + const handle = String(entry?.handle ?? '').trim() + // The entry's home is identity, not trust: verification stays on the transport's home. + const entryHome = String(entry?.home ?? '').trim() + const ownerHome = /^@[A-Za-z0-9._-]{1,120}$/.test(entryHome) ? entryHome : bound.inviterHome + if (!handle) { + if (name) out.push({ name, ownerName, ownerHome, connected: false }) + continue + } + if (!/^@[A-Za-z0-9._-]{1,120}$/.test(handle)) continue + out.push({ + handle, + ownerHome, + name: name || handle, + ownerName, + verified: (handle === seat && same(ownerHome, bound.inviterHome)) || Boolean(publishedRemoteOf(state, handle)), + }) + } + return out + } + + // #904: the host room's notice rides the join like the roster (the guest's group settings were empty). + // It is stranger-written text too, so it is read only for the room this mirror is bound to and only + // from that room's own inviter; an empty value is not encoded, so it never wipes a notice. + function noticeOf(data, room, event) { + if (data?.op !== 'join' || typeof data.notice !== 'string') return undefined + const text = data.notice.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, 2000) + const bound = room?.sharedFrom ?? {} + const same = (a, b) => { + const left = String(a ?? '').trim().toLowerCase() + return left !== '' && left === String(b ?? '').trim().toLowerCase() + } + if (bound.remoteRoomId && !same(data.roomId, bound.remoteRoomId)) return undefined + if (bound.inviterHome && !same(event?.fromHome, bound.inviterHome)) return undefined + if (bound.inviterHandle && !same(event?.fromHandle, bound.inviterHandle)) return undefined + return text + } + + // #904: one remote participant per roster entry. A roster is the whole list, so an entry it drops is a + // Bot the host no longer keeps here: it leaves this mirror's list, and nothing of it stays addressable. + function applyRoster(room, entries) { + if (!Array.isArray(entries)) return false + let changed = false + const seen = new Set() + const seat = String(room.sharedFrom?.invitedHandle ?? '') + const boundHome = room.sharedFrom?.inviterHome + const sameHome = (a, b) => { + const left = String(a ?? '').trim().toLowerCase() + return left !== '' && left === String(b ?? '').trim().toLowerCase() + } + const at = Date.now() + for (const entry of entries) { + const key = entry.handle || `not-connected:${entry.name.toLowerCase()}` + const ownerHome = entry.ownerHome || boundHome + seen.add(key) + // #904: one entry per (home, handle), so a same-named Bot of another home is a different Bot. + const found = (room.participants ?? []).find(p => p.remote && (p.handle === key || p.id === key) + && (!entry.handle || sameHome(p.ownerHome ?? boundHome, ownerHome))) + if (found) { + if (found.leftSeq !== undefined) { + delete found.leftSeq + delete found.leftAt + changed = true + } + if (entry.name && found.displayName !== entry.name) { + found.displayName = entry.name + changed = true + } + if (entry.ownerName && found.ownerName !== entry.ownerName) { + found.ownerName = entry.ownerName + changed = true + } + if (entry.handle && entry.handle === seat && found.verified !== true) { + found.verified = true + changed = true + } + continue + } + room.participants = [...(room.participants ?? []), entry.handle ? { + kind: 'bot', + remote: true, + id: entry.handle, + handle: entry.handle, + ownerHome, + ownerName: entry.ownerName, + displayName: entry.name, + role: 'member', + fromRoster: true, + verified: entry.verified === true, + roomOnly: true, + joinedSeq: room.seq ?? 0, + joinedAt: at, + } : { + kind: 'bot', + remote: true, + id: key, + unconnected: true, + ownerName: entry.ownerName, + displayName: entry.name, + role: 'member', + fromRoster: true, + verified: false, + roomOnly: true, + joinedSeq: room.seq ?? 0, + joinedAt: at, + }] + changed = true + } + for (const p of room.participants ?? []) { + if (!p.fromRoster || p.leftSeq !== undefined) continue + if (seen.has(p.handle || p.id)) continue + p.leftSeq = room.seq ?? 0 + p.leftAt = at + changed = true + } + return changed + } + // Membership notice: the add/remove click is the owner's consent, so this does not wait // for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees. rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) { @@ -973,6 +1144,7 @@ export function install(rt) { invitedHandle: remote.handle, seq: room.seq ?? 0, ticket: remote.ticket, + ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}), ...seat.from, }, 'room-share', seat.botId, room.id) continue @@ -984,6 +1156,7 @@ export function install(rt) { name: room.name, invitedHandle: remote.handle, seq: room.seq ?? 0, + ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}), ...from, }, 'room-share') } @@ -1477,6 +1650,9 @@ export function install(rt) { const who = cleanPeerName(data.who, remoteBotTitle(seat)) const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0 const human = data.authorKind !== 'bot' + // #904: this path never reads the envelope's `authorId`: `ownSeatOf` matched the seat by the + // transport's own `fromHandle`, so `seat.handle` is already the Bot that wrote the line, while a + // claim inside the envelope is only the guest's word. A guest cannot speak as another Bot here. const posted = append(room, rt.authoredRoomLine(room, { who, text: data.text || '', @@ -1767,8 +1943,17 @@ export function install(rt) { 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 } + // #904: a remote seat stands for the handle only when this home verified it and it belongs to the + // home the transport names: two homes may carry the same handle, and an unverified roster entry is a + // display fact, never a signature. + const sameHome = (left, right) => { + const a = String(left ?? '').trim().toLowerCase() + return a !== '' && a === String(right ?? '').trim().toLowerCase() + } const traced = claim === sender - || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim)) + || currentParticipants(room).some(p => p.remote === true && p.leftSeq === undefined + && p.verified === true && (p.handle === claim || p.id === claim) + && sameHome(p.ownerHome ?? room.sharedFrom?.inviterHome, event.fromHome)) || room.sharedFrom?.inviterHandle === claim return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true } } @@ -1894,6 +2079,8 @@ export function install(rt) { remoteRoomId: data.roomId, joinedSeq: data.seq, ...(ticket && data.op === 'join' ? { ticket } : {}), + ...(data.op === 'join' ? { roster: rosterOf(data, { sharedFrom: { remoteRoomId: data.roomId, inviterHome: event.fromHome, inviterHandle: event.fromHandle, invitedHandle: data.invitedHandle } }, event) } : {}), + ...(typeof noticeOf(data, undefined, event) === 'string' ? { notice: noticeOf(data, undefined, event) } : {}), }, actor || (ownerId ? { memberId: ownerId } : undefined)) } else { delete room.hidden @@ -1914,11 +2101,15 @@ export function install(rt) { }] room.sharedFrom = { ...room.sharedFrom, ticket } } - for (const p of room.participants ?? []) { - if (seatedHere && p.id !== localBotId) continue - if (p.leftSeq !== undefined) { - delete p.leftSeq - delete p.leftAt + // #904: only a join brings anyone back. A line on an existing mirror must not clear the soft leaves + // a roster just set, or a plain message would quietly undo a removal. + if (data.op === 'join') { + for (const p of room.participants ?? []) { + if (seatedHere && p.id !== localBotId) continue + if (p.leftSeq !== undefined) { + delete p.leftSeq + delete p.leftAt + } } } if (data.name) room.name = cleanPeerName(data.name, room.name) @@ -1932,6 +2123,11 @@ export function install(rt) { invitedHandle: data.invitedHandle || room.sharedFrom?.invitedHandle, remoteRoomId: data.roomId, } + // #904: the host re-sends the whole roster when its membership changes, so this list is the + // current one; a Bot it no longer names leaves this mirror. + applyRoster(room, data.op === 'join' ? rosterOf(data, room, event) : undefined) + const notice = noticeOf(data, room, event) + if (typeof notice === 'string') room.notice = notice syncMembers(room) } let called = [] @@ -1980,6 +2176,10 @@ export function install(rt) { if (!ownerId) throw invalid('Sign in first') const handle = draft.invitedHandle || draft.handle const localBotId = draft.localBotId && state.bots?.[draft.localBotId] ? draft.localBotId : undefined + // #904: `invitedHandle` on this side names one of my own Bots (the share addressed it), so the + // host's Bot to record is the one that carried the join. A handle this home owns is never a remote. + const seatHandle = typeof draft.inviterHandle === 'string' && draft.inviterHandle.startsWith('@') ? draft.inviterHandle : undefined + const mineHandles = new Set(Object.values(state.bots ?? {}).map(bot => bot?.fields?.social?.member?.handle).filter(own => typeof own === 'string')) const at = Date.now() const inviterName = cleanPeerName(draft.inviterName, 'a friend') const title = cleanPeerName(draft.name, sharedRoomTitle(inviterName)) @@ -2026,7 +2226,35 @@ export function install(rt) { joinedAt: draft.invitedAt ?? at, invitedByName: inviterName, invitedAt: draft.invitedAt ?? at, - }] : handle ? [{ + }] : []), + // #904: the host's seat Bot is a participant of its own, not the alternative to mine. A guest + // always brings a Bot (`localBotId`), so the old either/or dropped the host's side whole — the + // reported "they cannot see my Bots". The seat is the Bot that carried the join (the inviter), + // never `invitedHandle`: on this side that is one of my own Bots. It keeps the shape a ticket + // seat has here (#884). + ...(seatHandle && !mineHandles.has(seatHandle) ? [{ + kind: 'bot', + remote: true, + id: seatHandle, + handle: seatHandle, + ownerHome: draft.inviterHome, + ownerName: inviterName, + displayName: cleanPeerName(draft.inviterBotName, seatHandle), + role: 'member', + ...(typeof draft.ticket === 'string' && draft.ticket ? { admittedBy: 'ticket', ticket: draft.ticket } : {}), + verified: true, + roomOnly: true, + joinedSeq: draft.joinedSeq ?? 0, + joinedAt: draft.invitedAt ?? at, + invitedByName: inviterName, + invitedAt: draft.invitedAt ?? at, + ownerId, + }] : []), + // The friend path's remote Bot: a share that names a Bot of another home and brought none of + // mine. The either/or this replaces kept one of the two, and a share may legitimately name both. + // When this home brought a Bot, `invitedHandle` names one of my own Bots (the share addressed it): + // recording it as remote would list my own Bot twice, once under each name. + ...(handle && handle !== seatHandle && !mineHandles.has(handle) && !(localBotId && handle === draft.invitedHandle) ? [{ kind: 'bot', remote: true, id: handle, @@ -2035,6 +2263,8 @@ export function install(rt) { ownerName: inviterName, displayName: cleanPeerName(draft.botName, handle), role: 'member', + verified: true, + roomOnly: true, joinedSeq: draft.joinedSeq ?? 0, joinedAt: draft.invitedAt ?? at, invitedByName: inviterName, @@ -2044,6 +2274,10 @@ export function install(rt) { ], } state.rooms[id] = room + // #904: the notice the join carried (gated by the caller) is the room's own notice. + if (typeof draft.notice === 'string') room.notice = draft.notice + // #904: the roster the join carried, merged on top of the two above (the seat is already there). + if (applyRoster(room, draft.roster)) syncMembers(room) await rt.save?.() return room } diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index 0edf88c4..818bde3f 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -14,6 +14,27 @@ function clip(value, max = 60) { return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max) } +// #904: a room's roster entry. A Bot inside a real room is a handful, not a crowd; the cap keeps one +// envelope from turning into a member dump a guest has to render. +export const ROSTER_MAX = 32 +// The group notice's own limit in the room editor (core/src/client/group-settings.js). +export const NOTICE_MAX = 2000 + +// #904: a handle is unique only inside its home, so an entry carries the home it belongs to: two homes +// can keep Bots with the same local name, and a guest has to tell them apart when it attributes a line. +// The receiving side verifies against the transport's home; this field is for identity, not for trust. +function rosterEntry(entry = {}) { + const handle = clip(entry.handle, 120) + const home = clip(entry.home, 120) + const out = { name: clip(entry.name) } + if (handle) out.handle = handle + if (/^@[A-Za-z0-9._-]{1,120}$/.test(home)) out.home = home + const ownerName = clip(entry.ownerName) + if (ownerName) out.ownerName = ownerName + if (!handle) out.connected = false + return out +} + export function encodeRoomShare(payload = {}) { const body = { v: 1, @@ -49,6 +70,18 @@ export function encodeRoomShare(payload = {}) { if (Array.isArray(payload.calls) && payload.calls.length > 0) { body.calls = payload.calls.map(handle => clip(handle, 120)).filter(Boolean).slice(0, 24) } + // #904: the host room's Bots ride the membership notice so the guest's mirror can list them. It is + // display data only — the guest still takes reachability from Bot Social, so a roster can never hand + // out a send path. `connected: false` is a Bot with no Bot Social identity: shown, never addressed. + // #904: an explicit roster is encoded even when empty — empty means the host keeps none of its Bots in + // the room any more, while an absent field means "change nothing" (an older host). + if (body.op === 'join' && Array.isArray(payload.roster)) { + body.roster = payload.roster.slice(0, ROSTER_MAX).map(rosterEntry).filter(entry => entry.connected === false || entry.handle) + } + // #904: the host room's notice rides the same join envelope, so the guest's group settings are not + // empty. It is text the room displays, never authority. A present notice is encoded even when empty: + // empty means the host cleared it, while an absent field means "change nothing" (an older host). + if (body.op === 'join' && typeof payload.notice === 'string') body.notice = clip(payload.notice, NOTICE_MAX) return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}` } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index c39880f6..e58bf7a9 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -1575,3 +1575,390 @@ test('#909: a line is never shown under a handle the room cannot trace', async ( assert.equal(own.authorId, '@chief.alice.home') assert.equal(own.unverified, undefined) }) + + +// #904: a guest who joins with a link always brings a Bot, and the participants list treated the local +// Bot and the host's seat Bot as alternatives. The host's side was therefore dropped whole: the guest's +// room listed themselves and their own Bot, nothing of the host's — the reported "they cannot see my Bots". +test('#904: a link join keeps the host\'s seat Bot beside the guest\'s own', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const room = await rt.recordInboundSharedRoom({ + id: 'from-alice', + name: 'War room', + inviterName: 'Alice', + inviterHome: '@alice.home', + inviterHandle: '@chief.alice.home', + invitedHandle: '@spec.b.bob.home', + localBotId: 'spec-b', + remoteRoomId: 'alice-war', + joinedSeq: 2, + ticket: 'AbCdEfGhIjKlMnOpQrStUv', + }, actorB) + const bots = room.participants.filter(p => p.kind === 'bot') + assert.deepEqual(bots.filter(p => !p.remote).map(p => p.id), ['spec-b'], 'the guest\'s own Bot stays') + const seat = bots.find(p => p.remote) + assert.ok(seat, 'the host\'s seat Bot is recorded too, not only the guest\'s Bot') + assert.equal(seat.handle, '@chief.alice.home', 'the Bot that carried the join, never the invited handle') + assert.equal(seat.admittedBy, 'ticket', 'the seat keeps the shape a ticket seat has') + assert.equal(seat.roomOnly, true, 'a seat is reachable inside the room it was admitted to, nowhere else') + assert.equal(seat.verified, true) + // The report's "Members · 2" is You + your own Bot, and the badge on it is the guest's own Bot: admin is + // `localBotId` alone, so a host Bot becoming a participant must not take the badge with it. + assert.equal(room.participants.length, 3, 'you, your own Bot, and the host seat: three') + assert.equal(room.admin, 'spec-b', 'the guest\'s own Bot stays the admin of its mirror') + assert.equal(seat.role, 'member', 'the host seat is a member, never the admin') + // The fix lands in `participants` only: `room.members` is what speaks, leads and renders, and a remote + // handle there would hand the guest a member of its own group. + assert.deepEqual(room.members, ['spec-b'], 'the room members stay local; a remote is never a member') +}) + +// #904: the join notice carries the host room's Bot roster, and the guest lists every Bot in it. What a +// roster decides is who is displayed: an entry this home cannot confirm stays unverified and unreachable. +test('#904: a join roster lists every host Bot, and a line keeps the Bot that wrote it', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + // A published friend Bot in the roster is a fact this home holds, so that entry is verified. + publish(rt, [{ ...CODER, handle: '@writer.a.home', name: 'Writer A', home: '@alice.home', homeName: 'Alice', owner: 'Alice' }]) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@chief.b.alice.home', seq: 4, + notice: 'Be nice to each other.', + roster: [ + { handle: '@chief.a.home', home: '@alice.home', name: 'Chief A', ownerName: 'Alice' }, + { handle: '@chief.b.alice.home', home: '@alice.home', name: 'Chief B', ownerName: 'Alice' }, + { handle: '@writer.a.home', home: '@alice.home', name: 'Writer A', ownerName: 'Alice' }, + { handle: '@ghost.alice.home', home: '@alice.home', name: 'Ghost', ownerName: 'Alice' }, + { name: 'Offline Bot', ownerName: 'Alice' }, + ], + }) + assert.equal(parseRoomShare(join).roster.length, 5, 'the roster rides the existing envelope') + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Alice', + text: join, localBotId: 'spec-b', + }, actorB) + const room = first.room + assert.equal(room.notice, 'Be nice to each other.', 'the group notice rides the join, so the guest\'s settings are not empty') + const live = room.participants.filter(p => p.leftSeq === undefined) + const handles = live.filter(p => p.kind === 'bot' && p.remote).map(p => p.handle ?? p.id).sort() + assert.deepEqual(handles, [ + '@chief.a.home', '@chief.alice.home', '@chief.b.alice.home', '@ghost.alice.home', '@writer.a.home', 'not-connected:offline bot', + ], 'every Bot the host named is listed, and the guest\'s own Bot is not the only one') + const seat = live.find(p => p.handle === '@chief.b.alice.home') + assert.equal(seat.verified, true, 'the seat that carried the join is this home\'s own fact') + assert.equal(live.find(p => p.handle === '@chief.a.home').ownerHome, '@alice.home', 'the roster entry carries the home its handle is unique in') + const named = live.find(p => p.handle === '@writer.a.home') + assert.equal(named.displayName, 'Writer A') + assert.equal(named.ownerName, 'Alice', 'the guest can tell whose Bot it is') + assert.equal(named.verified, true, 'a published friend Bot is this home\'s own fact') + const ghost = live.find(p => p.handle === '@ghost.alice.home') + assert.equal(ghost.verified, false, 'a name in a stranger\'s roster is not proof') + assert.equal(named.roomOnly, true, 'a roster never widens reach') + const offline = live.find(p => p.unconnected === true) + assert.equal(offline.displayName, 'Offline Bot') + assert.equal(offline.handle, undefined, 'a Bot with no identity has no handle to write to') + // The client reads these through presentRoom, which keeps the whole live list. + const shown = presentRoom(rt.state.rooms[room.id], actorB, rt.state) + assert.equal(shown.presentedParticipants.filter(p => p.remote).length, 6) + + // A line keeps the Bot that wrote it — not the seat, not the inviter — when the roster holds it. + const line = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 5, + who: 'Writer A', authorKind: 'bot', authorId: '@writer.a.home', text: 'on it', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: line, localBotId: 'spec-b' }, actorB) + const posted = room.log.filter(entry => String(entry.text).includes('on it')).at(-1) + assert.equal(posted.authorId, '@writer.a.home') + assert.equal(posted.botId, '@writer.a.home') + + // A line that names a Bot this room never verified is said by the inviter, never by that Bot. + const forged = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 6, + who: 'Ghost', authorKind: 'bot', authorId: '@nobody.home', text: 'not me', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: forged, localBotId: 'spec-b' }, actorB) + const forgedLine = room.log.filter(entry => String(entry.text).includes('not me')).at(-1) + assert.equal(forgedLine.authorId, '@chief.alice.home', 'an invented author never speaks as another Bot') + + // A line that names an unverified roster entry is said by the inviter, never by that Bot. + const fromGhost = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 7, + who: 'Ghost', authorKind: 'bot', authorId: '@ghost.alice.home', text: 'not verified', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: fromGhost, localBotId: 'spec-b' }, actorB) + const ghostLine = room.log.filter(entry => String(entry.text).includes('not verified')).at(-1) + assert.equal(ghostLine.authorId, '@chief.alice.home', 'an unverified roster entry never speaks under its own name') + + // A Bot handle is a Bot's line: calling it a person's line in the envelope does not make it one, and an + // unverified handle is still said by the Bot that carried the line and marked unverified. + const humanClaim = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 8, + who: 'Ghost', authorKind: 'human', authorId: '@ghost.alice.home', text: 'as a person', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: humanClaim, localBotId: 'spec-b' }, actorB) + const asPerson = room.log.filter(entry => String(entry.text).includes('as a person')).at(-1) + assert.equal(asPerson.authorKind, 'bot', 'a Bot handle never becomes a person because the envelope says so') + assert.equal(asPerson.authorId, '@chief.alice.home', 'and it is the Bot that carried the line that said it') + assert.equal(asPerson.unverified, true, 'the claim it failed to prove is marked unverified') + assert.equal(asPerson.botId, '@chief.alice.home') + + // A verified Bot's own line needs no marker, and neither does a person's line from the other home. + const person = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 9, + who: 'Alice', authorKind: 'human', authorId: 'owner-a', text: 'a person here', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: person, localBotId: 'spec-b' }, actorB) + const said = room.log.filter(entry => String(entry.text).includes('a person here')).at(-1) + assert.equal(said.authorKind, 'human') + assert.equal(said.authorId, 'owner-a') + assert.equal(said.unverified, undefined, 'a person\'s line from that home is not a Bot claim') + + // The host's next roster is the current one: the Bots it no longer names leave this mirror. + const shrunk = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 7, + roster: [ + { handle: '@chief.a.home', name: 'Chief A', ownerName: 'Alice' }, + { handle: '@chief.b.alice.home', name: 'Chief B', ownerName: 'Alice' }, + ], + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: shrunk, localBotId: 'spec-b' }, actorB) + const after = room.participants.filter(p => p.leftSeq === undefined) + assert.deepEqual(after.filter(p => p.kind === 'bot' && p.remote).map(p => p.handle).sort(), ['@chief.a.home', '@chief.alice.home', '@chief.b.alice.home'], 'the seat that carried the join is not roster-controlled; the two it dropped are gone') + assert.equal(after.some(p => p.id === 'spec-b'), true, 'the guest\'s own Bot is not host-controlled') + // #904: identity is (home, handle). A Bot of another home is a different Bot even when the name + // matches, so a line it did not send cannot borrow its name here. The envelope itself cannot arrive + // from a home this room is not bound to at all (#909 refuses that before any attribution). + room.participants.push({ + kind: 'bot', remote: true, id: '@chief.dup.home', handle: '@chief.dup.home', ownerHome: '@bob.home', + ownerName: 'Bob', displayName: 'Dup', role: 'member', verified: true, roomOnly: true, joinedSeq: 0, joinedAt: 1, + }) + const crossHome = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 12, + who: 'Dup', authorKind: 'bot', authorId: '@chief.dup.home', text: 'cross home', + }) + const intruder = await rt.applyInboundRoomShare({ fromHandle: '@chief.bob.home', fromHome: '@bob.home', text: crossHome, localBotId: 'spec-b' }, actorB) + assert.equal(intruder.refused, 'not-inviter', 'a home this room is not bound to cannot write into it') + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: crossHome, localBotId: 'spec-b' }, actorB) + const cross = room.log.filter(entry => String(entry.text).includes('cross home')).at(-1) + assert.equal(cross.authorId, '@chief.alice.home', 'another home\'s Bot name is not this room\'s Bot') + assert.equal(cross.unverified, true) + + // A removed entry is a soft leave, not a deletion: its history stays resolvable. + const left = room.participants.filter(p => p.leftSeq !== undefined).map(p => p.handle || p.id).sort() + assert.deepEqual(left, ['@ghost.alice.home', '@writer.a.home', 'not-connected:offline bot'], 'a roster drop marks the entry left instead of deleting it') + + // A line must not bring a removed Bot back: only a join does, so a removal cannot be undone by + // whoever keeps talking in the room. + const afterRemoval = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 6, + who: 'Alice', authorKind: 'human', authorId: 'owner-a', text: 'after removal', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: afterRemoval, localBotId: 'spec-b' }, actorB) + assert.deepEqual(room.participants.filter(p => p.leftSeq !== undefined).map(p => p.handle || p.id).sort(), left, 'a line does not resurrect a removed Bot') + + // The same join redelivered changes nothing: no second entry, no second participant. + const count = room.participants.length + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: shrunk, localBotId: 'spec-b' }, actorB) + assert.equal(room.participants.length, count, 'a redelivered join adds no second entry') + + // A Bot the host puts back comes back in place: the existing entry clears its soft leave, no duplicate. + const back = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 8, + roster: [ + { handle: '@chief.a.home', name: 'Chief A', ownerName: 'Alice' }, + { handle: '@chief.b.alice.home', name: 'Chief B', ownerName: 'Alice' }, + { handle: '@writer.a.home', name: 'Writer A', ownerName: 'Alice' }, + ], + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: back, localBotId: 'spec-b' }, actorB) + assert.deepEqual(room.members, ['spec-b'], 'a roster never writes into the room members') + const writers = room.participants.filter(p => p.handle === '@writer.a.home') + assert.equal(writers.length, 1, 'a Bot that comes back reuses its entry') + assert.equal(writers[0].leftSeq, undefined, 'and its soft leave is cleared') + + // The host edits its notice and clears it: both ride the same join. Clearing is a present empty + // field; an absent field (an older host) is not an instruction to change anything. + const edited = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 10, + notice: 'Edited notice.', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: edited, localBotId: 'spec-b' }, actorB) + assert.equal(room.notice, 'Edited notice.', 'an edited notice follows on the next join') + const cleared = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 11, + notice: '', + }) + assert.equal(parseRoomShare(cleared).notice, '', 'a cleared notice is still carried, as an empty field') + assert.equal(parseRoomShare(encodeRoomShare({ op: 'join', roomId: 'alice-war', name: 'War room' })).notice, undefined, 'no notice field means "change nothing"') + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: cleared, localBotId: 'spec-b' }, actorB) + assert.equal(room.notice, '', 'clearing the notice clears the guest\'s copy') + + // #904: a join that carries no roster field is not a request to empty the list. An older host, or a + // peer that drops the field, leaves the guest's participants exactly as they are. + const bare = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 9, + }) + const kept = room.participants.map(p => p.id) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: bare, localBotId: 'spec-b' }, actorB) + assert.deepEqual(room.participants.map(p => p.id), kept, 'a join without a roster field changes nothing') + + // #904: an explicit empty roster clears this home's Bots from the mirror — the seat, my own Bot and the + // person stay. (An absent field, just above, changes nothing.) + const emptied = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 13, + roster: [], + }) + assert.deepEqual(parseRoomShare(emptied).roster, [], 'an explicit empty roster is encoded instead of dropped') + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: emptied, localBotId: 'spec-b' }, actorB) + assert.equal(room.participants.filter(p => p.fromRoster && p.leftSeq === undefined).length, 0, 'an empty roster leaves nobody of the host\'s from the roster') + assert.equal(room.participants.some(p => p.id === 'spec-b' && p.leftSeq === undefined), true, 'my own Bot is not controlled by the roster') + assert.equal(room.participants.some(p => p.handle === '@chief.alice.home' && p.leftSeq === undefined), true, 'the seat that carried the join stays') +}) + +// #904: a roster is stranger-written text, so the envelope that carries one is gated: only the room's +// own inviter, only the room this mirror is bound to, and nothing it says widens reach by itself. +test('#904: a roster from another home or for another room changes no participant', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = { + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@chief.b.alice.home', seq: 1, + notice: 'Alice\'s notice.', + roster: [{ handle: '@chief.a.home', name: 'Chief A', ownerName: 'Alice' }], + } + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: encodeRoomShare(join), localBotId: 'spec-b', + }, actorB) + const room = first.room + const before = room.participants.length + + // Another home claims the same remote room id and names a Bot: the envelope is dropped whole. + const fromElsewhere = { + ...join, seq: 2, inviterHome: '@mallory.home', inviterHandle: '@mallory.home', + roster: [{ handle: '@mallory.bot.home', name: 'Mallory Bot', ownerName: 'Mallory' }], + } + await rt.applyInboundRoomShare({ + fromHandle: '@mallory.home', fromHome: '@mallory.home', text: encodeRoomShare(fromElsewhere), localBotId: 'spec-b', + }, actorB) + assert.equal(rt.state.rooms[room.id].participants.length, before, 'a roster from a home that is not the inviter changes nothing') + assert.equal(rt.state.rooms[room.id].participants.some(p => p.handle === '@mallory.bot.home'), false) + assert.equal(rt.state.rooms[room.id].notice, "Alice's notice.", 'a joiner from another home does not rewrite the notice') + + // A roster sent for another room id does not touch this mirror either. + const elsewhere = { ...join, roomId: 'other-room', seq: 3, notice: 'Mallory was here.', roster: [{ handle: '@eve.home', name: 'Eve', ownerName: 'Eve' }] } + await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: encodeRoomShare(elsewhere), localBotId: 'spec-b', + }, actorB) + assert.equal(rt.state.rooms[room.id].participants.some(p => p.handle === '@eve.home'), false, 'another room id opens no slot here') + assert.equal(rt.state.rooms[room.id].participants.length, before) + assert.equal(rt.state.rooms[room.id].notice, "Alice's notice.", 'a join for another room id does not rewrite the notice') +}) + +// #904: the host side of the same envelope. The membership notice names this home's Bots by name and +// owner, and a Bot with no Bot Social identity rides as not connected instead of being left out. +test('#904: the membership notice carries the room\'s own Bots, and one without an identity as not connected', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + rt.state.bots['chief-b'].ownerId = 'owner-1' + const room = rt.state.rooms.war + rt.state.bots['chief-a'].fields = { social: { member: { handle: '@chief.a.home', home: '@alice.home', ownerName: 'Alice' } } } + rt.state.bots['chief-b'].fields = { social: { member: { handle: '@chief.b.home', home: '@alice.home', ownerName: 'Alice' } } } + room.members.push('spec-a') + room.participants.push({ kind: 'bot', id: 'spec-a', role: 'member', displayName: 'Writer A', joinedSeq: 0, joinedAt: 1 }) + room.participants.push({ kind: 'bot', remote: true, id: '@coder.bob.home', handle: '@coder.bob.home', ownerHome: '@bob.home', ownerName: 'Bob', displayName: 'Coder', role: 'member', joinedSeq: 0, joinedAt: 1 }) + + assert.equal(rt.state.bots['chief-a'].fields.social.member.home, '@alice.home', 'fixture home') + assert.equal(room.participants.some(p => p.id === 'chief-a'), true, 'chief-a is a participant') + await rt.notifyRoomShare(room, { op: 'join', handle: '@coder.bob.home' }) + const notice = parseRoomShare(sent.at(-1).text) + // #904: removing or adding one of *this home's* Bots has to send a fresh roster; the guest's list + // otherwise never changes and a removed Bot keeps writing. + const joins = () => sent.filter(m => m.text.includes('"op":"join"')) + await rt.removeRoomParticipant('war', { kind: 'bot', id: 'chief-b' }, actorA) + await new Promise(resolve => setTimeout(resolve, 0)) + const afterRemove = parseRoomShare(joins().at(-1).text) + assert.equal(afterRemove.roster.some(entry => entry.handle === '@chief.b.home'), false, 'a local remove re-sends the roster without the Bot') + await rt.addRoomParticipant('war', { kind: 'bot', id: 'chief-b' }, actorA) + await new Promise(resolve => setTimeout(resolve, 0)) + const afterAdd = parseRoomShare(joins().at(-1).text) + assert.equal(afterAdd.roster.some(entry => entry.handle === '@chief.b.home'), true, 'a local add re-sends the roster with the Bot') + assert.equal(notice.op, 'join') + room.notice = 'Keep it short.' + await rt.notifyRoomShare(room, { op: 'join', handle: '@coder.bob.home' }) + const withNotice = parseRoomShare(sent.at(-1).text) + assert.equal(withNotice.notice, 'Keep it short.', 'the join carries the room\'s notice') + assert.deepEqual(notice.roster.map(entry => entry.handle ?? entry.name).sort(), ['@chief.a.home', '@chief.b.home', 'Writer A']) + assert.equal(notice.roster.find(entry => entry.handle === '@chief.a.home').home, '@alice.home', 'a roster entry names the home its handle is unique in') + const a = notice.roster.find(entry => entry.handle === '@chief.a.home') + assert.deepEqual({ name: a.name, ownerName: a.ownerName }, { name: 'Chief A', ownerName: 'Alice' }) + const offline = notice.roster.find(entry => !entry.handle) + assert.equal(offline.connected, false, 'a Bot with no identity is listed as not connected, not dropped') + assert.equal(notice.roster.some(entry => entry.handle === '@coder.bob.home'), false, 'another home\'s Bot is not this home\'s to publish') + + // A leave says only that someone left; it carries no roster. + await rt.notifyRoomShare(room, { op: 'leave', handle: '@coder.bob.home' }) + assert.equal(parseRoomShare(sent.at(-1).text).roster, undefined) +}) + +// #904: the seat path attributes a line to the seat that carried it and never to the envelope's own +// `authorId`: `ownSeatOf` matched that seat by the transport's `fromHandle`, so the seat already is the +// Bot that wrote the line, while a claim inside the envelope is only the guest's word. This guards the +// reverse "fix" (trust the claim) that would let a guest speak as any Bot in this home. +test('#904: a seat\'s line is attributed to the seat, not to whatever the envelope claims', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + const room = rt.state.rooms.war + const ticket = 'AbCdEfGhIjKlMnOpQrStUv' + room.participants.push({ + kind: 'bot', remote: true, id: '@chief.bob.home', handle: '@chief.bob.home', ownerHome: '@bob.home', ownerName: 'Bob', + displayName: 'Chief', role: 'member', admittedBy: 'ticket', ticket, hostBotId: 'chief-a', joinedSeq: 0, joinedAt: 1, + }) + const line = (authorId, seq) => encodeRoomShare({ + op: 'line', roomId: 'war', inviterHome: '@bob.home', inviterHandle: '@chief.bob.home', + seq, who: 'Chief', authorKind: 'bot', authorId, text: 'line ' + seq, + }) + const ingest = (authorId, seq) => rt.applyInboundRoomShare({ + fromHandle: '@chief.bob.home', fromHome: '@bob.home', text: line(authorId, seq), + }, actorA) + + // A Bot of this home is not the seat: the claim falls back to the seat that carried the line. + await ingest('@writer.a.home', 1) + const forged = room.log.filter(entry => String(entry.text).includes('line 1')).at(-1) + assert.equal(forged.authorId, '@chief.bob.home', 'a guest cannot speak as a Bot of this home') + assert.equal(forged.botId, '@chief.bob.home') + assert.equal(forged.via, '@chief.bob.home') + + // The seat is the author, and an invented handle is not adopted either. + await ingest('@chief.bob.home', 2) + const own = room.log.filter(entry => String(entry.text).includes('line 2')).at(-1) + assert.equal(own.authorId, '@chief.bob.home') + await ingest('@ghost.nobody', 3) + const invented = room.log.filter(entry => String(entry.text).includes('line 3')).at(-1) + assert.equal(invented.authorId, '@chief.bob.home', 'an invented handle never becomes an author') +}) diff --git a/core/test/room-ticket-seat.test.js b/core/test/room-ticket-seat.test.js index 5bac8562..230fb763 100644 --- a/core/test/room-ticket-seat.test.js +++ b/core/test/room-ticket-seat.test.js @@ -243,7 +243,8 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated, const mirror = guestRoomOf(b.rt) assert.ok(mirror, 'the joins projected the room on the guest side') assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1, 'one room for both seats') - assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['chief-b', 'helper-b']) + // #904: the guest also lists the host's Bot that carried the join; before that it listed no host Bot at all. + assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['@chief.alice.home', '@writer.alice.home', 'chief-b', 'helper-b']) assert.equal(mirror.sharedFrom.remoteRoomId, 'war') assert.equal(mirror.sharedFrom.ticket, invite.ticket) assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"') diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 49408df4..e3a88a65 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -71,7 +71,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second ### Face-to-face room lead -- A local group's **Members · N** card (`group-settings.js`) ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), after the note on who leads the group (#892) and on the card's own row padding (`.bt-flybot-room-members`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` (#876, the social plugin's **Invite by link**). Covered by `core/test/room-members-slot.test.js`. Plugin copy that core's dictionary carries is translated through `__flybot__.t` (#892: Invite by link, the Join-or-create dialog), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (`roomTitle` in `remote-label.js`: sidebar, header, drawer, brand); "Shared room from …" stays as the sidebar row's hover and the **Shared** tag's title (#892). +- A local group's **Members · N** card (`group-settings.js`) ends with the plugin rows of slot `flybot.room.members` (`FlyBotRoomMembers` in `flybot-slots.js`), after the note on who leads the group (#892) and on the card's own row padding (`.bt-flybot-room-members`), never in a face or shared (`inbound-share`) room; the rows get `host`, the core endpoint call (also `__flybot__.host`), for `room-invite` / `room-invite-mint` / `room-invite-revoke` (#876, the social plugin's **Invite by link**). Covered by `core/test/room-members-slot.test.js`. Plugin copy that core's dictionary carries is translated through `__flybot__.t` (#892: Invite by link, the Join-or-create dialog), so a zh UI reads it in Chinese. A room a link opened is titled by its own name on the joining home (`roomTitle` in `remote-label.js`: sidebar, header, drawer, brand); "Shared room from …" stays as the sidebar row's hover and the **Shared** tag's title (#892). In a room projected from another home the Members rows carry what that side could confirm (#904): a Bot learned from the host's roster reads **Unconfirmed** until this home holds its own fact about it, one with no Bot Social identity reads **Not connected**, and the remove **×** is not offered there — the host's membership is changed on the host's side. - The face room's details (`FaceMembers` in `details-drawer.js`) open with `FaceLead` (`group-settings.js`, #476): the current lead as `Bot (person)`, *Default* until the creator picked one. Only when `room.face.lead.canPick` (this home made the room and Bot Social stores a lead) does it show **Change**, a **Make lead** button per Bot of every home and **Use the default lead**; each calls `social/face-lead`. Everyone else sees it read-only. `FacePersonRow` marks the lead's Bot with a *Lead* tag (`isLeadSeat`, by handle). Never a chat command. ### Room avatars in face-to-face chats diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index c449d702..da34a378 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. 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. +- 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. #904: the projection now records the host's side too. `recordInboundSharedRoom` keeps the guest's own Bot **and** one remote participant for the Bot that carried the join (`draft.inviterHandle`, with `admittedBy: 'ticket'` / `roomOnly` when a link seated it) — `invitedHandle` on this side names one of the guest's own Bots, so it is never recorded as a remote, and the friend path keeps its one remote. The rest of the host's room rides the `join` as `roster` and `notice`: `notifyRoomShare` sends `{ handle, home, name, ownerName }` for every Bot of this home in the room, and `{ name, ownerName, connected: false }` for one with no Bot Social identity; nothing of another guest's home is published, and the roster is re-sent whenever the host adds or removes a Bot of its own. `applyInboundRoomShare` reads a roster only when the envelope names this mirror's room (`data.roomId` = `sharedFrom.remoteRoomId`) and comes from its bound inviter, and merges it into the participants: one remote participant per entry (`fromRoster`, `roomOnly`), keyed by `(home, handle)` so two same-named Bots of different homes stay distinct, `verified` only for the seat or a published friend Bot, an unverified entry displayed but never addressed, an identity-less one shown as not connected. A roster is the whole list, so a `join` that drops an entry marks it left (soft leave: the entry stays with `leftSeq`, history still resolves) and a re-sent `join` clears the mark; an empty `roster: []` clears the list, while a `join` without the field changes nothing. The notice is replaced by the next `join` that carries one; an explicit empty string clears it and a missing field leaves it. - 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/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index f9c824b6..ac60307e 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -518,7 +518,7 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -41,22 +41,67 @@ const ZH = { +@@ -41,22 +41,69 @@ const ZH = { ' and ': '、', and: '和', '{count} members': '{count} 位成员', @@ -578,6 +578,8 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + Retry: '重试', + Approve: '批准', + "Friend's Bot": '好友的 Bot', ++ 'Unconfirmed': '未确认', ++ 'Not connected': '未接入', + 'Expand': '展开', + 'Collapse': '收起', 'Setting up your Main Bot…': '正在设置你的主 Bot…', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index a1205893..3dbac4fe 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -74,7 +74,7 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -78,21 +78,66 @@ var ZH = { +@@ -78,21 +78,68 @@ var ZH = { " and ": "、", and: "和", "{count} members": "{count} 位成员", @@ -133,6 +133,8 @@ diff --git a/client.js b/client.js + Retry: "重试", + Approve: "批准", + "Friend's Bot": "好友的 Bot", ++ "Unconfirmed": "未确认", ++ "Not connected": "未接入", + "Expand": "展开", + "Collapse": "收起", "Setting up your Main Bot…": "正在设置你的主 Bot…", @@ -10692,7 +10694,7 @@ diff --git a/client.js b/client.js key: id, type: "button", "aria-pressed": mode === id, -@@ -8999,86 +8999,139 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { +@@ -8999,86 +8999,142 @@ function GroupSettingsForm({ room: room2, roster, actions, run, onDeleted }) { if (mode !== id) void update({ mode: id }); } }, t(label)))), @@ -10770,9 +10772,12 @@ diff --git a/client.js b/client.js + { className: "bt-gs-who" }, + (0, import_react30.createElement)(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + (0, import_react30.createElement)("span", { className: "bt-row-title", title: person.handle || void 0 }, person.label || remoteBotTitle(person)), -+ room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, t("Friend's Bot")) ++ // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is ++ // labelled as such; a Bot with no Bot Social identity can never be written to at all. ++ room2.origin === "face-to-face" ? null : (0, import_react30.createElement)("span", { className: "bt-tag" }, person.unconnected ? t("Not connected") : room2.origin === "inbound-share" && person.verified === false ? t("Unconfirmed") : t("Friend's Bot")) + ), -+ room2.origin === "face-to-face" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") ++ // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. ++ room2.origin === "face-to-face" || room2.origin === "inbound-share" || members.length + remotes.length <= 2 ? null : (0, import_react30.createElement)("button", { type: "button", className: "bt-x", "aria-label": t("Remove {name} from the group", { name: remoteBotTitle(person) }), title: t("Remove from group"), onClick: () => update({ removeParticipants: [{ kind: "bot", id: person.handle || person.id, remote: true }] }) }, "×") + )), + room2.origin === "face-to-face" ? null : adding ? [ + ...outsidePeople.map((person) => (0, import_react30.createElement)( diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 299e5b19..ca07777f 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -403,7 +403,7 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js const admin = roster.byId[room.admin] const mode = shownMode(room, roster) const modeHint = ROOM_MODES.find(([id]) => id === mode)?.[2] ?? '' -@@ -65,37 +65,67 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { +@@ -65,37 +65,73 @@ export function GroupSettingsForm({ room, roster, actions, run, onDeleted }) { h('div', { className: 'bt-set-row bt-bs-row' }, h('span', { className: 'bt-set-copy' }, h('span', { className: 'bt-bs-label' }, t('Folder'))), h(FolderMenu, { value: room.workDir, group: true, actions, run, onPick: workDir => update({ workDir }) }))), @@ -439,8 +439,14 @@ diff --git a/src/client/group-settings.js b/src/client/group-settings.js + h('span', { className: 'bt-gs-who' }, + h(BotAvatar, { bot: lookBotForRemote(person, roster), size: 26, badge: false, live: false }), + h('span', { className: 'bt-row-title', title: person.handle || undefined }, person.label || remoteBotTitle(person)), -+ room.origin === 'face-to-face' ? null : h('span', { className: 'bt-tag' }, t('Friend\'s Bot'))), -+ room.origin === 'face-to-face' || members.length + remotes.length <= 2 ++ // #904: a host's roster entry is not a friend's Bot, and one this home cannot confirm is ++ // labelled as such; a Bot with no Bot Social identity can never be written to at all. ++ room.origin === 'face-to-face' ? null ++ : h('span', { className: 'bt-tag' }, person.unconnected ? t('Not connected') ++ : room.origin === 'inbound-share' && person.verified === false ? t('Unconfirmed') ++ : t('Friend\'s Bot'))), ++ // A guest's mirror is not theirs to edit: the host's membership is changed on the host side. ++ room.origin === 'face-to-face' || room.origin === 'inbound-share' || members.length + remotes.length <= 2 + ? null + : h('button', { type: 'button', className: 'bt-x', 'aria-label': t('Remove {name} from the group', { name: remoteBotTitle(person) }), title: t('Remove from group'), onClick: () => update({ removeParticipants: [{ kind: 'bot', id: person.handle || person.id, remote: true }] }) }, '×'))), + room.origin === 'face-to-face' ? null : adding diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index ffda33ad..e7cd2d7f 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -691,6 +691,7 @@ export function install(rt) { syncMembers(room) await rt.save() if (record?.remote && record.handle) void rt.notifyRoomShare?.(room, { op: 'join', handle: record.handle }) + else if (record?.kind === 'bot' && !record.remote) reshareRoster(room) return room }) } @@ -708,7 +709,9 @@ export function install(rt) { throw forbidden('Only a room admin can remove participants') } const live = currentParticipants(room) - const botsLeft = live.filter(p => p.kind === 'bot' && p.id !== me.id && p.handle !== me.handle) + // A local Bot has no handle: comparing handles alone dropped every local Bot from the count, so + // removing one always tripped the two-Bot rule. Compare by handle only when the entry has one. + const botsLeft = live.filter(p => p.kind === 'bot' && p.id !== me.id && (!me.handle || p.handle !== me.handle)) const ownerLeave = actor && ( (kind === 'human' && me.id === actor.memberId) || (kind === 'bot' && (state.bots?.[me.id]?.ownerId === actor.memberId || me.ownerId === actor.memberId || room.sharedFrom?.ownerId === actor.memberId)) @@ -735,6 +738,8 @@ export function install(rt) { .catch(error => rt.warn?.('ds-bot: a seat\'s leave was not settled: %s', error)) } else if (kind === 'bot' && room.origin === 'inbound-share' && room.sharedFrom?.ticket) { void leaveTicketRoom(room, me).catch(error => rt.warn?.('ds-bot: leaving %s was not sent: %s', room.name, error)) + } else if (kind === 'bot' && !me.remote) { + reshareRoster(room) } return room }) @@ -953,6 +958,172 @@ export function install(rt) { } } + // #904: a guest's copy learns this home's membership only from a fresh roster. A local add or remove + // therefore has to send one; without this the guest list never changes and a removed Bot keeps writing. + function reshareRoster(room) { + if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) return + if (!(room.participants ?? []).some(p => p.remote)) return + void Promise.resolve(rt.notifyRoomShare?.(room, { op: 'join' })) + .catch(error => rt.warn?.('ds-bot: a membership change was not shared: %s', error)) + } + + // #904: the Bots this home keeps in the room, for the guests' copies of it. Only this home's own Bots: + // another guest's Bot is not this home's to publish. A Bot with no Bot Social identity rides along as + // `connected: false` so the guest can show it as not connected instead of leaving it out entirely. + function roomRoster(room) { + const out = [] + for (const p of currentParticipants(room)) { + if (p.kind !== 'bot' || p.remote) continue + const bot = state.bots?.[p.id] + const member = bot?.fields?.social?.member + const handle = typeof member?.handle === 'string' && /^@[A-Za-z0-9._-]{1,120}$/.test(member.handle) ? member.handle : undefined + const name = bot?.name || p.displayName || p.id + const ownerName = ownerNameSafe(p) || cleanPeerName(member?.ownerName, '') + // #904: (home, handle) is the identity a guest keys on; a handle alone is unique only inside a home. + const home = typeof member?.home === 'string' && /^@[A-Za-z0-9._-]{1,120}$/.test(member.home) ? member.home : '' + const homeField = home ? { home } : {} + out.push(handle ? { handle, name, ...homeField, ...(ownerName ? { ownerName } : {}) } : { name, ...homeField, ...(ownerName ? { ownerName } : {}) }) + } + return out + } + + // #904: a roster is text a stranger wrote. It is read only for the room this mirror is bound to and + // only from that room's own inviter; anything else is dropped whole. Every entry it names stays + // unverified unless this home already holds a fact about that handle (the seat that carried the join, + // or a published friend Bot) — an unverified entry is displayed, never addressed. + function rosterOf(data, room, event) { + if (!Array.isArray(data?.roster)) return undefined + const bound = room?.sharedFrom ?? {} + const same = (a, b) => { + const left = String(a ?? '').trim().toLowerCase() + return left !== '' && left === String(b ?? '').trim().toLowerCase() + } + if (!same(data.roomId, bound.remoteRoomId)) return undefined + if (bound.inviterHome && bound.inviterHandle) { + if (!same(event?.fromHome, bound.inviterHome) || !same(event?.fromHandle, bound.inviterHandle)) return undefined + } else if (bound.inviterHome ? !same(event?.fromHome, bound.inviterHome) : !same(event?.fromHandle, bound.inviterHandle)) return undefined + const seat = String(bound.invitedHandle ?? '') + const out = [] + for (const entry of data.roster.slice(0, 32)) { + const name = cleanPeerName(entry?.name, '') + const ownerName = cleanPeerName(entry?.ownerName, '') + const handle = String(entry?.handle ?? '').trim() + // The entry's home is identity, not trust: verification stays on the transport's home. + const entryHome = String(entry?.home ?? '').trim() + const ownerHome = /^@[A-Za-z0-9._-]{1,120}$/.test(entryHome) ? entryHome : bound.inviterHome + if (!handle) { + if (name) out.push({ name, ownerName, ownerHome, connected: false }) + continue + } + if (!/^@[A-Za-z0-9._-]{1,120}$/.test(handle)) continue + out.push({ + handle, + ownerHome, + name: name || handle, + ownerName, + verified: (handle === seat && same(ownerHome, bound.inviterHome)) || Boolean(publishedRemoteOf(state, handle)), + }) + } + return out + } + + // #904: the host room's notice rides the join like the roster (the guest's group settings were empty). + // It is stranger-written text too, so it is read only for the room this mirror is bound to and only + // from that room's own inviter; an empty value is not encoded, so it never wipes a notice. + function noticeOf(data, room, event) { + if (data?.op !== 'join' || typeof data.notice !== 'string') return undefined + const text = data.notice.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, 2000) + const bound = room?.sharedFrom ?? {} + const same = (a, b) => { + const left = String(a ?? '').trim().toLowerCase() + return left !== '' && left === String(b ?? '').trim().toLowerCase() + } + if (bound.remoteRoomId && !same(data.roomId, bound.remoteRoomId)) return undefined + if (bound.inviterHome && !same(event?.fromHome, bound.inviterHome)) return undefined + if (bound.inviterHandle && !same(event?.fromHandle, bound.inviterHandle)) return undefined + return text + } + + // #904: one remote participant per roster entry. A roster is the whole list, so an entry it drops is a + // Bot the host no longer keeps here: it leaves this mirror's list, and nothing of it stays addressable. + function applyRoster(room, entries) { + if (!Array.isArray(entries)) return false + let changed = false + const seen = new Set() + const seat = String(room.sharedFrom?.invitedHandle ?? '') + const boundHome = room.sharedFrom?.inviterHome + const sameHome = (a, b) => { + const left = String(a ?? '').trim().toLowerCase() + return left !== '' && left === String(b ?? '').trim().toLowerCase() + } + const at = Date.now() + for (const entry of entries) { + const key = entry.handle || `not-connected:${entry.name.toLowerCase()}` + const ownerHome = entry.ownerHome || boundHome + seen.add(key) + // #904: one entry per (home, handle), so a same-named Bot of another home is a different Bot. + const found = (room.participants ?? []).find(p => p.remote && (p.handle === key || p.id === key) + && (!entry.handle || sameHome(p.ownerHome ?? boundHome, ownerHome))) + if (found) { + if (found.leftSeq !== undefined) { + delete found.leftSeq + delete found.leftAt + changed = true + } + if (entry.name && found.displayName !== entry.name) { + found.displayName = entry.name + changed = true + } + if (entry.ownerName && found.ownerName !== entry.ownerName) { + found.ownerName = entry.ownerName + changed = true + } + if (entry.handle && entry.handle === seat && found.verified !== true) { + found.verified = true + changed = true + } + continue + } + room.participants = [...(room.participants ?? []), entry.handle ? { + kind: 'bot', + remote: true, + id: entry.handle, + handle: entry.handle, + ownerHome, + ownerName: entry.ownerName, + displayName: entry.name, + role: 'member', + fromRoster: true, + verified: entry.verified === true, + roomOnly: true, + joinedSeq: room.seq ?? 0, + joinedAt: at, + } : { + kind: 'bot', + remote: true, + id: key, + unconnected: true, + ownerName: entry.ownerName, + displayName: entry.name, + role: 'member', + fromRoster: true, + verified: false, + roomOnly: true, + joinedSeq: room.seq ?? 0, + joinedAt: at, + }] + changed = true + } + for (const p of room.participants ?? []) { + if (!p.fromRoster || p.leftSeq !== undefined) continue + if (seen.has(p.handle || p.id)) continue + p.leftSeq = room.seq ?? 0 + p.leftAt = at + changed = true + } + return changed + } + // Membership notice: the add/remove click is the owner's consent, so this does not wait // for a chat line. Bot Social is 1:1; the envelope is the group record the other FlyBot sees. rt.notifyRoomShare = async function notifyRoomShare(room, { op = 'join', handle } = {}) { @@ -973,6 +1144,7 @@ export function install(rt) { invitedHandle: remote.handle, seq: room.seq ?? 0, ticket: remote.ticket, + ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}), ...seat.from, }, 'room-share', seat.botId, room.id) continue @@ -984,6 +1156,7 @@ export function install(rt) { name: room.name, invitedHandle: remote.handle, seq: room.seq ?? 0, + ...(op === 'join' ? { roster: roomRoster(room), notice: room.notice || '' } : {}), ...from, }, 'room-share') } @@ -1477,6 +1650,9 @@ export function install(rt) { const who = cleanPeerName(data.who, remoteBotTitle(seat)) const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0 const human = data.authorKind !== 'bot' + // #904: this path never reads the envelope's `authorId`: `ownSeatOf` matched the seat by the + // transport's own `fromHandle`, so `seat.handle` is already the Bot that wrote the line, while a + // claim inside the envelope is only the guest's word. A guest cannot speak as another Bot here. const posted = append(room, rt.authoredRoomLine(room, { who, text: data.text || '', @@ -1767,8 +1943,17 @@ export function install(rt) { 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 } + // #904: a remote seat stands for the handle only when this home verified it and it belongs to the + // home the transport names: two homes may carry the same handle, and an unverified roster entry is a + // display fact, never a signature. + const sameHome = (left, right) => { + const a = String(left ?? '').trim().toLowerCase() + return a !== '' && a === String(right ?? '').trim().toLowerCase() + } const traced = claim === sender - || currentParticipants(room).some(p => p.remote === true && (p.handle === claim || p.id === claim)) + || currentParticipants(room).some(p => p.remote === true && p.leftSeq === undefined + && p.verified === true && (p.handle === claim || p.id === claim) + && sameHome(p.ownerHome ?? room.sharedFrom?.inviterHome, event.fromHome)) || room.sharedFrom?.inviterHandle === claim return traced ? { authorId: claim, authorKind: kind } : { authorId: sender || claim, authorKind: 'bot', unverified: true } } @@ -1894,6 +2079,8 @@ export function install(rt) { remoteRoomId: data.roomId, joinedSeq: data.seq, ...(ticket && data.op === 'join' ? { ticket } : {}), + ...(data.op === 'join' ? { roster: rosterOf(data, { sharedFrom: { remoteRoomId: data.roomId, inviterHome: event.fromHome, inviterHandle: event.fromHandle, invitedHandle: data.invitedHandle } }, event) } : {}), + ...(typeof noticeOf(data, undefined, event) === 'string' ? { notice: noticeOf(data, undefined, event) } : {}), }, actor || (ownerId ? { memberId: ownerId } : undefined)) } else { delete room.hidden @@ -1914,11 +2101,15 @@ export function install(rt) { }] room.sharedFrom = { ...room.sharedFrom, ticket } } - for (const p of room.participants ?? []) { - if (seatedHere && p.id !== localBotId) continue - if (p.leftSeq !== undefined) { - delete p.leftSeq - delete p.leftAt + // #904: only a join brings anyone back. A line on an existing mirror must not clear the soft leaves + // a roster just set, or a plain message would quietly undo a removal. + if (data.op === 'join') { + for (const p of room.participants ?? []) { + if (seatedHere && p.id !== localBotId) continue + if (p.leftSeq !== undefined) { + delete p.leftSeq + delete p.leftAt + } } } if (data.name) room.name = cleanPeerName(data.name, room.name) @@ -1932,6 +2123,11 @@ export function install(rt) { invitedHandle: data.invitedHandle || room.sharedFrom?.invitedHandle, remoteRoomId: data.roomId, } + // #904: the host re-sends the whole roster when its membership changes, so this list is the + // current one; a Bot it no longer names leaves this mirror. + applyRoster(room, data.op === 'join' ? rosterOf(data, room, event) : undefined) + const notice = noticeOf(data, room, event) + if (typeof notice === 'string') room.notice = notice syncMembers(room) } let called = [] @@ -1980,6 +2176,10 @@ export function install(rt) { if (!ownerId) throw invalid('Sign in first') const handle = draft.invitedHandle || draft.handle const localBotId = draft.localBotId && state.bots?.[draft.localBotId] ? draft.localBotId : undefined + // #904: `invitedHandle` on this side names one of my own Bots (the share addressed it), so the + // host's Bot to record is the one that carried the join. A handle this home owns is never a remote. + const seatHandle = typeof draft.inviterHandle === 'string' && draft.inviterHandle.startsWith('@') ? draft.inviterHandle : undefined + const mineHandles = new Set(Object.values(state.bots ?? {}).map(bot => bot?.fields?.social?.member?.handle).filter(own => typeof own === 'string')) const at = Date.now() const inviterName = cleanPeerName(draft.inviterName, 'a friend') const title = cleanPeerName(draft.name, sharedRoomTitle(inviterName)) @@ -2026,7 +2226,35 @@ export function install(rt) { joinedAt: draft.invitedAt ?? at, invitedByName: inviterName, invitedAt: draft.invitedAt ?? at, - }] : handle ? [{ + }] : []), + // #904: the host's seat Bot is a participant of its own, not the alternative to mine. A guest + // always brings a Bot (`localBotId`), so the old either/or dropped the host's side whole — the + // reported "they cannot see my Bots". The seat is the Bot that carried the join (the inviter), + // never `invitedHandle`: on this side that is one of my own Bots. It keeps the shape a ticket + // seat has here (#884). + ...(seatHandle && !mineHandles.has(seatHandle) ? [{ + kind: 'bot', + remote: true, + id: seatHandle, + handle: seatHandle, + ownerHome: draft.inviterHome, + ownerName: inviterName, + displayName: cleanPeerName(draft.inviterBotName, seatHandle), + role: 'member', + ...(typeof draft.ticket === 'string' && draft.ticket ? { admittedBy: 'ticket', ticket: draft.ticket } : {}), + verified: true, + roomOnly: true, + joinedSeq: draft.joinedSeq ?? 0, + joinedAt: draft.invitedAt ?? at, + invitedByName: inviterName, + invitedAt: draft.invitedAt ?? at, + ownerId, + }] : []), + // The friend path's remote Bot: a share that names a Bot of another home and brought none of + // mine. The either/or this replaces kept one of the two, and a share may legitimately name both. + // When this home brought a Bot, `invitedHandle` names one of my own Bots (the share addressed it): + // recording it as remote would list my own Bot twice, once under each name. + ...(handle && handle !== seatHandle && !mineHandles.has(handle) && !(localBotId && handle === draft.invitedHandle) ? [{ kind: 'bot', remote: true, id: handle, @@ -2035,6 +2263,8 @@ export function install(rt) { ownerName: inviterName, displayName: cleanPeerName(draft.botName, handle), role: 'member', + verified: true, + roomOnly: true, joinedSeq: draft.joinedSeq ?? 0, joinedAt: draft.invitedAt ?? at, invitedByName: inviterName, @@ -2044,6 +2274,10 @@ export function install(rt) { ], } state.rooms[id] = room + // #904: the notice the join carried (gated by the caller) is the room's own notice. + if (typeof draft.notice === 'string') room.notice = draft.notice + // #904: the roster the join carried, merged on top of the two above (the seat is already there). + if (applyRoster(room, draft.roster)) syncMembers(room) await rt.save?.() return room } diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index 0edf88c4..818bde3f 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -14,6 +14,27 @@ function clip(value, max = 60) { return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max) } +// #904: a room's roster entry. A Bot inside a real room is a handful, not a crowd; the cap keeps one +// envelope from turning into a member dump a guest has to render. +export const ROSTER_MAX = 32 +// The group notice's own limit in the room editor (core/src/client/group-settings.js). +export const NOTICE_MAX = 2000 + +// #904: a handle is unique only inside its home, so an entry carries the home it belongs to: two homes +// can keep Bots with the same local name, and a guest has to tell them apart when it attributes a line. +// The receiving side verifies against the transport's home; this field is for identity, not for trust. +function rosterEntry(entry = {}) { + const handle = clip(entry.handle, 120) + const home = clip(entry.home, 120) + const out = { name: clip(entry.name) } + if (handle) out.handle = handle + if (/^@[A-Za-z0-9._-]{1,120}$/.test(home)) out.home = home + const ownerName = clip(entry.ownerName) + if (ownerName) out.ownerName = ownerName + if (!handle) out.connected = false + return out +} + export function encodeRoomShare(payload = {}) { const body = { v: 1, @@ -49,6 +70,18 @@ export function encodeRoomShare(payload = {}) { if (Array.isArray(payload.calls) && payload.calls.length > 0) { body.calls = payload.calls.map(handle => clip(handle, 120)).filter(Boolean).slice(0, 24) } + // #904: the host room's Bots ride the membership notice so the guest's mirror can list them. It is + // display data only — the guest still takes reachability from Bot Social, so a roster can never hand + // out a send path. `connected: false` is a Bot with no Bot Social identity: shown, never addressed. + // #904: an explicit roster is encoded even when empty — empty means the host keeps none of its Bots in + // the room any more, while an absent field means "change nothing" (an older host). + if (body.op === 'join' && Array.isArray(payload.roster)) { + body.roster = payload.roster.slice(0, ROSTER_MAX).map(rosterEntry).filter(entry => entry.connected === false || entry.handle) + } + // #904: the host room's notice rides the same join envelope, so the guest's group settings are not + // empty. It is text the room displays, never authority. A present notice is encoded even when empty: + // empty means the host cleared it, while an absent field means "change nothing" (an older host). + if (body.op === 'join' && typeof payload.notice === 'string') body.notice = clip(payload.notice, NOTICE_MAX) return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}` } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index c39880f6..e58bf7a9 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -1575,3 +1575,390 @@ test('#909: a line is never shown under a handle the room cannot trace', async ( assert.equal(own.authorId, '@chief.alice.home') assert.equal(own.unverified, undefined) }) + + +// #904: a guest who joins with a link always brings a Bot, and the participants list treated the local +// Bot and the host's seat Bot as alternatives. The host's side was therefore dropped whole: the guest's +// room listed themselves and their own Bot, nothing of the host's — the reported "they cannot see my Bots". +test('#904: a link join keeps the host\'s seat Bot beside the guest\'s own', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const room = await rt.recordInboundSharedRoom({ + id: 'from-alice', + name: 'War room', + inviterName: 'Alice', + inviterHome: '@alice.home', + inviterHandle: '@chief.alice.home', + invitedHandle: '@spec.b.bob.home', + localBotId: 'spec-b', + remoteRoomId: 'alice-war', + joinedSeq: 2, + ticket: 'AbCdEfGhIjKlMnOpQrStUv', + }, actorB) + const bots = room.participants.filter(p => p.kind === 'bot') + assert.deepEqual(bots.filter(p => !p.remote).map(p => p.id), ['spec-b'], 'the guest\'s own Bot stays') + const seat = bots.find(p => p.remote) + assert.ok(seat, 'the host\'s seat Bot is recorded too, not only the guest\'s Bot') + assert.equal(seat.handle, '@chief.alice.home', 'the Bot that carried the join, never the invited handle') + assert.equal(seat.admittedBy, 'ticket', 'the seat keeps the shape a ticket seat has') + assert.equal(seat.roomOnly, true, 'a seat is reachable inside the room it was admitted to, nowhere else') + assert.equal(seat.verified, true) + // The report's "Members · 2" is You + your own Bot, and the badge on it is the guest's own Bot: admin is + // `localBotId` alone, so a host Bot becoming a participant must not take the badge with it. + assert.equal(room.participants.length, 3, 'you, your own Bot, and the host seat: three') + assert.equal(room.admin, 'spec-b', 'the guest\'s own Bot stays the admin of its mirror') + assert.equal(seat.role, 'member', 'the host seat is a member, never the admin') + // The fix lands in `participants` only: `room.members` is what speaks, leads and renders, and a remote + // handle there would hand the guest a member of its own group. + assert.deepEqual(room.members, ['spec-b'], 'the room members stay local; a remote is never a member') +}) + +// #904: the join notice carries the host room's Bot roster, and the guest lists every Bot in it. What a +// roster decides is who is displayed: an entry this home cannot confirm stays unverified and unreachable. +test('#904: a join roster lists every host Bot, and a line keeps the Bot that wrote it', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + // A published friend Bot in the roster is a fact this home holds, so that entry is verified. + publish(rt, [{ ...CODER, handle: '@writer.a.home', name: 'Writer A', home: '@alice.home', homeName: 'Alice', owner: 'Alice' }]) + const join = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@chief.b.alice.home', seq: 4, + notice: 'Be nice to each other.', + roster: [ + { handle: '@chief.a.home', home: '@alice.home', name: 'Chief A', ownerName: 'Alice' }, + { handle: '@chief.b.alice.home', home: '@alice.home', name: 'Chief B', ownerName: 'Alice' }, + { handle: '@writer.a.home', home: '@alice.home', name: 'Writer A', ownerName: 'Alice' }, + { handle: '@ghost.alice.home', home: '@alice.home', name: 'Ghost', ownerName: 'Alice' }, + { name: 'Offline Bot', ownerName: 'Alice' }, + ], + }) + assert.equal(parseRoomShare(join).roster.length, 5, 'the roster rides the existing envelope') + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', fromName: 'Alice', + text: join, localBotId: 'spec-b', + }, actorB) + const room = first.room + assert.equal(room.notice, 'Be nice to each other.', 'the group notice rides the join, so the guest\'s settings are not empty') + const live = room.participants.filter(p => p.leftSeq === undefined) + const handles = live.filter(p => p.kind === 'bot' && p.remote).map(p => p.handle ?? p.id).sort() + assert.deepEqual(handles, [ + '@chief.a.home', '@chief.alice.home', '@chief.b.alice.home', '@ghost.alice.home', '@writer.a.home', 'not-connected:offline bot', + ], 'every Bot the host named is listed, and the guest\'s own Bot is not the only one') + const seat = live.find(p => p.handle === '@chief.b.alice.home') + assert.equal(seat.verified, true, 'the seat that carried the join is this home\'s own fact') + assert.equal(live.find(p => p.handle === '@chief.a.home').ownerHome, '@alice.home', 'the roster entry carries the home its handle is unique in') + const named = live.find(p => p.handle === '@writer.a.home') + assert.equal(named.displayName, 'Writer A') + assert.equal(named.ownerName, 'Alice', 'the guest can tell whose Bot it is') + assert.equal(named.verified, true, 'a published friend Bot is this home\'s own fact') + const ghost = live.find(p => p.handle === '@ghost.alice.home') + assert.equal(ghost.verified, false, 'a name in a stranger\'s roster is not proof') + assert.equal(named.roomOnly, true, 'a roster never widens reach') + const offline = live.find(p => p.unconnected === true) + assert.equal(offline.displayName, 'Offline Bot') + assert.equal(offline.handle, undefined, 'a Bot with no identity has no handle to write to') + // The client reads these through presentRoom, which keeps the whole live list. + const shown = presentRoom(rt.state.rooms[room.id], actorB, rt.state) + assert.equal(shown.presentedParticipants.filter(p => p.remote).length, 6) + + // A line keeps the Bot that wrote it — not the seat, not the inviter — when the roster holds it. + const line = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 5, + who: 'Writer A', authorKind: 'bot', authorId: '@writer.a.home', text: 'on it', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: line, localBotId: 'spec-b' }, actorB) + const posted = room.log.filter(entry => String(entry.text).includes('on it')).at(-1) + assert.equal(posted.authorId, '@writer.a.home') + assert.equal(posted.botId, '@writer.a.home') + + // A line that names a Bot this room never verified is said by the inviter, never by that Bot. + const forged = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 6, + who: 'Ghost', authorKind: 'bot', authorId: '@nobody.home', text: 'not me', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: forged, localBotId: 'spec-b' }, actorB) + const forgedLine = room.log.filter(entry => String(entry.text).includes('not me')).at(-1) + assert.equal(forgedLine.authorId, '@chief.alice.home', 'an invented author never speaks as another Bot') + + // A line that names an unverified roster entry is said by the inviter, never by that Bot. + const fromGhost = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 7, + who: 'Ghost', authorKind: 'bot', authorId: '@ghost.alice.home', text: 'not verified', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: fromGhost, localBotId: 'spec-b' }, actorB) + const ghostLine = room.log.filter(entry => String(entry.text).includes('not verified')).at(-1) + assert.equal(ghostLine.authorId, '@chief.alice.home', 'an unverified roster entry never speaks under its own name') + + // A Bot handle is a Bot's line: calling it a person's line in the envelope does not make it one, and an + // unverified handle is still said by the Bot that carried the line and marked unverified. + const humanClaim = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 8, + who: 'Ghost', authorKind: 'human', authorId: '@ghost.alice.home', text: 'as a person', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: humanClaim, localBotId: 'spec-b' }, actorB) + const asPerson = room.log.filter(entry => String(entry.text).includes('as a person')).at(-1) + assert.equal(asPerson.authorKind, 'bot', 'a Bot handle never becomes a person because the envelope says so') + assert.equal(asPerson.authorId, '@chief.alice.home', 'and it is the Bot that carried the line that said it') + assert.equal(asPerson.unverified, true, 'the claim it failed to prove is marked unverified') + assert.equal(asPerson.botId, '@chief.alice.home') + + // A verified Bot's own line needs no marker, and neither does a person's line from the other home. + const person = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 9, + who: 'Alice', authorKind: 'human', authorId: 'owner-a', text: 'a person here', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: person, localBotId: 'spec-b' }, actorB) + const said = room.log.filter(entry => String(entry.text).includes('a person here')).at(-1) + assert.equal(said.authorKind, 'human') + assert.equal(said.authorId, 'owner-a') + assert.equal(said.unverified, undefined, 'a person\'s line from that home is not a Bot claim') + + // The host's next roster is the current one: the Bots it no longer names leave this mirror. + const shrunk = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 7, + roster: [ + { handle: '@chief.a.home', name: 'Chief A', ownerName: 'Alice' }, + { handle: '@chief.b.alice.home', name: 'Chief B', ownerName: 'Alice' }, + ], + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: shrunk, localBotId: 'spec-b' }, actorB) + const after = room.participants.filter(p => p.leftSeq === undefined) + assert.deepEqual(after.filter(p => p.kind === 'bot' && p.remote).map(p => p.handle).sort(), ['@chief.a.home', '@chief.alice.home', '@chief.b.alice.home'], 'the seat that carried the join is not roster-controlled; the two it dropped are gone') + assert.equal(after.some(p => p.id === 'spec-b'), true, 'the guest\'s own Bot is not host-controlled') + // #904: identity is (home, handle). A Bot of another home is a different Bot even when the name + // matches, so a line it did not send cannot borrow its name here. The envelope itself cannot arrive + // from a home this room is not bound to at all (#909 refuses that before any attribution). + room.participants.push({ + kind: 'bot', remote: true, id: '@chief.dup.home', handle: '@chief.dup.home', ownerHome: '@bob.home', + ownerName: 'Bob', displayName: 'Dup', role: 'member', verified: true, roomOnly: true, joinedSeq: 0, joinedAt: 1, + }) + const crossHome = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 12, + who: 'Dup', authorKind: 'bot', authorId: '@chief.dup.home', text: 'cross home', + }) + const intruder = await rt.applyInboundRoomShare({ fromHandle: '@chief.bob.home', fromHome: '@bob.home', text: crossHome, localBotId: 'spec-b' }, actorB) + assert.equal(intruder.refused, 'not-inviter', 'a home this room is not bound to cannot write into it') + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: crossHome, localBotId: 'spec-b' }, actorB) + const cross = room.log.filter(entry => String(entry.text).includes('cross home')).at(-1) + assert.equal(cross.authorId, '@chief.alice.home', 'another home\'s Bot name is not this room\'s Bot') + assert.equal(cross.unverified, true) + + // A removed entry is a soft leave, not a deletion: its history stays resolvable. + const left = room.participants.filter(p => p.leftSeq !== undefined).map(p => p.handle || p.id).sort() + assert.deepEqual(left, ['@ghost.alice.home', '@writer.a.home', 'not-connected:offline bot'], 'a roster drop marks the entry left instead of deleting it') + + // A line must not bring a removed Bot back: only a join does, so a removal cannot be undone by + // whoever keeps talking in the room. + const afterRemoval = encodeRoomShare({ + op: 'line', roomId: 'alice-war', inviterName: 'Alice', inviterHome: '@alice.home', + inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 6, + who: 'Alice', authorKind: 'human', authorId: 'owner-a', text: 'after removal', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: afterRemoval, localBotId: 'spec-b' }, actorB) + assert.deepEqual(room.participants.filter(p => p.leftSeq !== undefined).map(p => p.handle || p.id).sort(), left, 'a line does not resurrect a removed Bot') + + // The same join redelivered changes nothing: no second entry, no second participant. + const count = room.participants.length + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: shrunk, localBotId: 'spec-b' }, actorB) + assert.equal(room.participants.length, count, 'a redelivered join adds no second entry') + + // A Bot the host puts back comes back in place: the existing entry clears its soft leave, no duplicate. + const back = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 8, + roster: [ + { handle: '@chief.a.home', name: 'Chief A', ownerName: 'Alice' }, + { handle: '@chief.b.alice.home', name: 'Chief B', ownerName: 'Alice' }, + { handle: '@writer.a.home', name: 'Writer A', ownerName: 'Alice' }, + ], + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: back, localBotId: 'spec-b' }, actorB) + assert.deepEqual(room.members, ['spec-b'], 'a roster never writes into the room members') + const writers = room.participants.filter(p => p.handle === '@writer.a.home') + assert.equal(writers.length, 1, 'a Bot that comes back reuses its entry') + assert.equal(writers[0].leftSeq, undefined, 'and its soft leave is cleared') + + // The host edits its notice and clears it: both ride the same join. Clearing is a present empty + // field; an absent field (an older host) is not an instruction to change anything. + const edited = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 10, + notice: 'Edited notice.', + }) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: edited, localBotId: 'spec-b' }, actorB) + assert.equal(room.notice, 'Edited notice.', 'an edited notice follows on the next join') + const cleared = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 11, + notice: '', + }) + assert.equal(parseRoomShare(cleared).notice, '', 'a cleared notice is still carried, as an empty field') + assert.equal(parseRoomShare(encodeRoomShare({ op: 'join', roomId: 'alice-war', name: 'War room' })).notice, undefined, 'no notice field means "change nothing"') + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: cleared, localBotId: 'spec-b' }, actorB) + assert.equal(room.notice, '', 'clearing the notice clears the guest\'s copy') + + // #904: a join that carries no roster field is not a request to empty the list. An older host, or a + // peer that drops the field, leaves the guest's participants exactly as they are. + const bare = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 9, + }) + const kept = room.participants.map(p => p.id) + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: bare, localBotId: 'spec-b' }, actorB) + assert.deepEqual(room.participants.map(p => p.id), kept, 'a join without a roster field changes nothing') + + // #904: an explicit empty roster clears this home's Bots from the mirror — the seat, my own Bot and the + // person stay. (An absent field, just above, changes nothing.) + const emptied = encodeRoomShare({ + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.b.alice.home', invitedHandle: '@chief.b.alice.home', seq: 13, + roster: [], + }) + assert.deepEqual(parseRoomShare(emptied).roster, [], 'an explicit empty roster is encoded instead of dropped') + await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: emptied, localBotId: 'spec-b' }, actorB) + assert.equal(room.participants.filter(p => p.fromRoster && p.leftSeq === undefined).length, 0, 'an empty roster leaves nobody of the host\'s from the roster') + assert.equal(room.participants.some(p => p.id === 'spec-b' && p.leftSeq === undefined), true, 'my own Bot is not controlled by the roster') + assert.equal(room.participants.some(p => p.handle === '@chief.alice.home' && p.leftSeq === undefined), true, 'the seat that carried the join stays') +}) + +// #904: a roster is stranger-written text, so the envelope that carries one is gated: only the room's +// own inviter, only the room this mirror is bound to, and nothing it says widens reach by itself. +test('#904: a roster from another home or for another room changes no participant', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { bob } = await signAliceAndBob(rt) + const actorB = await rt.authenticate(bob.sessionToken) + const join = { + op: 'join', roomId: 'alice-war', name: 'War room', inviterName: 'Alice', + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@chief.b.alice.home', seq: 1, + notice: 'Alice\'s notice.', + roster: [{ handle: '@chief.a.home', name: 'Chief A', ownerName: 'Alice' }], + } + const first = await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: encodeRoomShare(join), localBotId: 'spec-b', + }, actorB) + const room = first.room + const before = room.participants.length + + // Another home claims the same remote room id and names a Bot: the envelope is dropped whole. + const fromElsewhere = { + ...join, seq: 2, inviterHome: '@mallory.home', inviterHandle: '@mallory.home', + roster: [{ handle: '@mallory.bot.home', name: 'Mallory Bot', ownerName: 'Mallory' }], + } + await rt.applyInboundRoomShare({ + fromHandle: '@mallory.home', fromHome: '@mallory.home', text: encodeRoomShare(fromElsewhere), localBotId: 'spec-b', + }, actorB) + assert.equal(rt.state.rooms[room.id].participants.length, before, 'a roster from a home that is not the inviter changes nothing') + assert.equal(rt.state.rooms[room.id].participants.some(p => p.handle === '@mallory.bot.home'), false) + assert.equal(rt.state.rooms[room.id].notice, "Alice's notice.", 'a joiner from another home does not rewrite the notice') + + // A roster sent for another room id does not touch this mirror either. + const elsewhere = { ...join, roomId: 'other-room', seq: 3, notice: 'Mallory was here.', roster: [{ handle: '@eve.home', name: 'Eve', ownerName: 'Eve' }] } + await rt.applyInboundRoomShare({ + fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: encodeRoomShare(elsewhere), localBotId: 'spec-b', + }, actorB) + assert.equal(rt.state.rooms[room.id].participants.some(p => p.handle === '@eve.home'), false, 'another room id opens no slot here') + assert.equal(rt.state.rooms[room.id].participants.length, before) + assert.equal(rt.state.rooms[room.id].notice, "Alice's notice.", 'a join for another room id does not rewrite the notice') +}) + +// #904: the host side of the same envelope. The membership notice names this home's Bots by name and +// owner, and a Bot with no Bot Social identity rides as not connected instead of being left out. +test('#904: the membership notice carries the room\'s own Bots, and one without an identity as not connected', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + rt.state.bots['chief-b'].ownerId = 'owner-1' + const room = rt.state.rooms.war + rt.state.bots['chief-a'].fields = { social: { member: { handle: '@chief.a.home', home: '@alice.home', ownerName: 'Alice' } } } + rt.state.bots['chief-b'].fields = { social: { member: { handle: '@chief.b.home', home: '@alice.home', ownerName: 'Alice' } } } + room.members.push('spec-a') + room.participants.push({ kind: 'bot', id: 'spec-a', role: 'member', displayName: 'Writer A', joinedSeq: 0, joinedAt: 1 }) + room.participants.push({ kind: 'bot', remote: true, id: '@coder.bob.home', handle: '@coder.bob.home', ownerHome: '@bob.home', ownerName: 'Bob', displayName: 'Coder', role: 'member', joinedSeq: 0, joinedAt: 1 }) + + assert.equal(rt.state.bots['chief-a'].fields.social.member.home, '@alice.home', 'fixture home') + assert.equal(room.participants.some(p => p.id === 'chief-a'), true, 'chief-a is a participant') + await rt.notifyRoomShare(room, { op: 'join', handle: '@coder.bob.home' }) + const notice = parseRoomShare(sent.at(-1).text) + // #904: removing or adding one of *this home's* Bots has to send a fresh roster; the guest's list + // otherwise never changes and a removed Bot keeps writing. + const joins = () => sent.filter(m => m.text.includes('"op":"join"')) + await rt.removeRoomParticipant('war', { kind: 'bot', id: 'chief-b' }, actorA) + await new Promise(resolve => setTimeout(resolve, 0)) + const afterRemove = parseRoomShare(joins().at(-1).text) + assert.equal(afterRemove.roster.some(entry => entry.handle === '@chief.b.home'), false, 'a local remove re-sends the roster without the Bot') + await rt.addRoomParticipant('war', { kind: 'bot', id: 'chief-b' }, actorA) + await new Promise(resolve => setTimeout(resolve, 0)) + const afterAdd = parseRoomShare(joins().at(-1).text) + assert.equal(afterAdd.roster.some(entry => entry.handle === '@chief.b.home'), true, 'a local add re-sends the roster with the Bot') + assert.equal(notice.op, 'join') + room.notice = 'Keep it short.' + await rt.notifyRoomShare(room, { op: 'join', handle: '@coder.bob.home' }) + const withNotice = parseRoomShare(sent.at(-1).text) + assert.equal(withNotice.notice, 'Keep it short.', 'the join carries the room\'s notice') + assert.deepEqual(notice.roster.map(entry => entry.handle ?? entry.name).sort(), ['@chief.a.home', '@chief.b.home', 'Writer A']) + assert.equal(notice.roster.find(entry => entry.handle === '@chief.a.home').home, '@alice.home', 'a roster entry names the home its handle is unique in') + const a = notice.roster.find(entry => entry.handle === '@chief.a.home') + assert.deepEqual({ name: a.name, ownerName: a.ownerName }, { name: 'Chief A', ownerName: 'Alice' }) + const offline = notice.roster.find(entry => !entry.handle) + assert.equal(offline.connected, false, 'a Bot with no identity is listed as not connected, not dropped') + assert.equal(notice.roster.some(entry => entry.handle === '@coder.bob.home'), false, 'another home\'s Bot is not this home\'s to publish') + + // A leave says only that someone left; it carries no roster. + await rt.notifyRoomShare(room, { op: 'leave', handle: '@coder.bob.home' }) + assert.equal(parseRoomShare(sent.at(-1).text).roster, undefined) +}) + +// #904: the seat path attributes a line to the seat that carried it and never to the envelope's own +// `authorId`: `ownSeatOf` matched that seat by the transport's `fromHandle`, so the seat already is the +// Bot that wrote the line, while a claim inside the envelope is only the guest's word. This guards the +// reverse "fix" (trust the claim) that would let a guest speak as any Bot in this home. +test('#904: a seat\'s line is attributed to the seat, not to whatever the envelope claims', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + const room = rt.state.rooms.war + const ticket = 'AbCdEfGhIjKlMnOpQrStUv' + room.participants.push({ + kind: 'bot', remote: true, id: '@chief.bob.home', handle: '@chief.bob.home', ownerHome: '@bob.home', ownerName: 'Bob', + displayName: 'Chief', role: 'member', admittedBy: 'ticket', ticket, hostBotId: 'chief-a', joinedSeq: 0, joinedAt: 1, + }) + const line = (authorId, seq) => encodeRoomShare({ + op: 'line', roomId: 'war', inviterHome: '@bob.home', inviterHandle: '@chief.bob.home', + seq, who: 'Chief', authorKind: 'bot', authorId, text: 'line ' + seq, + }) + const ingest = (authorId, seq) => rt.applyInboundRoomShare({ + fromHandle: '@chief.bob.home', fromHome: '@bob.home', text: line(authorId, seq), + }, actorA) + + // A Bot of this home is not the seat: the claim falls back to the seat that carried the line. + await ingest('@writer.a.home', 1) + const forged = room.log.filter(entry => String(entry.text).includes('line 1')).at(-1) + assert.equal(forged.authorId, '@chief.bob.home', 'a guest cannot speak as a Bot of this home') + assert.equal(forged.botId, '@chief.bob.home') + assert.equal(forged.via, '@chief.bob.home') + + // The seat is the author, and an invented handle is not adopted either. + await ingest('@chief.bob.home', 2) + const own = room.log.filter(entry => String(entry.text).includes('line 2')).at(-1) + assert.equal(own.authorId, '@chief.bob.home') + await ingest('@ghost.nobody', 3) + const invented = room.log.filter(entry => String(entry.text).includes('line 3')).at(-1) + assert.equal(invented.authorId, '@chief.bob.home', 'an invented handle never becomes an author') +}) diff --git a/patches/ds-bot/files/test/room-ticket-seat.test.js b/patches/ds-bot/files/test/room-ticket-seat.test.js index 5bac8562..230fb763 100644 --- a/patches/ds-bot/files/test/room-ticket-seat.test.js +++ b/patches/ds-bot/files/test/room-ticket-seat.test.js @@ -243,7 +243,8 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated, const mirror = guestRoomOf(b.rt) assert.ok(mirror, 'the joins projected the room on the guest side') assert.equal(Object.values(b.rt.state.rooms).filter(r => r.origin === 'inbound-share').length, 1, 'one room for both seats') - assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['chief-b', 'helper-b']) + // #904: the guest also lists the host's Bot that carried the join; before that it listed no host Bot at all. + assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined && p.kind === 'bot').map(p => p.id).sort(), ['@chief.alice.home', '@writer.alice.home', 'chief-b', 'helper-b']) assert.equal(mirror.sharedFrom.remoteRoomId, 'war') assert.equal(mirror.sharedFrom.ticket, invite.ticket) assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"')