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/876-room-invite-endpoints.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A room's invite link now has the endpoints the Members card works through: read the live link, mint one, withdraw it, and knock at a link someone else handed you — first for the preview, then again with `accept: true` when Join is clicked ([#876](https://github.com/ThinkFlowLab/FlyBot/issues/876)). The knock leaves from one of your own Bots and names the Bot to knock on from the link, never from the request body; a link into a room on another FlyBot, a bare ticket, and a ticket outside the 16–64 character shape are each refused in one sentence and send nothing.
84 changes: 84 additions & 0 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,87 @@ function RoomAvatar({ room: room2, roster, size = 36 }) {
);
}

// src/client/room-invite-preview.js
var INVITE_PREVIEW_NAME_MAX = 60;
function stripInviteMarkers(text) {
let out = String(text ?? "");
for (let previous; previous !== out; ) {
previous = out;
out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, "");
}
return out;
}
function cleanInviteName(value, fallback = "") {
return stripInviteMarkers(value).replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ").replace(/[[\]"<>`@]/g, "").replace(/\s+/g, " ").trim().slice(0, INVITE_PREVIEW_NAME_MAX) || fallback;
}
function inviteMemberCount(value) {
if (typeof value === "number") return Number.isFinite(value) ? value : NaN;
if (typeof value !== "string") return NaN;
const text = value.trim();
if (!text) return NaN;
const count = Number(text);
return Number.isFinite(count) ? count : NaN;
}
function invitePreviewOf(seen) {
if (!seen || typeof seen !== "object" || seen.unsupported === true) return null;
const name = cleanInviteName(seen.name);
const lead = cleanInviteName(seen.lead);
const members = inviteMemberCount(seen.members);
const maxMembers = inviteMemberCount(seen.maxMembers);
const people2 = Number.isFinite(members) && Number.isFinite(maxMembers) ? `${members} / ${maxMembers} in the room` : Number.isFinite(members) ? `${members} in the room` : "";
const expiresAt = typeof seen.expiresAt === "string" ? seen.expiresAt : "";
if (!name && !lead && !people2 && !expiresAt) return null;
return { name, lead, people: people2, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 };
}
function inviteCountdown(expiresAt, offsetMs = 0) {
const at = Date.parse(expiresAt) - (Number(offsetMs) || 0);
if (!Number.isFinite(at)) return "Open while the room is open";
const left = Math.max(0, at - Date.now());
if (left <= 0) return "This link has expired";
const total = Math.max(1, Math.ceil(left / 1e3));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`;
}
function RoomInvitePreview({ h: h42, preview, kind = "face-link-preview", busy = false, joinLabel = "Join", clearLabel = "Clear", showClear = true, onJoin, onClear, now } = {}) {
if (!preview || typeof h42 !== "function") return null;
const at = `${kind}`;
const child = (name) => `${at}-${name}`;
const countdown = now === void 0 ? inviteCountdown(preview.expiresAt, preview.clockOffsetMs) : inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now);
return h42(
"div",
{ className: "fs-preview", "data-flybot": at, role: "status" },
h42("div", { className: "fs-preview-name", "data-flybot": child("name") }, preview.name || "Face-to-face room"),
preview.lead ? h42("div", { className: "fs-preview-meta", "data-flybot": child("lead") }, `Lead: ${preview.lead}`) : null,
preview.people ? h42("div", { className: "fs-preview-meta", "data-flybot": child("members") }, preview.people) : null,
preview.expiresAt ? h42("div", { className: "fs-preview-meta", "data-flybot": child("expiry") }, countdown) : null,
onJoin || onClear ? h42(
"div",
{ className: "fs-preview-foot", "data-flybot": child("foot") },
showClear && onClear ? h42("button", {
type: "button",
className: "fs-btn fs-btn-sm",
"data-flybot": child("clear"),
disabled: !!busy,
onClick: onClear
}, clearLabel) : null,
onJoin ? h42("button", {
type: "button",
className: "fs-btn fs-btn-sm fs-btn-primary",
"data-flybot": child("join"),
disabled: !!busy,
onClick: onJoin
}, joinLabel) : null
) : null
);
}
function inviteCountdownAt(expiresAt, offsetMs, now) {
const at = Date.parse(expiresAt) - (Number(offsetMs) || 0);
if (!Number.isFinite(at)) return "Open while the room is open";
const left = Math.max(0, at - Number(now || 0));
if (left <= 0) return "This link has expired";
const total = Math.max(1, Math.ceil(left / 1e3));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")} left to join`;
}

// src/client/approval-claims.js
var claims = /* @__PURE__ */ new Map();
var claimListeners = /* @__PURE__ */ new Set();
Expand Down Expand Up @@ -2183,6 +2264,9 @@ function registry2() {
globalThis.__flybot__.BotMark ??= BotMark;
globalThis.__flybot__.BotAvatar ??= BotAvatar;
globalThis.__flybot__.lookOf ??= lookOf;
globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview;
globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf;
globalThis.__flybot__.inviteCountdown ??= inviteCountdown;
globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner };
return slots;
}
Expand Down
6 changes: 6 additions & 0 deletions core/src/client/flybot-slots.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { call as callHost } from './api.js'
import { friendBotOwner, friendBotTitle, isListedRemoteChat } from '../shared/remote-label.js'
import { registry as baseRegistry } from './flybot-registry.js'
import { BotAvatar, BotMark, lookOf } from './mark.js'
import { RoomInvitePreview, inviteCountdown, invitePreviewOf } from './room-invite-preview.js'
import { t } from './i18n.js'
import { chatOpened, claimApprovals, claimedIds, onClaims } from './approval-claims.js'
import { isApprovalWaiting, WAITING_STATUSES } from '../shared/approval-waiting.js'
Expand All @@ -54,6 +55,11 @@ export function registry() {
globalThis.__flybot__.BotMark ??= BotMark
globalThis.__flybot__.BotAvatar ??= BotAvatar
globalThis.__flybot__.lookOf ??= lookOf
// One room-invite preview card for every surface that shows an invite (#876): the face-room paste
// flow and the member-invite dialog render this, so the markup is not written twice.
globalThis.__flybot__.RoomInvitePreview ??= RoomInvitePreview
globalThis.__flybot__.invitePreviewOf ??= invitePreviewOf
globalThis.__flybot__.inviteCountdown ??= inviteCountdown
// The same for a friend's Bot's one name and its owner (#498), so the social plugin says it as the sidebar does.
globalThis.__flybot__.names ??= { friendBotTitle, friendBotOwner }
return slots
Expand Down
111 changes: 111 additions & 0 deletions core/src/client/room-invite-preview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// One room-invite preview card, drawn wherever an invite is shown (#876). The face-room paste flow
// and the member-invite dialog both render this component, so the markup exists once: a change here
// is visible in both, and neither surface keeps a copy.
//
// It is a plain function, not a hook component: the caller passes its own `createElement` as `h`,
// which is what lets the core bundle and a classic-script plugin (which gets `h` from `require`)
// draw the same card. Everything the card says comes from a server reply, so the strings are
// cleaned here, at the last place before a person reads them — the same rules the server applies to
// a room name (`cleanFaceName`): one line, no controls, brackets, markup or @, capped.
export const INVITE_PREVIEW_NAME_MAX = 60

// Untrusted fence markers are stripped to a fixed point: one pass can remove a match and assemble a
// new one, so a nested `<untrusted-<untrusted-filemessage>` would otherwise leave a complete
// `<untrusted-message>` in the card a person reads (#822).
export function stripInviteMarkers(text) {
let out = String(text ?? '')
for (let previous; previous !== out;) {
previous = out
out = out.replace(/<\/?\s*untrusted-(?:file|message)/gi, '')
}
return out
}

export function cleanInviteName(value, fallback = '') {
return stripInviteMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, INVITE_PREVIEW_NAME_MAX) || fallback
}

// A count is only what reads as one (#823): `Number('')` is 0, which would draw an unknown room as
// empty, so an empty or non-numeric string is "not sent", like null.
export function inviteMemberCount(value) {
if (typeof value === 'number') return Number.isFinite(value) ? value : NaN
if (typeof value !== 'string') return NaN
const text = value.trim()
if (!text) return NaN
const count = Number(text)
return Number.isFinite(count) ? count : NaN
}

// What the card can honestly say, and nothing else: a reply with no field worth showing is no card
// at all, so the caller keeps its own path (a paste that previews nothing still joins as before).
export function invitePreviewOf(seen) {
if (!seen || typeof seen !== 'object' || seen.unsupported === true) return null
const name = cleanInviteName(seen.name)
const lead = cleanInviteName(seen.lead)
const members = inviteMemberCount(seen.members)
const maxMembers = inviteMemberCount(seen.maxMembers)
const people = Number.isFinite(members) && Number.isFinite(maxMembers)
? `${members} / ${maxMembers} in the room`
: Number.isFinite(members) ? `${members} in the room` : ''
const expiresAt = typeof seen.expiresAt === 'string' ? seen.expiresAt : ''
if (!name && !lead && !people && !expiresAt) return null
return { name, lead, people, expiresAt, clockOffsetMs: Number(seen.clockOffsetMs) || 0 }
}

// Every home counts down to the server's one deadline (#458): `offsetMs` is how far the server's
// clock runs ahead of this one (0 unless the skew is real). m:ss, so the line visibly ticks.
export function inviteCountdown(expiresAt, offsetMs = 0) {
const at = Date.parse(expiresAt) - (Number(offsetMs) || 0)
if (!Number.isFinite(at)) return 'Open while the room is open'
const left = Math.max(0, at - Date.now())
if (left <= 0) return 'This link has expired'
const total = Math.max(1, Math.ceil(left / 1000))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join`
}

// `kind` prefixes every `data-flybot` name, so each surface keeps the hook its tests and its CSS
// already use while sharing this one tree of elements.
export function RoomInvitePreview({ h, preview, kind = 'face-link-preview', busy = false, joinLabel = 'Join', clearLabel = 'Clear', showClear = true, onJoin, onClear, now } = {}) {
if (!preview || typeof h !== 'function') return null
const at = `${kind}`
const child = (name) => `${at}-${name}`
const countdown = now === undefined
? inviteCountdown(preview.expiresAt, preview.clockOffsetMs)
: inviteCountdownAt(preview.expiresAt, preview.clockOffsetMs, now)
return h('div', { className: 'fs-preview', 'data-flybot': at, role: 'status' },
h('div', { className: 'fs-preview-name', 'data-flybot': child('name') }, preview.name || 'Face-to-face room'),
preview.lead ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('lead') }, `Lead: ${preview.lead}`) : null,
preview.people ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('members') }, preview.people) : null,
preview.expiresAt ? h('div', { className: 'fs-preview-meta', 'data-flybot': child('expiry') }, countdown) : null,
onJoin || onClear
? h('div', { className: 'fs-preview-foot', 'data-flybot': child('foot') },
showClear && onClear
? h('button', {
type: 'button', className: 'fs-btn fs-btn-sm', 'data-flybot': child('clear'),
disabled: !!busy, onClick: onClear,
}, clearLabel)
: null,
onJoin
? h('button', {
type: 'button', className: 'fs-btn fs-btn-sm fs-btn-primary', 'data-flybot': child('join'),
disabled: !!busy, onClick: onJoin,
}, joinLabel)
: null)
: null)
}

// The same text as `inviteCountdown`, measured against a clock the caller already has — a render is
// then reproducible in a test without freezing the machine's clock.
export function inviteCountdownAt(expiresAt, offsetMs, now) {
const at = Date.parse(expiresAt) - (Number(offsetMs) || 0)
if (!Number.isFinite(at)) return 'Open while the room is open'
const left = Math.max(0, at - Number(now || 0))
if (left <= 0) return 'This link has expired'
const total = Math.max(1, Math.ceil(left / 1000))
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')} left to join`
}
21 changes: 20 additions & 1 deletion core/src/host/authz.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ const BOT_TARGETED = new Set([
])

// Endpoints whose body names a room.
const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post'])
const ROOM_TARGETED = new Set(['update-room', 'delete-room', 'room-progress', 'create-room', 'room-history', 'room-post',
// #876: the room's own link. Reading it is a member's read; minting and withdrawing hand out
// or kill a way in, so they follow the room's admin rule.
'room-invite', 'room-invite-mint', 'room-invite-revoke'])

// Endpoints whose body names a Session.
const SESSION_TARGETED = new Set(['earlier', 'send', 'answer-question', 'dismiss-question', 'approval-answer', 'exchange'])
Expand Down Expand Up @@ -334,6 +337,19 @@ export function install(rt) {
const id = body?.id ?? body?.roomId
return roomMemberOf(actor, id) || roomOwnedBy(actor, id) ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite') {
const id = body?.id ?? body?.roomId
const room = state.rooms?.[id]
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
return roomOwnedBy(actor, id) || roomMemberOf(actor, id) || me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You are not a member of that room')
}
if (ep === 'room-invite-mint' || ep === 'room-invite-revoke') {
const id = body?.id ?? body?.roomId
if (roomOwnedBy(actor, id)) return ALLOW
const room = state.rooms?.[id]
const me = currentParticipants(room).find(p => p.kind === 'human' && p.id === actor.memberId)
return me?.role === 'admin' ? ALLOW : deny('bot/forbidden', 'You do not own that room')
}
if (ep === 'delete-room') {
const id = body?.id ?? body?.roomId
if (roomOwnedBy(actor, id)) return ALLOW
Expand Down Expand Up @@ -370,6 +386,9 @@ export function install(rt) {
// Sharing a member Bot's brief is the Bot owner's decision, checked on the record
// in room-membership (#53); the endpoint itself only needs a signed-in actor.
if (ep === 'share-brief') return ALLOW
// #876: knocking at another FlyBot's room link is not authority over anything here. The knock
// carries a ticket that the inviter's record decides on, and the sender is the actor's own Bot.
if (ep === 'room-invite-ask') return ALLOW
// Task transitions are requester-checked on the record in tasks.js (#54) — the
// endpoint only needs a signed-in actor, like held-send decisions. Create is
// grant-aware there (owner, matching grant, or the named room). Explicit set:
Expand Down
20 changes: 20 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,26 @@ export function install(rt) {
}, actor)
return ok(line)
}
// #876: the room's own link. Reading it is what the Members card shows (copy and the
// countdown); minting replaces the live ticket, withdrawing stops answering at once.
case 'room-invite': {
await rt.load()
const invite = rt.localInviteOf(String(body.roomId ?? body.id ?? ''))
return ok({ invite: invite ?? null })
}
case 'room-invite-mint': {
await rt.load()
return ok({ invite: await rt.mintRoomInvite(String(body.roomId ?? body.id ?? '')) })
}
case 'room-invite-revoke': {
await rt.load()
return ok(await rt.revokeRoomInvite(String(body.roomId ?? body.id ?? '')))
}
// The invitee's side: a knock at the link (and, on Join, the accepting knock).
case 'room-invite-ask': {
await rt.load()
return ok(await rt.askLocalInvite(String(body.link ?? body.url ?? ''), { accept: body.accept === true }, actor))
}
case 'folders':
case 'folder-pick':
case 'folder-list':
Expand Down
36 changes: 35 additions & 1 deletion core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
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, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomInviteLink, 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'
Expand Down Expand Up @@ -1012,6 +1012,40 @@ export function install(rt) {
return { revoked: true }
}

// #876: the invitee's side of a local link. The link names the Bot to knock on, and the knock
// carries the ticket and nothing else; the accepting click is the same knock with `accept: true`.
// Nothing here decides the seat: the inviter's room record answers, and its offer comes back as
// `invite` through applyInboundRoomShare (rt.onLocalInviteOffer). The knock goes out from a Bot of
// the actor's whose home has a Bot Social identity — the link cannot name the sender, so the
// transport does, exactly as on the answering side.
function localSpeaker(actor) {
const usable = Object.values(state.bots ?? {}).filter(bot => bot && bot.remote === undefined
&& typeof bot?.fields?.social?.member?.handle === 'string'
&& bot.fields.social.member.handle.startsWith('@'))
const mine = actor?.memberId ? usable.filter(bot => bot.ownerId === actor.memberId) : usable
const list = mine.length > 0 ? mine : usable
const chief = actor?.memberId ? state.members?.[actor.memberId]?.designatedChiefId : undefined
return (chief !== undefined ? list.find(bot => bot.id === chief) : undefined)?.id ?? list[0]?.id
}

rt.askLocalInvite = async function askLocalInvite(link, opts = {}, actor) {
const parsed = parseRoomInviteLink(typeof link === 'string' ? link : '')
if (!parsed || parsed.kind !== 'local') throw invalid('That is not a room link on another FlyBot')
if (!rt.flybotSend) throw invalid('Bot Social is not connected on this machine yet')
const from = localSpeaker(actor)
if (from === undefined) throw invalid('This machine has no Bot Social identity yet')
const accept = opts?.accept === true
const sent = await rt.flybotSend({
from,
to: parsed.handle,
text: encodeRoomShare({ op: 'ask', ticket: parsed.ticket, ...(accept ? { accept: true } : {}) }),
role: 'request',
via: 'room-share',
meta: { share: 'ask', ticket: parsed.ticket },
})
return { sent: sent?.action ?? 'sent', handle: parsed.handle, ticket: parsed.ticket, accept }
}

// #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
Expand Down
Loading
Loading