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"')