diff --git a/changelog.d/852-local-room-invites.added.md b/changelog.d/852-local-room-invites.added.md new file mode 100644 index 000000000..b3331044d --- /dev/null +++ b/changelog.d/852-local-room-invites.added.md @@ -0,0 +1 @@ +A room's invite link can now be used as an invitation: the link carries a ticket, the inviter's FlyBot answers with the room it belongs to, and only the accepting side is admitted, one home per ticket ([#852](https://github.com/ThinkFlowLab/FlyBot/issues/852)). Nothing in the invitation can create a room, seat a member, or use up the ticket, and a refused seat gives the ticket back. One field now reads both link shapes — this server's `/r/` and a room on another FlyBot, `/r/@handle/` — and a link into another FlyBot's room is answered as itself ("That link opens a room on another FlyBot") instead of being sent to Bot Social as a ticket it never issued. diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 8d0c0d1d5..e34299596 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -961,7 +961,7 @@ export function install(rt) { ticket: invite.ticket, handle: invite.handle, expiresAt, - ...(typeof invite.consumedBy === 'string' && invite.consumedBy ? { consumedBy: invite.consumedBy } : {}), + ...(typeof invite.acceptedBy === 'string' && invite.acceptedBy ? { acceptedBy: invite.acceptedBy } : {}), } } @@ -976,7 +976,10 @@ export function install(rt) { const wanted = String(ticket ?? '') if (!wanted) return undefined for (const room of Object.values(state.rooms || {})) { - if (room?.localInvite?.ticket === wanted) return room + if (room?.localInvite?.ticket !== wanted) continue + // An expired ticket resolves nothing: the room record is the authority, and a dead link must + // not be able to open a room through the accept path. + return liveLocalInvite(room) ? room : undefined } return undefined } @@ -987,10 +990,13 @@ export function install(rt) { // A shared or face room has its own invitation; this one is for a room that lives in FlyBot. if (room.origin === 'inbound-share' || isFaceRoom(room)) throw invalid('This room has its own invitation') const handle = shareInviter(room).inviterHandle - if (!handle) throw invalid('Sign in first') + if (!handle) throw invalid('This machine has no Bot Social identity yet') const expiresAt = Date.now() + LOCAL_INVITE_TTL_MS // 16 random bytes in base64url are 22 characters, the shape the invite field already accepts. const ticket = randomBytes(16).toString('base64url') + // Build the link before the record is written: a handle the link cannot carry must not leave a + // ticket behind that no invitee could ever use. + if (!roomInviteUrl(handle, ticket, rt.roomInviteOrigin || ROOM_INVITE_ORIGIN)) throw invalid('This room has no Bot handle a link can carry') // One live ticket per room: minting again replaces the last one, and the old link stops working. room.localInvite = { ticket, handle, expiresAt, mintedAt: Date.now() } await rt.save?.() @@ -1006,6 +1012,60 @@ export function install(rt) { return { revoked: true } } + // #852: a knock at a local room's link. The invitee's host sends `ask` (the accepting knock carries + // `accept: true`); this side answers from the room record only, and a knock never creates a room, + // adds a participant, or writes a line. The asker is the transport's sender, never a field of the + // payload. Verify → consume → add → the join goes back from addRoomParticipant. + async function answerLocalInvite(data, event, actor) { + const ticket = String(data.ticket ?? '') + const asker = String(event.fromHandle ?? '') + if (!asker) return { handled: true, refused: 'no-sender' } + const room = Object.values(state.rooms || {}).find(record => record?.localInvite?.ticket === ticket) + if (room === undefined) return { handled: true, refused: 'unknown' } + const invite = room.localInvite + if (!(Number(invite.expiresAt) > Date.now())) return { handled: true, refused: 'expired' } + if (invite.acceptedBy && invite.acceptedBy !== asker) return { handled: true, refused: 'consumed' } + if (data.accept !== true) { + // The offer is information, not authority: the ticket is not consumed, and no member joins. + await sendShareTo(room, { handle: asker }, { + op: 'invite', + ticket, + roomId: room.id, + name: room.name, + lead: room.admin ? state.bots?.[room.admin]?.name : undefined, + expiresAt: invite.expiresAt, + ...shareInviter(room), + }, 'room-share') + return { handled: true, offer: { ticket, roomId: room.id, name: room.name, expiresAt: invite.expiresAt } } + } + // The claim lands here, once: the room record keeps the one home the ticket admitted. + const claimed = invite.acceptedBy !== asker + if (claimed) { + invite.acceptedBy = asker + invite.acceptedAt = Date.now() + } + try { + const seated = currentParticipants(room).some(p => p.remote === true && (p.handle === asker || p.id === asker)) + if (seated) { + // The usual retry: the join reply was lost. Success without a second seat, and say it again. + await rt.save?.() + void rt.notifyRoomShare?.(room, { op: 'join', handle: asker }) + } else { + const ownerId = actor?.memberId ?? Object.keys(state.members || {})[0] + await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined)) + } + } catch (error) { + // Nothing was added, so the ticket must not be burnt either. + if (claimed) { + delete invite.acceptedBy + delete invite.acceptedAt + } + rt.warn?.('ds-bot: a room invite accept from %s was refused: %s', asker, error) + return { handled: true, refused: 'not-addable' } + } + return { handled: true, room, accepted: true } + } + // Room lines go out to the friends' Bots in the room, checked per author and per recipient // (#51): a line written by a local Bot that may not share the room never leaves, a friend's // Bot that is no longer granted to us gets nothing, and the sending Bot is one that may @@ -1217,6 +1277,13 @@ export function install(rt) { const fromName = cleanPeerName(event.fromName || data.inviterName, 'a friend') const localBotId = event.localBotId && state.bots?.[event.localBotId] ? event.localBotId : undefined const ownerId = actor?.memberId ?? event.ownerId ?? Object.keys(state.members || {})[0] + // #852: answered before the branch below, so a knock or an offer never projects a room here. + if (data.op === 'ask') return answerLocalInvite(data, event, actor) + if (data.op === 'invite') { + // The dialog shows the offer; it admits nobody and creates nothing on this side. + void rt.onLocalInviteOffer?.(data, { fromHandle, fromHome, fromName }) + return { handled: true, offer: data } + } let room = inboundOf(data.roomId, fromHome, fromHandle) if (data.op === 'leave') { if (!room) return { handled: true, room: undefined } diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index d5c1eba39..8a7d6a710 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -4,7 +4,11 @@ export const ROOM_SHARE_MARK = 'FLYBOT-ROOM-SHARE' -const OPS = new Set(['join', 'leave', 'line']) +// #852: `ask` is the invitee knocking at a local room's link (or accepting it with `accept: true`); +// `invite` is the inviter's answer with the room's name and the ticket's deadline. Neither creates a +// room on the receiving side, and neither is authority: only the room record decides. +const OPS = new Set(['join', 'leave', 'line', 'ask', 'invite']) +const TICKET_SHAPE = /^[A-Za-z0-9_-]{16,64}$/ function clip(value, max = 60) { return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max) @@ -22,6 +26,12 @@ export function encodeRoomShare(payload = {}) { invitedHandle: clip(payload.invitedHandle, 120), seq: Number.isFinite(Number(payload.seq)) ? Number(payload.seq) : 0, } + // #852: the link's ticket, the second (accepting) knock, and the inviter's answer fields. + if (payload.ticket) body.ticket = clip(payload.ticket, 64) + if (payload.accept === true) body.accept = true + if (payload.lead) body.lead = clip(payload.lead) + if (Number.isFinite(Number(payload.expiresAt)) && Number(payload.expiresAt) > 0) body.expiresAt = Math.floor(Number(payload.expiresAt)) + if (body.op === 'ask' && !payload.roomId) delete body.roomId if (payload.who) body.who = clip(payload.who) if (payload.kind) body.kind = clip(payload.kind, 32) if (payload.authorKind) body.authorKind = clip(payload.authorKind, 16) @@ -79,7 +89,11 @@ export function parseRoomShare(text) { if (end < 0) return undefined try { const data = JSON.parse(raw.slice(start, end + 1)) - if (data?.v !== 1 || !OPS.has(data.op) || !data.roomId) return undefined + if (data?.v !== 1 || !OPS.has(data.op)) return undefined + // A knock names the ticket and nothing else; an offer must also name the room it is for. + if (data.op === 'ask') return TICKET_SHAPE.test(String(data.ticket ?? '')) ? data : undefined + if (data.op === 'invite') return TICKET_SHAPE.test(String(data.ticket ?? '')) && data.roomId ? data : undefined + if (!data.roomId) return undefined return data } catch { return undefined diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index b6e828a55..b1d849651 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -1205,7 +1205,14 @@ test('#852: a room link is one live ticket — ten minutes, one per room, replac // Ten minutes is the whole life: past the deadline the room offers no preview, and a fresh mint works. rt.state.rooms.war.localInvite.expiresAt = Date.now() - 1 assert.equal(rt.localInviteOf('war'), undefined, 'an expired ticket offers nothing') + assert.equal(rt.roomOfLocalInvite(rt.state.rooms.war.localInvite.ticket), undefined, 'an expired ticket resolves no room, so accept cannot open one') assert.notEqual(await rt.mintRoomInvite('war'), undefined, 'the room can hand out a fresh one') + // A handle the link cannot carry must not leave a ticket behind, and must not replace a live one. + const kept = rt.localInviteOf('war') + rt.state.bots['chief-a'].fields.social.member.handle = 'not a handle' + await assert.rejects(() => rt.mintRoomInvite('war'), /no Bot handle a link can carry/) + assert.equal(rt.localInviteOf('war').ticket, kept.ticket, 'the refused mint left the live ticket alone') + rt.state.bots['chief-a'].fields.social.member.handle = first.handle // Withdraw clears it for good, and deleting the room takes its link with it. const third = await rt.mintRoomInvite('war') @@ -1228,9 +1235,80 @@ test('#852: a local link is not a face-room link, and a shared or face room has const { rt } = host(t, TEAM) await rt.load() const room = rt.state.rooms.war + // No Bot Social identity on this machine yet: the refusal has to say that, not "sign in". + await assert.rejects(() => rt.mintRoomInvite('war'), /no Bot Social identity yet/) room.origin = 'inbound-share' await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) room.origin = 'face-to-face' room.face = { roomId: 'frm_1', members: [] } await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) }) +test('#852: the knock asks the room record, and only the accept admits one home', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + const invite = await rt.mintRoomInvite('war') + // Both envelopes parse by op: a knock needs a ticket and carries no room id, an offer needs the room. + const askText = encodeRoomShare({ op: 'ask', ticket: invite.ticket }) + assert.equal(JSON.parse(askText.split('\n')[1]).roomId, undefined, 'a knock carries no room id') + assert.equal(parseRoomShare(askText).op, 'ask') + assert.equal(parseRoomShare(encodeRoomShare({ op: 'ask', ticket: 'short' })), undefined, 'a ticket keeps its own length') + assert.equal(parseRoomShare(encodeRoomShare({ op: 'invite', ticket: invite.ticket })), undefined, 'an offer names its room') + + const knock = (payload, fromHandle = '@coder.bob.home', fromHome = '@bob.home') => rt.applyInboundRoomShare({ + text: encodeRoomShare(payload), fromHandle, fromHome, fromName: 'Coder', + }) + const roomsBefore = Object.keys(rt.state.rooms).length + const seats = () => rt.state.rooms.war.participants.filter(p => p.remote === true) + + // The knock alone: an offer goes back, nothing is created, nobody joins, the ticket is not consumed. + const offer = await knock({ op: 'ask', ticket: invite.ticket }) + assert.equal(offer.handled, true) + const answered = sent.map(m => parseRoomShare(m.text)).filter(d => d?.op === 'invite') + assert.equal(answered.length, 1, 'the host answers the knock with one offer') + assert.equal(answered[0].ticket, invite.ticket) + assert.equal(answered[0].roomId, 'war') + assert.equal(answered[0].name, 'War room') + assert.ok(answered[0].expiresAt > Date.now(), 'the offer carries the deadline the inviter clock set') + assert.equal(Object.keys(rt.state.rooms).length, roomsBefore, 'a knock creates no room on either side') + assert.equal(seats().length, 0, 'a knock adds nobody') + assert.equal(rt.state.rooms.war.localInvite.acceptedBy, undefined, 'the offer does not consume the ticket') + + // The accept: consume, seat once, and the join goes back; the same home retrying adds no second seat. + const accepted = await knock({ op: 'ask', ticket: invite.ticket, accept: true }) + assert.equal(accepted.accepted, true) + assert.equal(rt.state.rooms.war.localInvite.acceptedBy, '@coder.bob.home') + assert.equal(seats().length, 1, 'the accept seats the asker once') + assert.equal(seats()[0].handle, '@coder.bob.home') + const joins = () => sent.filter(m => m.text.includes('"op":"join"')) + const before = joins().length + const again = await knock({ op: 'ask', ticket: invite.ticket, accept: true }) + assert.equal(again.accepted, true, 'the same home retrying is idempotent success') + assert.equal(seats().length, 1, 'no second seat') + assert.ok(joins().length > before, 'the lost join reply is sent again') + + // Another home cannot take the seat the ticket already gave one home. + const other = await knock({ op: 'ask', ticket: invite.ticket, accept: true }, '@coder.eve.home', '@eve.home') + assert.equal(other.refused, 'consumed') + assert.equal(seats().length, 1, 'the refused home seats nobody') + + // Unknown, expired and sender-less knocks change nothing and say why. + assert.equal((await knock({ op: 'ask', ticket: 'A'.repeat(22) })).refused, 'unknown') + const { expiresAt } = rt.state.rooms.war.localInvite + rt.state.rooms.war.localInvite.expiresAt = Date.now() - 1 + assert.equal((await knock({ op: 'ask', ticket: invite.ticket })).refused, 'expired') + rt.state.rooms.war.localInvite.expiresAt = expiresAt + assert.equal((await knock({ op: 'ask', ticket: invite.ticket }, '')).refused, 'no-sender') + assert.equal(seats().length, 1, 'no refusal seats anyone') + // An add the room refuses must not burn the ticket: the claim is rolled back with the seat. + const fresh = await rt.mintRoomInvite('war') + const refusedAdd = await knock({ op: 'ask', ticket: fresh.ticket, accept: true }, '@stranger.eve.home', '@eve.home') + assert.equal(refusedAdd.refused, 'not-addable', 'a Bot the room cannot seat is refused') + assert.equal(rt.state.rooms.war.localInvite.acceptedBy, undefined, 'the refused add left the ticket unconsumed') + assert.equal(seats().length, 1, 'and seated nobody') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index b4f475109..b718c7de1 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -152,7 +152,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - A room that lives in FlyBot can hand out one link: `https://social.botawaken.com/r//`. `core/src/shared/room-share.js` `parseRoomInviteLink` reads both shapes in the one invite field and says which one it saw (`{ kind: 'local', handle, ticket }` / `{ kind: 'face', ticket }`), and `roomInviteUrl` builds the local one. The ticket is `randomBytes(16)` in base64url — 22 characters of `[A-Za-z0-9_-]`, inside the 64-character shape `FACE_INVITE_TICKET` already accepts. The handle is its own path segment because `send_as_member` addresses a Bot by handle, and a ticket cannot carry one inside that limit. - The room record is the only authority: `room.localInvite { ticket, handle, expiresAt, mintedAt }`, ten minutes (`LOCAL_INVITE_TTL_MS`, the face-room window). `rt.mintRoomInvite(roomId)` replaces the previous ticket, so there is one live ticket per room; `rt.localInviteOf(roomId)` answers only while it is live; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId)` and deleting the room clear it. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. -- The link is not reachable yet: this half adds the record and the URL shape. The `ask` / `invite` / `accept` envelopes and their fail-closed order still have to be wired into `applyInboundRoomShare` and the social plugin, in this same issue after #858. Covered by `core/test/remote-room.test.js` (#852). +- Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. `acceptedBy` is written once, at accept: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home is refused; an add the room refuses rolls the claim back, so no refusal burns the ticket. Refusals are local on the inviter's host (`unknown` / `expired` / `consumed` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). ### Face-to-face owner send diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 056f37b59..8463802b0 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -31,7 +31,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers | `plugins/social/src/server-config.js` | Which server this FlyBot uses (#337): address validation (`parseServerUrl`), the `GET /v1/platform` probe (`probeServer`), and `createServerController`, which starts the social layer for the effective server and switches it live. `index.js` wires it and registers `social/server`, `social/server-check`, `social/server-set`. | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). | | `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). | -| `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, `parseFaceInviteLink`), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | +| `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, `parseInviteLink` / `parseFaceInviteLink`), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | | `plugins/social/src/presence.js` | "Name is thinking…" for friends' Bots (#297): sends `set_presence` for my Bots' turns that answer a friend or run in an open face room (`turn.start` / `turn.end`, heartbeat), polls `list_presence` only while a social chat asks (`social/typing`), clears a line when that Bot's message arrives. Gated on the `presence` feature. | | `plugins/social/src/face-title.js` | Face-to-face room titles (`Face-to-face · Alice, Bob · 482913`). Ships in the plugin so a packaged install can import (#153); core keeps its own copy for hosted rooms. | | `plugins/social/client.js` | Settings → **Bot Social** (`SocialServerSettings`, slot `flybot.settings.page`); `PeerThinking` dock line (`social-thinking`, order 60: one `bt-shimmer` line `Name is thinking…`). Social strip: **N to approve** badge, Add friend / Face-to-face panels from +, Past rooms; sidebar + Face-to-face room / Add friend (own handle line, per-Bot **Copy invite link**); in-chat Approve / Reject dock, waiting `flybot.room.overview` tile grid (6-digit code and its QR, then Leave / Enter chat), open room **Invite by link** card (link QR, Copy link, Withdraw link); Bot settings Sharing (Copy invite link) / Share with a friend / People with access (inside the host's titled **Sharing** card, so `BotSocialDetails` draws no title of its own, #406), or Auto-send + Remove this Bot on a friend's Bot. | @@ -108,7 +108,7 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_ - Face-to-face room cards use a short status chip (`data-flybot=face-status`): live rooms read **open**; expired matching windows under **Past rooms** read **ended**; candidates show a countdown (`12m` / `45s`); closed/left keep their labels (#166). - Join → waiting → leave (#306): **Join** is enabled as soon as a Bot is picked (a short number answers with the inline `Enter a 6-digit number.`; no Bot picked shows `Pick a Bot first.`). The waiting countdown reads `m:ss left to join` and ticks every second. The dialog overlay (`.fs-dialog-back`, z-index above the details panel and the Bot pane) covers the whole window, and while it waits in a room the details panel draws no second waiting card for it (`faceDialog`); my own Bots' waiting tiles use the roster look in both places. Every **Leave** (waiting dialog, waiting overview, open room) asks once (**Leave** / **Stay**, `ConfirmLeave`) and lands on the main Bot (`openMainBot`). In an open room **Leave** sits on the status-chip line, a row above the **Auto-send** toggle. The room subtitle counts what it means: `2 people · 2 Bots` (`faceRoomCount` in core `group-settings.js`). - QR for a room number (#773): the waiting dialog and the room panel draw the 6-digit number as a QR under it (`data-flybot=face-qr`, one `path` in an `svg`), so a phone camera or another FlyBot can scan instead of typing. The image is computed in the browser — byte mode, error correction M, version 1–10 picking the smallest that holds the text, every module of the matrix — with no request, no third-party image service and no runtime dependency. The payload is `FLYBOT-` plus the six digits and nothing else: a deadline in the image would re-encode every second and a screenshot would lie, so the countdown line beside it keeps ticking. The code goes when joining ends and as soon as the room opens, so a number on screen is never one that no longer works. Scanning reads the pixels with the browser's `BarcodeDetector` and the camera with `getUserMedia`; a browser without the decoder says so **before** it asks for the camera, a refused camera gets one sentence, and a QR from anywhere else is `That is not a FlyBot room code. Ask for the 6-digit number instead.` Typing the number always stays, and a successful scan ends in the same `social/face-join` the number field uses — it only fills the field. -- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner accept exactly one shape, `https://…/r/` (`parseFaceInviteLink`); a pasted or scanned value that is not that link is `That is not an invite link. It looks like https://…/r/…` and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. +- Invites into a room that already exists (#773, Bot Social #39): an open room shows **Invite by link** on its overview (`data-flybot=face-invite`), and **Create link** mints one with `POST /v1/face-rooms/{id}/invites` → `{ ticket, url, expires_at }`. What it draws is the URL itself as a QR, so a phone camera opens the room and FlyBot's own scan joins with the same ticket; nothing is minted in the browser. The card carries the link as text, **Copy link**, a countdown to the server deadline, and **Withdraw link** (`DELETE /v1/face-rooms/{id}/invites/{ticket}`), which drops the image and says nobody can join with it any more. The link expires with the room, so the code goes with the deadline. The input field and the scanner read one field with two shapes (`parseInviteLink`): this server's `https://…/r/`, and a link into a room on another FlyBot, `https://…/r/@handle/` (#852). The face-room path only ever answers the first — `parseFaceInviteLink` returns the ticket for the face shape and nothing for the local one — and a local link is refused by name, in the dialog, in the scanner and at the host route (`social/face-join-invite` answers before the `face_room_invites` gate, since that ticket was never this server's): `That link opens a room on another FlyBot. It does not join a face-to-face room.` Anything that is neither shape is `That is not an invite link. It looks like https://…/r/…`, and the 6-digit number stays the way in. Joining is `POST /v1/face-rooms/join-invite`, and each refusal keeps its own sentence: `invite_not_found`, `expired` and `revoked` each ask for a new one, `room_full`, `ended` → the room has closed, `already_member` → you are already in that room. Both the mint and the join paths are behind the same gate, so an older server never receives a request it cannot answer. The whole card is behind the `face_room_invites` entry in `GET /v1/platform` `features`: on a server without it the endpoints answer `{ unsupported: true }` without calling a route that is not there, and the card says `Invites need a newer Bot Social. The 6-digit number still works.` Those endpoints (`social/face-invite`, `social/face-invite-revoke`, `social/face-invite-preview`, `social/face-join-invite`) are listed in `SOCIAL_UI_ENDPOINTS`, so the UI cannot call what the host does not serve. A pasted or scanned invite asks the room first (#798): `social/face-invite-preview` answers `{ roomId, name, lead, members, maxMembers, expiresAt }` and the dialog draws a card whose first line is the name (a room Bot Social has no name for, which is every room FlyBot opens, reads `Face-to-face room` instead of reporting the missing field, #859), the lead, `members / maxMembers` when both are numbers (the count alone when only one is), and the deadline as a countdown; **Join** on that card runs the join above and **Clear** empties the field. Pressing **Join with link** again while that card is already up for the link in the field asks the host for nothing and keeps the card, countdown and all (#824); the guard compares the parsed ticket, not the typed text, while a field edit or **Clear** drops the card so the next press looks the link up once. The preview is information, never a gate: a 404 or a timeout drops the card and the same join runs with the same sentence; `{ unsupported: true }` is the one answer that stops there, because the invite feature is off on that server and a join would only reach the same sentence through a route that is not there. The card cleans `name` and `lead` itself with the same rules as `cleanFaceName`, so a stranger's brackets and markup reach the dialog as plain text, and it strips the untrusted fence markers until the string stops changing, as the server's `stripFaceMarkers` does: one pass can remove a match and assemble a new one, so a nested `` would otherwise leave a complete `` in the card a person reads (#822). The lead arrives from Bot Social as the member object (`{ handle, display_name, owner_name }`), not as a string, so the host renders it with the same `faceMemberLabel` rule the member list uses — `Lead: Chief (SelfTest A2)` — while a string answer keeps the `cleanFaceName` path, and an object that names nobody leaves the lead line out (#846). Every Bot picked in the dialog joins (#817): the link path asks the host once per selected Bot, exactly as the 6-digit number does. It has its own stop table, because the invite refusals do not look like the number ones: a ticket that is unknown, expired or withdrawn, a room that has ended and a room that is full stop the loop after the first refusal (those answers cannot change for the next Bot), while a refusal about one Bot alone — already in the room, its token revoked, not found, forbidden — keeps the other Bots going. A Bot that cannot join is listed by name while the Bots that did bring the room up, so one failure never hides the others. - Status chips on room details and Past rooms (`data-flybot=face-status`) read **Open** / **Closed** in sentence case with readable contrast, and the past row keeps its own spacing between avatars, title, time and chip (#170). - One card-placement rule (#418): a held message's decision sits where that message is. A Bot's reply to a friend has its buttons on the held reply bubble in place (no copy in the dock of that chat or the friend's chat); a message with no bubble of its own (a Bot's `message_friend_bot`, a typed composer line held by policy, a face-room line) has its card in the dock of the chat it belongs to, as below. - In-chat approval cards (`flybot.session.dock`) are right-aligned bubbles capped at ~460px: **Waiting for approval**, one `from → to` line, a word-wrapped preview with **Show more**, and **Approve** / **Reject** (one right-hand group) with a quiet **Always approve** link at the left of the same row, alone above them when the card is narrow (#166, #260, #271). One place per approval (#278): a Bot's direct message to a friend is drawn in the friend's chat once that chat exists (the sending Bot's own chat only until then), never in a Face-to-face room that has the friend in it; a Face-to-face room shows, counts as waiting, and gets a "Waiting for the owner's approval" notice for just the lines held for its own room id. The client also hides that old notice in a Face-to-face room's history. The held text shows once (#306): a Bot line held in a Face-to-face room keeps its text in the card, and the room draws no bubble for it until it is approved (`GroupMessage`, `core/src/client/cells.js`, matched by `room.heldLines`, #456, and by `room.pendingApprovals` as a fallback); a line the owner typed is never held and stays a plain bubble. A Bot's message to a friend (`message_friend_bot`) has no bubble in either chat, so the card is already its only place. #456: the held room line lives only on its card (greyed preview) until **Approve**: `onFaceLine` answers `{ held: true, approvalId }` for a Bot line Bot Social holds, `social/decide-approval` calls `flybot.rooms.settleHeld`, Approve makes it a room bubble and Reject a grey ` · Not sent` line, with no `Rejected.` note in the face room. A decided card leaves the dock at once (no **Approved** / **Rejected** stub, any card kind). A face card's `from` is the room's own #384 label for that local Bot (`Researcher (Alice) → the room`, `faceFromLabel`). `applyFaceSnapshot` posts a human or system event for a round only when the core room logged it now (`applyFaceLine` refuses an event id it has), so an event read twice starts no second round. diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 8d0c0d1d5..e34299596 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -961,7 +961,7 @@ export function install(rt) { ticket: invite.ticket, handle: invite.handle, expiresAt, - ...(typeof invite.consumedBy === 'string' && invite.consumedBy ? { consumedBy: invite.consumedBy } : {}), + ...(typeof invite.acceptedBy === 'string' && invite.acceptedBy ? { acceptedBy: invite.acceptedBy } : {}), } } @@ -976,7 +976,10 @@ export function install(rt) { const wanted = String(ticket ?? '') if (!wanted) return undefined for (const room of Object.values(state.rooms || {})) { - if (room?.localInvite?.ticket === wanted) return room + if (room?.localInvite?.ticket !== wanted) continue + // An expired ticket resolves nothing: the room record is the authority, and a dead link must + // not be able to open a room through the accept path. + return liveLocalInvite(room) ? room : undefined } return undefined } @@ -987,10 +990,13 @@ export function install(rt) { // A shared or face room has its own invitation; this one is for a room that lives in FlyBot. if (room.origin === 'inbound-share' || isFaceRoom(room)) throw invalid('This room has its own invitation') const handle = shareInviter(room).inviterHandle - if (!handle) throw invalid('Sign in first') + if (!handle) throw invalid('This machine has no Bot Social identity yet') const expiresAt = Date.now() + LOCAL_INVITE_TTL_MS // 16 random bytes in base64url are 22 characters, the shape the invite field already accepts. const ticket = randomBytes(16).toString('base64url') + // Build the link before the record is written: a handle the link cannot carry must not leave a + // ticket behind that no invitee could ever use. + if (!roomInviteUrl(handle, ticket, rt.roomInviteOrigin || ROOM_INVITE_ORIGIN)) throw invalid('This room has no Bot handle a link can carry') // One live ticket per room: minting again replaces the last one, and the old link stops working. room.localInvite = { ticket, handle, expiresAt, mintedAt: Date.now() } await rt.save?.() @@ -1006,6 +1012,60 @@ export function install(rt) { return { revoked: true } } + // #852: a knock at a local room's link. The invitee's host sends `ask` (the accepting knock carries + // `accept: true`); this side answers from the room record only, and a knock never creates a room, + // adds a participant, or writes a line. The asker is the transport's sender, never a field of the + // payload. Verify → consume → add → the join goes back from addRoomParticipant. + async function answerLocalInvite(data, event, actor) { + const ticket = String(data.ticket ?? '') + const asker = String(event.fromHandle ?? '') + if (!asker) return { handled: true, refused: 'no-sender' } + const room = Object.values(state.rooms || {}).find(record => record?.localInvite?.ticket === ticket) + if (room === undefined) return { handled: true, refused: 'unknown' } + const invite = room.localInvite + if (!(Number(invite.expiresAt) > Date.now())) return { handled: true, refused: 'expired' } + if (invite.acceptedBy && invite.acceptedBy !== asker) return { handled: true, refused: 'consumed' } + if (data.accept !== true) { + // The offer is information, not authority: the ticket is not consumed, and no member joins. + await sendShareTo(room, { handle: asker }, { + op: 'invite', + ticket, + roomId: room.id, + name: room.name, + lead: room.admin ? state.bots?.[room.admin]?.name : undefined, + expiresAt: invite.expiresAt, + ...shareInviter(room), + }, 'room-share') + return { handled: true, offer: { ticket, roomId: room.id, name: room.name, expiresAt: invite.expiresAt } } + } + // The claim lands here, once: the room record keeps the one home the ticket admitted. + const claimed = invite.acceptedBy !== asker + if (claimed) { + invite.acceptedBy = asker + invite.acceptedAt = Date.now() + } + try { + const seated = currentParticipants(room).some(p => p.remote === true && (p.handle === asker || p.id === asker)) + if (seated) { + // The usual retry: the join reply was lost. Success without a second seat, and say it again. + await rt.save?.() + void rt.notifyRoomShare?.(room, { op: 'join', handle: asker }) + } else { + const ownerId = actor?.memberId ?? Object.keys(state.members || {})[0] + await rt.addRoomParticipant(room.id, { kind: 'bot', handle: asker }, actor ?? (ownerId ? { memberId: ownerId } : undefined)) + } + } catch (error) { + // Nothing was added, so the ticket must not be burnt either. + if (claimed) { + delete invite.acceptedBy + delete invite.acceptedAt + } + rt.warn?.('ds-bot: a room invite accept from %s was refused: %s', asker, error) + return { handled: true, refused: 'not-addable' } + } + return { handled: true, room, accepted: true } + } + // Room lines go out to the friends' Bots in the room, checked per author and per recipient // (#51): a line written by a local Bot that may not share the room never leaves, a friend's // Bot that is no longer granted to us gets nothing, and the sending Bot is one that may @@ -1217,6 +1277,13 @@ export function install(rt) { const fromName = cleanPeerName(event.fromName || data.inviterName, 'a friend') const localBotId = event.localBotId && state.bots?.[event.localBotId] ? event.localBotId : undefined const ownerId = actor?.memberId ?? event.ownerId ?? Object.keys(state.members || {})[0] + // #852: answered before the branch below, so a knock or an offer never projects a room here. + if (data.op === 'ask') return answerLocalInvite(data, event, actor) + if (data.op === 'invite') { + // The dialog shows the offer; it admits nobody and creates nothing on this side. + void rt.onLocalInviteOffer?.(data, { fromHandle, fromHome, fromName }) + return { handled: true, offer: data } + } let room = inboundOf(data.roomId, fromHome, fromHandle) if (data.op === 'leave') { if (!room) return { handled: true, room: undefined } diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index d5c1eba39..8a7d6a710 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -4,7 +4,11 @@ export const ROOM_SHARE_MARK = 'FLYBOT-ROOM-SHARE' -const OPS = new Set(['join', 'leave', 'line']) +// #852: `ask` is the invitee knocking at a local room's link (or accepting it with `accept: true`); +// `invite` is the inviter's answer with the room's name and the ticket's deadline. Neither creates a +// room on the receiving side, and neither is authority: only the room record decides. +const OPS = new Set(['join', 'leave', 'line', 'ask', 'invite']) +const TICKET_SHAPE = /^[A-Za-z0-9_-]{16,64}$/ function clip(value, max = 60) { return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max) @@ -22,6 +26,12 @@ export function encodeRoomShare(payload = {}) { invitedHandle: clip(payload.invitedHandle, 120), seq: Number.isFinite(Number(payload.seq)) ? Number(payload.seq) : 0, } + // #852: the link's ticket, the second (accepting) knock, and the inviter's answer fields. + if (payload.ticket) body.ticket = clip(payload.ticket, 64) + if (payload.accept === true) body.accept = true + if (payload.lead) body.lead = clip(payload.lead) + if (Number.isFinite(Number(payload.expiresAt)) && Number(payload.expiresAt) > 0) body.expiresAt = Math.floor(Number(payload.expiresAt)) + if (body.op === 'ask' && !payload.roomId) delete body.roomId if (payload.who) body.who = clip(payload.who) if (payload.kind) body.kind = clip(payload.kind, 32) if (payload.authorKind) body.authorKind = clip(payload.authorKind, 16) @@ -79,7 +89,11 @@ export function parseRoomShare(text) { if (end < 0) return undefined try { const data = JSON.parse(raw.slice(start, end + 1)) - if (data?.v !== 1 || !OPS.has(data.op) || !data.roomId) return undefined + if (data?.v !== 1 || !OPS.has(data.op)) return undefined + // A knock names the ticket and nothing else; an offer must also name the room it is for. + if (data.op === 'ask') return TICKET_SHAPE.test(String(data.ticket ?? '')) ? data : undefined + if (data.op === 'invite') return TICKET_SHAPE.test(String(data.ticket ?? '')) && data.roomId ? data : undefined + if (!data.roomId) return undefined return data } catch { return undefined diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index b6e828a55..b1d849651 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -1205,7 +1205,14 @@ test('#852: a room link is one live ticket — ten minutes, one per room, replac // Ten minutes is the whole life: past the deadline the room offers no preview, and a fresh mint works. rt.state.rooms.war.localInvite.expiresAt = Date.now() - 1 assert.equal(rt.localInviteOf('war'), undefined, 'an expired ticket offers nothing') + assert.equal(rt.roomOfLocalInvite(rt.state.rooms.war.localInvite.ticket), undefined, 'an expired ticket resolves no room, so accept cannot open one') assert.notEqual(await rt.mintRoomInvite('war'), undefined, 'the room can hand out a fresh one') + // A handle the link cannot carry must not leave a ticket behind, and must not replace a live one. + const kept = rt.localInviteOf('war') + rt.state.bots['chief-a'].fields.social.member.handle = 'not a handle' + await assert.rejects(() => rt.mintRoomInvite('war'), /no Bot handle a link can carry/) + assert.equal(rt.localInviteOf('war').ticket, kept.ticket, 'the refused mint left the live ticket alone') + rt.state.bots['chief-a'].fields.social.member.handle = first.handle // Withdraw clears it for good, and deleting the room takes its link with it. const third = await rt.mintRoomInvite('war') @@ -1228,9 +1235,80 @@ test('#852: a local link is not a face-room link, and a shared or face room has const { rt } = host(t, TEAM) await rt.load() const room = rt.state.rooms.war + // No Bot Social identity on this machine yet: the refusal has to say that, not "sign in". + await assert.rejects(() => rt.mintRoomInvite('war'), /no Bot Social identity yet/) room.origin = 'inbound-share' await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) room.origin = 'face-to-face' room.face = { roomId: 'frm_1', members: [] } await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/) }) +test('#852: the knock asks the room record, and only the accept admits one home', async (t) => { + const { rt, sent } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + const invite = await rt.mintRoomInvite('war') + // Both envelopes parse by op: a knock needs a ticket and carries no room id, an offer needs the room. + const askText = encodeRoomShare({ op: 'ask', ticket: invite.ticket }) + assert.equal(JSON.parse(askText.split('\n')[1]).roomId, undefined, 'a knock carries no room id') + assert.equal(parseRoomShare(askText).op, 'ask') + assert.equal(parseRoomShare(encodeRoomShare({ op: 'ask', ticket: 'short' })), undefined, 'a ticket keeps its own length') + assert.equal(parseRoomShare(encodeRoomShare({ op: 'invite', ticket: invite.ticket })), undefined, 'an offer names its room') + + const knock = (payload, fromHandle = '@coder.bob.home', fromHome = '@bob.home') => rt.applyInboundRoomShare({ + text: encodeRoomShare(payload), fromHandle, fromHome, fromName: 'Coder', + }) + const roomsBefore = Object.keys(rt.state.rooms).length + const seats = () => rt.state.rooms.war.participants.filter(p => p.remote === true) + + // The knock alone: an offer goes back, nothing is created, nobody joins, the ticket is not consumed. + const offer = await knock({ op: 'ask', ticket: invite.ticket }) + assert.equal(offer.handled, true) + const answered = sent.map(m => parseRoomShare(m.text)).filter(d => d?.op === 'invite') + assert.equal(answered.length, 1, 'the host answers the knock with one offer') + assert.equal(answered[0].ticket, invite.ticket) + assert.equal(answered[0].roomId, 'war') + assert.equal(answered[0].name, 'War room') + assert.ok(answered[0].expiresAt > Date.now(), 'the offer carries the deadline the inviter clock set') + assert.equal(Object.keys(rt.state.rooms).length, roomsBefore, 'a knock creates no room on either side') + assert.equal(seats().length, 0, 'a knock adds nobody') + assert.equal(rt.state.rooms.war.localInvite.acceptedBy, undefined, 'the offer does not consume the ticket') + + // The accept: consume, seat once, and the join goes back; the same home retrying adds no second seat. + const accepted = await knock({ op: 'ask', ticket: invite.ticket, accept: true }) + assert.equal(accepted.accepted, true) + assert.equal(rt.state.rooms.war.localInvite.acceptedBy, '@coder.bob.home') + assert.equal(seats().length, 1, 'the accept seats the asker once') + assert.equal(seats()[0].handle, '@coder.bob.home') + const joins = () => sent.filter(m => m.text.includes('"op":"join"')) + const before = joins().length + const again = await knock({ op: 'ask', ticket: invite.ticket, accept: true }) + assert.equal(again.accepted, true, 'the same home retrying is idempotent success') + assert.equal(seats().length, 1, 'no second seat') + assert.ok(joins().length > before, 'the lost join reply is sent again') + + // Another home cannot take the seat the ticket already gave one home. + const other = await knock({ op: 'ask', ticket: invite.ticket, accept: true }, '@coder.eve.home', '@eve.home') + assert.equal(other.refused, 'consumed') + assert.equal(seats().length, 1, 'the refused home seats nobody') + + // Unknown, expired and sender-less knocks change nothing and say why. + assert.equal((await knock({ op: 'ask', ticket: 'A'.repeat(22) })).refused, 'unknown') + const { expiresAt } = rt.state.rooms.war.localInvite + rt.state.rooms.war.localInvite.expiresAt = Date.now() - 1 + assert.equal((await knock({ op: 'ask', ticket: invite.ticket })).refused, 'expired') + rt.state.rooms.war.localInvite.expiresAt = expiresAt + assert.equal((await knock({ op: 'ask', ticket: invite.ticket }, '')).refused, 'no-sender') + assert.equal(seats().length, 1, 'no refusal seats anyone') + // An add the room refuses must not burn the ticket: the claim is rolled back with the seat. + const fresh = await rt.mintRoomInvite('war') + const refusedAdd = await knock({ op: 'ask', ticket: fresh.ticket, accept: true }, '@stranger.eve.home', '@eve.home') + assert.equal(refusedAdd.refused, 'not-addable', 'a Bot the room cannot seat is refused') + assert.equal(rt.state.rooms.war.localInvite.acceptedBy, undefined, 'the refused add left the ticket unconsumed') + assert.equal(seats().length, 1, 'and seated nobody') +}) diff --git a/plugins/social/client.js b/plugins/social/client.js index b1a779190..7b637c515 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -881,6 +881,10 @@ window.__ModuleLoader__.load({ const faceInviteTicket = (text) => String(text ?? '').trim().match(/^(?:https?:)?\/\/[^\s/]+\/r\/([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i)?.[1] ?? '' const INVITE_UNSUPPORTED = 'Invites need a newer Bot Social. The 6-digit number still works.' const INVITE_LINK_COPY = 'That is not an invite link. It looks like https://…/r/…' + // Two shapes in one field (#852): the local one names the Bot to knock on, and its ticket is not a + // face-room invite. Same regex as the host's parseInviteLink; the sentence is shown here too. + const localInviteHandle = (text) => String(text ?? '').trim().match(/^(?:https?:)?\/\/[^\s/]+\/r\/(@[A-Za-z0-9._-]{1,120})\/[A-Za-z0-9_-]{16,64}\/?(?:[?#].*)?$/i)?.[1] ?? '' + const LOCAL_INVITE_COPY = 'That link opens a room on another FlyBot. It does not join a face-to-face room.' // The preview card names a room the person has not entered (#798). `name` is that room's own // string and the host passes it through as sent, so the last place before a person reads it // cleans it: one line, no controls, brackets, markup or @, capped — the same rules as the @@ -1384,6 +1388,10 @@ window.__ModuleLoader__.load({ const clearInvite = () => setInvite({ link: '', status: null }) const previewLink = async (typed) => { const text = String(typed ?? invite.link ?? '').trim() + if (localInviteHandle(text)) { + setInvite({ link: text, status: { tone: 'error', text: LOCAL_INVITE_COPY } }) + return + } const ticket = faceInviteTicket(text) if (!ticket) { setInvite({ link: text, status: { tone: 'error', text: INVITE_LINK_COPY } }) @@ -1426,6 +1434,10 @@ window.__ModuleLoader__.load({ return } const raw = String(result?.raw ?? '') + if (localInviteHandle(raw)) { + setScan({ tone: 'error', text: LOCAL_INVITE_COPY }) + return + } if (faceInviteTicket(raw)) { setScan({ tone: 'ok', text: 'Scanned an invite link.' }) void previewLink(raw) diff --git a/plugins/social/src/face-rooms.js b/plugins/social/src/face-rooms.js index b30875bb3..c4d3aa635 100644 --- a/plugins/social/src/face-rooms.js +++ b/plugins/social/src/face-rooms.js @@ -49,12 +49,27 @@ export const FACE_INVITE_UNSUPPORTED = 'Invites need a newer Bot Social. The 6-d export const invalidInviteLink = () => new FaceRoomError('That is not an invite link. It looks like https://…/r/…', 'invalid_invite') // A ticket is never accepted on its own: whoever reads "r/…" on a screen gets a link, and a bare // string could be anything. Unknown shapes answer '' and the caller says so in one sentence. -export function parseFaceInviteLink(value) { +// Since #852 the one invite field carries two shapes. Bot Social's link is `/r/`; a room on +// another FlyBot names the Bot to knock on — `/r/@handle/` — and its ticket belongs to that +// FlyBot, not to this server. Callers that must know which one they hold ask here; `parseFaceInviteLink` +// keeps answering only the face-room ticket, so a local link is never handed to Bot Social as its own. +export function parseInviteLink(value) { const text = String(value ?? '').trim() - const match = text.match(/^(?:https?:)?\/\/[^\s/]+\/r\/([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i) - return match ? match[1] : '' + const match = text.match(/^(?:https?:)?\/\/[^\s/]+\/r\/(?:(@[A-Za-z0-9._-]{1,120})\/)?([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i) + if (!match) return undefined + return match[1] ? { kind: 'local', handle: match[1], ticket: match[2] } : { kind: 'face', ticket: match[2] } } +export function parseFaceInviteLink(value) { + const parsed = parseInviteLink(value) + return parsed?.kind === 'face' ? parsed.ticket : '' +} + +// A local room's link is not refused because it is malformed: it is well formed and means something +// this server cannot do. One sentence says which, so the person does not go looking for a typo. +export const LOCAL_INVITE_COPY = 'That link opens a room on another FlyBot. It does not join a face-to-face room.' +export const localInviteHandle = value => parseInviteLink(value)?.handle ?? '' + // Same class of sanitizer as core cleanName / cleanPeerName: one line, no controls, // brackets, markup or @, capped. Used for every peer-controlled name and for platform // system copy that embeds those names. diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 2b982dd5d..1c04b6538 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -57,6 +57,8 @@ import { FACE_INVITE_TICKET, FACE_INVITE_FEATURE, FACE_INVITE_UNSUPPORTED, + LOCAL_INVITE_COPY, + localInviteHandle, invalidInviteLink, } from './face-rooms.js' import { @@ -3655,7 +3657,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp disposers.push(flybot.api.handle('social/face-invite-preview', async ({ ticket } = {}) => (await serverInvites()) ? faceNeed().previewInvite(ticket) : { unsupported: true })) // Joining by ticket is the same feature as minting one: an older server answers one sentence // instead of trying a route it does not serve. - disposers.push(flybot.api.handle('social/face-join-invite', async ({ ticket, botId } = {}) => (await serverInvites()) ? joinFaceRoom({ ticket, botId }) : { unsupported: true })) + disposers.push(flybot.api.handle('social/face-join-invite', async ({ ticket, botId } = {}) => { + // A link into a room on another FlyBot is answered before the feature gate: its ticket was never + // this server's, so answering "needs a newer Bot Social" would point at the wrong thing (#852). + const local = localInviteHandle(ticket) + if (local) throw new FaceRoomError(LOCAL_INVITE_COPY, 'local_invite') + return (await serverInvites()) ? joinFaceRoom({ ticket, botId }) : { unsupported: true } + })) disposers.push(flybot.api.handle('social/face-open', async (payload) => openFaceRoom(payload), { read: true })) disposers.push(flybot.api.handle('social/face-rooms', async (payload) => { if (payload?.poll !== false) await pollFaceRooms().catch(() => { /* a failed poll shows the rooms as last polled */ }) diff --git a/plugins/social/test/face-rooms.test.js b/plugins/social/test/face-rooms.test.js index 4d6d14782..c2b49b260 100644 --- a/plugins/social/test/face-rooms.test.js +++ b/plugins/social/test/face-rooms.test.js @@ -26,6 +26,9 @@ import { publishedForJoin, createFaceRoomApi, parseFaceInviteLink, + parseInviteLink, + localInviteHandle, + LOCAL_INVITE_COPY, faceInviteUrl, FACE_INVITE_UNSUPPORTED, FaceRoomError, @@ -596,6 +599,23 @@ test('#773: only a /r/ link is an invite — a bare ticket or another pa assert.match(FACE_INVITE_UNSUPPORTED, /6-digit number still works/) }) +test('#852: two link shapes in one field — a local room link names the Bot to knock on', () => { + const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/' + INVITE_TICKET + assert.deepEqual(parseInviteLink(INVITE_URL), { kind: 'face', ticket: INVITE_TICKET }) + assert.deepEqual(parseInviteLink(LOCAL_URL), { kind: 'local', handle: '@chief.alice.home', ticket: INVITE_TICKET }) + assert.deepEqual(parseInviteLink(LOCAL_URL + '/?from=chat'), { kind: 'local', handle: '@chief.alice.home', ticket: INVITE_TICKET }) + assert.equal(parseInviteLink(INVITE_TICKET), undefined, 'a bare ticket is not a link') + assert.equal(parseInviteLink('https://bot-social.example.com/r/@chief.alice.home/short'), undefined) + assert.equal(parseInviteLink('https://bot-social.example.com/r/@chief.alice.home'), undefined, 'the ticket is not optional') + // The face parser must never answer a ticket a local link carries: that ticket is another FlyBot's. + assert.equal(parseFaceInviteLink(LOCAL_URL), '') + assert.equal(parseFaceInviteLink(INVITE_URL), INVITE_TICKET, 'the face shape still answers its ticket') + assert.equal(localInviteHandle(LOCAL_URL), '@chief.alice.home') + assert.equal(localInviteHandle(INVITE_URL), '', 'a face link names no Bot to knock on') + assert.match(LOCAL_INVITE_COPY, /opens a room on another FlyBot/) + assert.match(LOCAL_INVITE_COPY, /does not join a face-to-face room/) +}) + test('#773: mint asks for a ticket and builds the link from the room id', async () => { const calls = [] const fetch = async (url, init) => { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index fecddeebf..69a1ff434 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9791,6 +9791,33 @@ test('#773: a pasted link joins through social/face-join-invite, and a non-link assert.equal(calls.length, before, 'the invalid link went nowhere') }) +test('#852: a link into a room on another FlyBot is answered in one sentence and calls nobody', async () => { + const LOCAL_URL = 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv' + const calls = [] + const statuses = [] + let index = 0 + const bots = [{ id: 'a', name: 'Chief', isMain: true }] + const states = ['setup', bots, ['a'], '', false, null, [], null, 0, null, { link: LOCAL_URL, status: null }] + const { registered, walk } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]); return {} }) + const plugin = registered[0].factory(() => ({ + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat() }), + Fragment: 'F', + useEffect() {}, + useMemo: fn => fn(), + useRef: value => ({ current: value }), + useState: value => { + const at = index++ + const initial = at < states.length ? states[at] : value + return [initial, next => { if (at === 10) statuses.push(next) }] + }, + })) + const tree = walk(plugin.FaceToFaceGroup({})) + tree.find(n => n.props['data-flybot'] === 'face-link-join-go').props.onClick() + await new Promise(resolve => setImmediate(resolve)) + assert.equal(statuses.at(-1).status.text, 'That link opens a room on another FlyBot. It does not join a face-to-face room.') + assert.deepEqual(calls, [], 'no preview and no join: the ticket is not this server\'s') +}) + test('#773: scanning a room invite link joins that room with its ticket', async () => { const scanned = await scanCase({ detector: class { detect() { return Promise.resolve([{ rawValue: INVITE_URL }]) } }, @@ -9914,6 +9941,21 @@ test('#773: tapping Scan twice releases the camera the first tap opened', async assert.deepEqual(stopped, [1], 'and the first one is released instead of leaking') }) +test('#852: the host refuses a local room link before the feature gate, and sends nothing', async (t) => { + const { call, server } = await inviteServer(t, ['face_room_invites']) + const before = server.calls.length + const failed = await call('flybot/social/face-join-invite', { + ticket: 'https://bot-social.example.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv', botId: 'a', + }) + assert.equal(failed.error.message, 'That link opens a room on another FlyBot. It does not join a face-to-face room.') + assert.equal(server.calls.length, before, 'no request at all: not an invite, not a join') + // The same route still answers the face shape and still gates on the feature. + const { call: call2, server: server2 } = await inviteServer(t, ['typed_by_owner']) + const older = await call2('flybot/social/face-join-invite', { ticket: 'AbCdEfGhIjKlMnOpQrStUv', botId: 'a' }) + assert.deepEqual(JSON.parse(JSON.stringify(older.value)), { unsupported: true }) + assert.deepEqual(inviteCalls(server2), []) +}) + test('#773: joining by ticket on a server without face_room_invites never reaches the route', async (t) => { const { call, server } = await inviteServer(t, ['typed_by_owner']) const joined = await call('flybot/social/face-join-invite', { ticket: 'AbCdEfGhIjKlMnOpQrStUv', botId: 'a' })