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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/852-local-room-invites.added.md
Original file line number Diff line number Diff line change
@@ -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/<ticket>` and a room on another FlyBot, `/r/@handle/<ticket>` — 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.
73 changes: 70 additions & 3 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
}
}

Expand All @@ -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
}
Expand All @@ -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?.()
Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
18 changes: 16 additions & 2 deletions core/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<inviter Bot handle>/<ticket>`. `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

Expand Down
Loading
Loading