feat(meetings): guest join by link — external people can join without a Connect account (batch68)

- Server: POST /api/meetings/guest-token issues an unauthenticated LiveKit token
  for a room that is currently live or a valid scheduled meeting (throwaway guest
  identity). Mesh signaling already tolerates anonymous peers.
- Client: opening /home?meet=CODE while signed out runs a lightweight GUEST mode —
  name prompt, then straight into the meeting with the full meeting client and the
  chat/sidebar chrome hidden. Signed-in users who open the link auto-join.
- sfuConnect uses the guest token for guests. Guests get a friendly "left the
  meeting / rejoin" screen (no chat to fall back to).
- Meeting "Add people" panel gains a "Copy invite link" (copyMeetingLink) that
  copies the guest link — foundation for the emailed invites in #4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 15:44:28 +05:30
parent 9b86def921
commit 466c0d5d70
2 changed files with 98 additions and 3 deletions
+17
View File
@@ -929,6 +929,23 @@ route('POST', '/api/meetings/token', async (req, res) => {
json(res, 200, { token, url: LIVEKIT_URL, identity: u.id, name: u.name || u.email });
});
// GUEST (no-login) LiveKit token — lets an external person invited by link join a meeting without a
// Connect account. Only minted for a room that is currently LIVE or a valid (not-ended) scheduled
// meeting, so a token can't be created for an arbitrary/expired code. Identity is a throwaway guest id.
route('POST', '/api/meetings/guest-token', async (req, res) => {
if (!LIVEKIT_ENABLED) return json(res, 501, { error: 'sfu not configured' });
const { room, name } = await readBody(req);
const rm = String(room || '').trim();
if (!/^\d{6}$/.test(rm)) return json(res, 400, { error: 'invalid room' });
const live = (() => { try { return require('./presence').meetingRooms.has(rm); } catch (_) { return false; } })();
const sched = (() => { try { const s = R.scheduledMeetings.byCode(rm); return !!(s && !s.ended_at); } catch (_) { return false; } })();
if (!live && !sched) return json(res, 404, { error: 'meeting not found or not active' });
const gid = 'guest-' + crypto.randomBytes(8).toString('hex');
const gname = String(name || 'Guest').slice(0, 60);
const token = livekitToken(gid, gname, rm, JSON.stringify({ guest: true }));
json(res, 200, { token, url: LIVEKIT_URL, identity: gid, name: gname });
});
// Decline an incoming 1:1 call: drops the caller, posts a "Call declined" line, clears the call.
route('POST', '/api/calls/decline', async (req, res) => {
const u = currentUser(req);