From acedb1469456fd678cf81c236ae2976d4fefeb93 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Fri, 23 Oct 2026 19:35:46 +0800 Subject: [PATCH] feat(rooms): mint and withdraw a local room invite link (#852) A room that lives in FlyBot can hand out one link: /r//, ten minutes, one live ticket per room, replaced on re-mint, cleared on withdraw and with the room. The handle is its own path segment because send_as_member addresses a Bot by handle, and a ticket cannot carry one inside the 64-character invite shape. parseRoomInviteLink now reads both shapes and says which one it saw, so a face-room /r/ still resolves exactly as before. The envelopes (ask/invite/accept) and their fail-closed order are the next commit on this branch, after #858. --- core/src/host/room-membership.js | 63 ++++++++++++++++++- core/src/shared/room-share.js | 23 +++++++ core/test/remote-room.test.js | 59 ++++++++++++++++- docs/subsystems/core-host.md | 6 ++ .../ds-bot/files/src/host/room-membership.js | 63 ++++++++++++++++++- patches/ds-bot/files/src/shared/room-share.js | 23 +++++++ patches/ds-bot/files/test/remote-room.test.js | 59 ++++++++++++++++- 7 files changed, 292 insertions(+), 4 deletions(-) diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index fce30cbfb..b5ba5f51a 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -33,9 +33,10 @@ // Inbound shared rooms (`origin: 'inbound-share'`) are the remote owner's projection // of a friend's room that invited their granted Bot. +import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js' +import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' @@ -932,6 +933,66 @@ export function install(rt) { } } + // #852: the link invitation for a room that lives here. The room record holds the one live ticket; + // the link is not authority on its own — every knock and every accept is read back against this + // record, and the ticket stops answering the moment it is withdrawn, replaced, expired, or the room + // is gone. Ten minutes, like the face-room window. + const ROOM_INVITE_ORIGIN = 'https://social.botawaken.com' + + function liveLocalInvite(room, at = Date.now()) { + const invite = room?.localInvite + if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) return undefined + const expiresAt = Number(invite.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= at) return undefined + return { + ticket: invite.ticket, + handle: invite.handle, + expiresAt, + ...(typeof invite.consumedBy === 'string' && invite.consumedBy ? { consumedBy: invite.consumedBy } : {}), + } + } + + rt.localInviteOf = function localInviteOf(roomId) { + const live = liveLocalInvite(rt.roomOf(roomId)) + if (!live) return undefined + const origin = rt.roomInviteOrigin || ROOM_INVITE_ORIGIN + return { ...live, url: roomInviteUrl(live.handle, live.ticket, origin) } + } + + rt.roomOfLocalInvite = function roomOfLocalInvite(ticket) { + const wanted = String(ticket ?? '') + if (!wanted) return undefined + for (const room of Object.values(state.rooms || {})) { + if (room?.localInvite?.ticket === wanted) return room + } + return undefined + } + + rt.mintRoomInvite = async function mintRoomInvite(roomId) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + // 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') + 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') + // 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?.() + return rt.localInviteOf(room.id) + } + + rt.revokeRoomInvite = async function revokeRoomInvite(roomId) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + if (!room.localInvite) return { revoked: false } + delete room.localInvite + await rt.save?.() + return { revoked: 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 diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index 4395354cd..d5c1eba39 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -37,6 +37,29 @@ export function encodeRoomShare(payload = {}) { return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}` } +// #852: a link invitation for a room that lives in FlyBot itself. No server knows this ticket: the +// inviter's host mints it and the room record keeps it. The link carries the inviter Bot's handle as +// its own path segment, because that handle is the only address the invitee can knock on — a ticket +// inside the 64-character shape cannot hold one. Ten minutes, like the face-room window, and never +// authority on its own: the room record decides. +export const LOCAL_INVITE_TTL_MS = 10 * 60 * 1000 +const INVITE_LINK = /^(?:https?:)?\/\/[^\s/]+\/r\/(?:(@[A-Za-z0-9._-]{1,120})\/)?([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i + +// Two shapes, one field: the face-room link the server owns, and the local one that names the Bot to +// knock on. Nothing here decides which is legitimate — the caller does, by kind. +export function parseRoomInviteLink(value) { + const match = String(value ?? '').trim().match(INVITE_LINK) + if (!match) return undefined + return match[1] ? { kind: 'local', handle: match[1], ticket: match[2] } : { kind: 'face', ticket: match[2] } +} + +export function roomInviteUrl(handle, ticket, origin = 'https://social.botawaken.com') { + const who = String(handle ?? '').trim() + const clean = String(ticket ?? '').trim() + if (!/^@[A-Za-z0-9._-]{1,120}$/.test(who) || !/^[A-Za-z0-9_-]{16,64}$/.test(clean)) return '' + return `${String(origin || '').replace(/\/+$/, '')}/r/${who}/${clean}` +} + export function parseRoomShare(text) { const raw = String(text ?? '') const at = raw.indexOf(ROOM_SHARE_MARK) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index da6c3613c..6fb0b572f 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -21,7 +21,7 @@ import { } from '../src/host/room-membership.js' import { install as installReach, reach } from '../src/host/reach.js' import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' -import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' +import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../src/shared/room-share.js' import { fenceUntrusted } from '../src/host/delivery.js' import { mentionRows } from '../src/client/at-menu.js' import { indexRoster } from '../src/client/sources.js' @@ -1112,3 +1112,60 @@ test('#825: an answer held for approval shows up as one line in the room, not as await rt.fanoutRoomLine(room, { seq: 10, who: 'Writer B', botId: 'spec-b', authorId: 'spec-b', authorKind: 'bot', text: 'held answer' }) assert.ok(room.log.some(line => line.kind === 'notice' && /held for approval/.test(line.text || '')), 'the room says so once') }) + +test('#852: a room link is one live ticket — ten minutes, one per room, replaced, withdrawn, gone with the room', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + await rt.authenticate(alice.sessionToken) + await grantToFriend(rt, 'chief-a') + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + assert.equal(rt.localInviteOf('war'), undefined, 'a room starts without a link') + + const first = await rt.mintRoomInvite('war') + assert.match(first.ticket, /^[A-Za-z0-9_-]{22}$/, '22 random characters, the shape the invite field accepts') + // The link answers "who do I knock on" by naming the inviter Bot in its own path segment. + assert.deepEqual(parseRoomInviteLink(first.url), { kind: 'local', handle: '@chief.a.home', ticket: first.ticket }) + assert.equal(roomInviteUrl('@chief.a.home', first.ticket, 'https://social.botawaken.com/'), first.url, 'one builder, no trailing slash') + const window = first.expiresAt - Date.now() + assert.ok(window > 9 * 60 * 1000 && window <= 10 * 60 * 1000, `ten minutes, the face-room window (got ${window} ms)`) + assert.equal(rt.roomOfLocalInvite(first.ticket).id, 'war') + + // One live ticket per room: minting again replaces the last one, and the old link stops answering. + const second = await rt.mintRoomInvite('war') + assert.notEqual(second.ticket, first.ticket) + assert.equal(rt.roomOfLocalInvite(first.ticket), undefined, 'the replaced link answers nothing') + assert.equal(rt.roomOfLocalInvite(second.ticket).id, 'war') + + // 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.notEqual(await rt.mintRoomInvite('war'), undefined, 'the room can hand out a fresh one') + + // Withdraw clears it for good, and deleting the room takes its link with it. + const third = await rt.mintRoomInvite('war') + assert.deepEqual(await rt.revokeRoomInvite('war'), { revoked: true }) + assert.equal(rt.localInviteOf('war'), undefined) + assert.equal(rt.roomOfLocalInvite(third.ticket), undefined, 'a withdrawn link answers nothing') + assert.deepEqual(await rt.revokeRoomInvite('war'), { revoked: false }) + const last = await rt.mintRoomInvite('war') + delete rt.state.rooms.war + assert.equal(rt.roomOfLocalInvite(last.ticket), undefined, 'deleting the room takes its link with it') +}) + +test('#852: a local link is not a face-room link, and a shared or face room has its own invitation', async (t) => { + // One field, two shapes: the face-room link the server owns, and the local one that names the Bot to knock on. + assert.deepEqual(parseRoomInviteLink('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv'), { kind: 'face', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }) + assert.deepEqual(parseRoomInviteLink('https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv/'), { kind: 'local', handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }) + assert.equal(parseRoomInviteLink('AbCdEfGhIjKlMnOpQrStUv'), undefined, 'a bare ticket is not a link') + assert.equal(parseRoomInviteLink('https://social.botawaken.com/r/@chief.a.home/short'), undefined, 'the ticket keeps its own length') + assert.equal(roomInviteUrl('@chief.a.home', 'short'), '', 'no link is built from a ticket that cannot work') + const { rt } = host(t, TEAM) + await rt.load() + const room = rt.state.rooms.war + 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/) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 865559f4d..a7c1e307d 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -148,6 +148,12 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi - 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. A leave envelope, hide, or grant revoke marks it left/hidden. `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. - 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) + +- 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). + ### Face-to-face owner send - Channel `send` on a face-to-face room (`origin: 'face-to-face'`) always `postRoomMessage`s, then shows a quiet local notice (`postToRoom` `quiet`+`notice`). It does not call `sessionController.prompt` unless a local Bot is @mentioned. Unsigned-in cloud UI uses `implicitRoomOwner` (the local human) so the line still fans out over `onFaceLine` / `FLYBOT-FACE-HUMAN` and the other FlyBot instance shows it; that path must not open a bot round (#124). Covered by `core/test/remote-room.test.js` and `core/test/rounds.test.js`. diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index fce30cbfb..b5ba5f51a 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -33,9 +33,10 @@ // Inbound shared rooms (`origin: 'inbound-share'`) are the remote owner's projection // of a friend's room that invited their granted Bot. +import { randomBytes } from 'node:crypto' import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js' import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js' -import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js' +import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js' import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js' import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js' import { hasAnyGrant, mayShareRoom } from './reach.js' @@ -932,6 +933,66 @@ export function install(rt) { } } + // #852: the link invitation for a room that lives here. The room record holds the one live ticket; + // the link is not authority on its own — every knock and every accept is read back against this + // record, and the ticket stops answering the moment it is withdrawn, replaced, expired, or the room + // is gone. Ten minutes, like the face-room window. + const ROOM_INVITE_ORIGIN = 'https://social.botawaken.com' + + function liveLocalInvite(room, at = Date.now()) { + const invite = room?.localInvite + if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) return undefined + const expiresAt = Number(invite.expiresAt) + if (!Number.isFinite(expiresAt) || expiresAt <= at) return undefined + return { + ticket: invite.ticket, + handle: invite.handle, + expiresAt, + ...(typeof invite.consumedBy === 'string' && invite.consumedBy ? { consumedBy: invite.consumedBy } : {}), + } + } + + rt.localInviteOf = function localInviteOf(roomId) { + const live = liveLocalInvite(rt.roomOf(roomId)) + if (!live) return undefined + const origin = rt.roomInviteOrigin || ROOM_INVITE_ORIGIN + return { ...live, url: roomInviteUrl(live.handle, live.ticket, origin) } + } + + rt.roomOfLocalInvite = function roomOfLocalInvite(ticket) { + const wanted = String(ticket ?? '') + if (!wanted) return undefined + for (const room of Object.values(state.rooms || {})) { + if (room?.localInvite?.ticket === wanted) return room + } + return undefined + } + + rt.mintRoomInvite = async function mintRoomInvite(roomId) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + // 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') + 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') + // 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?.() + return rt.localInviteOf(room.id) + } + + rt.revokeRoomInvite = async function revokeRoomInvite(roomId) { + const room = rt.roomOf(roomId) + if (room === undefined) throw invalid('Unknown group chat') + if (!room.localInvite) return { revoked: false } + delete room.localInvite + await rt.save?.() + return { revoked: 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 diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index 4395354cd..d5c1eba39 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -37,6 +37,29 @@ export function encodeRoomShare(payload = {}) { return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}` } +// #852: a link invitation for a room that lives in FlyBot itself. No server knows this ticket: the +// inviter's host mints it and the room record keeps it. The link carries the inviter Bot's handle as +// its own path segment, because that handle is the only address the invitee can knock on — a ticket +// inside the 64-character shape cannot hold one. Ten minutes, like the face-room window, and never +// authority on its own: the room record decides. +export const LOCAL_INVITE_TTL_MS = 10 * 60 * 1000 +const INVITE_LINK = /^(?:https?:)?\/\/[^\s/]+\/r\/(?:(@[A-Za-z0-9._-]{1,120})\/)?([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i + +// Two shapes, one field: the face-room link the server owns, and the local one that names the Bot to +// knock on. Nothing here decides which is legitimate — the caller does, by kind. +export function parseRoomInviteLink(value) { + const match = String(value ?? '').trim().match(INVITE_LINK) + if (!match) return undefined + return match[1] ? { kind: 'local', handle: match[1], ticket: match[2] } : { kind: 'face', ticket: match[2] } +} + +export function roomInviteUrl(handle, ticket, origin = 'https://social.botawaken.com') { + const who = String(handle ?? '').trim() + const clean = String(ticket ?? '').trim() + if (!/^@[A-Za-z0-9._-]{1,120}$/.test(who) || !/^[A-Za-z0-9_-]{16,64}$/.test(clean)) return '' + return `${String(origin || '').replace(/\/+$/, '')}/r/${who}/${clean}` +} + export function parseRoomShare(text) { const raw = String(text ?? '') const at = raw.indexOf(ROOM_SHARE_MARK) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index da6c3613c..6fb0b572f 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -21,7 +21,7 @@ import { } from '../src/host/room-membership.js' import { install as installReach, reach } from '../src/host/reach.js' import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js' -import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' +import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../src/shared/room-share.js' import { fenceUntrusted } from '../src/host/delivery.js' import { mentionRows } from '../src/client/at-menu.js' import { indexRoster } from '../src/client/sources.js' @@ -1112,3 +1112,60 @@ test('#825: an answer held for approval shows up as one line in the room, not as await rt.fanoutRoomLine(room, { seq: 10, who: 'Writer B', botId: 'spec-b', authorId: 'spec-b', authorKind: 'bot', text: 'held answer' }) assert.ok(room.log.some(line => line.kind === 'notice' && /held for approval/.test(line.text || '')), 'the room says so once') }) + +test('#852: a room link is one live ticket — ten minutes, one per room, replaced, withdrawn, gone with the room', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + await rt.authenticate(alice.sessionToken) + await grantToFriend(rt, 'chief-a') + rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } } + assert.equal(rt.localInviteOf('war'), undefined, 'a room starts without a link') + + const first = await rt.mintRoomInvite('war') + assert.match(first.ticket, /^[A-Za-z0-9_-]{22}$/, '22 random characters, the shape the invite field accepts') + // The link answers "who do I knock on" by naming the inviter Bot in its own path segment. + assert.deepEqual(parseRoomInviteLink(first.url), { kind: 'local', handle: '@chief.a.home', ticket: first.ticket }) + assert.equal(roomInviteUrl('@chief.a.home', first.ticket, 'https://social.botawaken.com/'), first.url, 'one builder, no trailing slash') + const window = first.expiresAt - Date.now() + assert.ok(window > 9 * 60 * 1000 && window <= 10 * 60 * 1000, `ten minutes, the face-room window (got ${window} ms)`) + assert.equal(rt.roomOfLocalInvite(first.ticket).id, 'war') + + // One live ticket per room: minting again replaces the last one, and the old link stops answering. + const second = await rt.mintRoomInvite('war') + assert.notEqual(second.ticket, first.ticket) + assert.equal(rt.roomOfLocalInvite(first.ticket), undefined, 'the replaced link answers nothing') + assert.equal(rt.roomOfLocalInvite(second.ticket).id, 'war') + + // 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.notEqual(await rt.mintRoomInvite('war'), undefined, 'the room can hand out a fresh one') + + // Withdraw clears it for good, and deleting the room takes its link with it. + const third = await rt.mintRoomInvite('war') + assert.deepEqual(await rt.revokeRoomInvite('war'), { revoked: true }) + assert.equal(rt.localInviteOf('war'), undefined) + assert.equal(rt.roomOfLocalInvite(third.ticket), undefined, 'a withdrawn link answers nothing') + assert.deepEqual(await rt.revokeRoomInvite('war'), { revoked: false }) + const last = await rt.mintRoomInvite('war') + delete rt.state.rooms.war + assert.equal(rt.roomOfLocalInvite(last.ticket), undefined, 'deleting the room takes its link with it') +}) + +test('#852: a local link is not a face-room link, and a shared or face room has its own invitation', async (t) => { + // One field, two shapes: the face-room link the server owns, and the local one that names the Bot to knock on. + assert.deepEqual(parseRoomInviteLink('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv'), { kind: 'face', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }) + assert.deepEqual(parseRoomInviteLink('https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv/'), { kind: 'local', handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv' }) + assert.equal(parseRoomInviteLink('AbCdEfGhIjKlMnOpQrStUv'), undefined, 'a bare ticket is not a link') + assert.equal(parseRoomInviteLink('https://social.botawaken.com/r/@chief.a.home/short'), undefined, 'the ticket keeps its own length') + assert.equal(roomInviteUrl('@chief.a.home', 'short'), '', 'no link is built from a ticket that cannot work') + const { rt } = host(t, TEAM) + await rt.load() + const room = rt.state.rooms.war + 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/) +})