diff --git a/apps/web/src/app/actions/meetings.ts b/apps/web/src/app/actions/meetings.ts index 94b4040..37381ef 100644 --- a/apps/web/src/app/actions/meetings.ts +++ b/apps/web/src/app/actions/meetings.ts @@ -29,6 +29,9 @@ interface UpdatePayload { joinCode: string; hostName: string; inviteeEmails: string[]; + // Set when someone was removed from the meeting: the join code was rotated, so the + // code in this email replaces the one the recipient was originally sent. + codeChanged?: boolean; } interface RemovalPayload { @@ -170,6 +173,7 @@ function updateEmailHtml(opts: { joinCode: string; hostName: string; joinUrl: string; + codeChanged?: boolean; }): string { const timeChanged = new Date(opts.scheduledAt).getTime() !== new Date(opts.previousScheduledAt).getTime(); @@ -226,10 +230,15 @@ function updateEmailHtml(opts: {
-

Your join code (unchanged)

+

${opts.codeChanged ? 'Your new join code' : 'Your join code (unchanged)'}

${opts.joinCode}
+ ${ + opts.codeChanged + ? `

The guest list changed, so the previous code no longer works. Use this one instead.

` + : '' + }
@@ -333,6 +342,7 @@ export async function sendMeetingUpdate( joinCode: payload.joinCode, hostName: payload.hostName, joinUrl: `${appUrl}/join/${payload.joinCode}`, + ...(payload.codeChanged !== undefined && { codeChanged: payload.codeChanged }), }); try { diff --git a/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts b/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts index d4ed5dd..0cdaecb 100644 --- a/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts +++ b/apps/web/src/app/api/scheduled-sessions/[id]/route.test.ts @@ -105,17 +105,35 @@ function invitee(email: string, id: string) { function setupService(options: { existingInvitees?: { id: string; email: string; name: string | null; invite_token: string }[]; meeting?: Partial; + /** Simulate the host having already started the meeting under its join code. */ + liveSession?: boolean; + /** Make the join_code UPDATE fail, as a unique-constraint clash would. */ + rotateFails?: boolean; }) { const existingInvitees = options.existingInvitees ?? []; const meeting = { ...baseMeeting, ...options.meeting }; const mock = createServiceMock((chain) => { + // Probing whether a candidate join code is free — matched on the filter rather + // than the table, since both tables are checked with the same shape of query. + if (chain.op === 'select' && chain.filters.join_code !== undefined) { + const isLive = chain.table === 'sessions' && chain.filters.join_code === meeting.join_code; + return { data: isLive && options.liveSession === true ? { id: 'live-1' } : null, error: null }; + } if (chain.table === 'scheduled_sessions' && chain.op === 'select') { return { data: { ...meeting, scheduled_session_invitees: existingInvitees }, error: null, }; } + if ( + chain.table === 'scheduled_sessions' && + chain.op === 'update' && + (chain.payload as { join_code?: string }).join_code !== undefined && + options.rotateFails === true + ) { + return { data: null, error: { message: 'duplicate key value' } }; + } if (chain.table === 'scheduled_sessions' && chain.op === 'update') { return { data: { ...meeting, ...(chain.payload as object) }, error: null }; } @@ -236,6 +254,127 @@ describe('PATCH /api/scheduled-sessions/[id]', () => { expect(mockSendMeetingInvites).not.toHaveBeenCalled(); }); + describe('join code rotation on removal', () => { + it('rotates the join code so the removed invitee is actually locked out', async () => { + const mock = setupService({ + existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')], + }); + + const response = await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), { + params, + }); + const body = (await response.json()) as { data: { join_code: string } }; + + const rotate = mock.calls.find( + (c) => + c.table === 'scheduled_sessions' && + c.op === 'update' && + (c.payload as { join_code?: string }).join_code !== undefined + ); + const newCode = (rotate?.payload as { join_code: string }).join_code; + + expect(rotate).toBeDefined(); + expect(newCode).not.toBe('ABC123'); + expect(newCode).toMatch(/^[A-Z0-9]{6}$/); + expect(rotate?.filters).toMatchObject({ id: MEETING_ID, host_user_id: mockUser.id }); + + // The caller sees the new code, so the dashboard stops showing the dead one. + expect(body.data.join_code).toBe(newCode); + }); + + it('tells the invitees who remain what the new code is', async () => { + setupService({ + existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')], + }); + + await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), { params }); + + expect(mockSendMeetingUpdate).toHaveBeenCalledTimes(1); + const payload = mockSendMeetingUpdate.mock.calls[0]?.[0] as { + inviteeEmails: string[]; + joinCode: string; + codeChanged: boolean; + }; + + // Only the retained invitee, and the email must carry the rotated code — sending + // the old one would leave them holding a code that no longer works. + expect(payload.inviteeEmails).toEqual(['stay@example.com']); + expect(payload.codeChanged).toBe(true); + expect(payload.joinCode).not.toBe('ABC123'); + }); + + it('leaves the code alone when nobody was removed', async () => { + const mock = setupService({ existingInvitees: [invitee('a@example.com', 'i1')] }); + + await PATCH(patchRequest({ inviteeEmails: ['a@example.com', 'b@example.com'] }), { params }); + + const rotate = mock.calls.find( + (c) => + c.table === 'scheduled_sessions' && + c.op === 'update' && + (c.payload as { join_code?: string }).join_code !== undefined + ); + expect(rotate).toBeUndefined(); + + // A newly added invitee gets the existing code, which still works. + const invitePayload = mockSendMeetingInvites.mock.calls[0]?.[0] as { joinCode: string }; + expect(invitePayload.joinCode).toBe('ABC123'); + }); + + it('does not rotate once the meeting has started', async () => { + const mock = setupService({ + existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')], + liveSession: true, + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const response = await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), { + params, + }); + + // The live room holds its own copy of the code, so rotating the scheduled row + // would claim a lockout that did not happen. + const rotate = mock.calls.find( + (c) => + c.table === 'scheduled_sessions' && + c.op === 'update' && + (c.payload as { join_code?: string }).join_code !== undefined + ); + expect(rotate).toBeUndefined(); + expect(response.status).toBe(200); + expect(warn).toHaveBeenCalled(); + + // Nothing changed for the people staying, so they are not emailed. + expect(mockSendMeetingUpdate).not.toHaveBeenCalled(); + // The removal itself still went through. + expect(mockSendInviteeRemoval).toHaveBeenCalledTimes(1); + }); + + it('still applies the removal when the rotation write fails', async () => { + const mock = setupService({ + existingInvitees: [invitee('stay@example.com', 'i1'), invitee('drop@example.com', 'i2')], + rotateFails: true, + }); + + const response = await PATCH(patchRequest({ inviteeEmails: ['stay@example.com'] }), { + params, + }); + + expect(response.status).toBe(200); + + const del = mock.calls.find( + (c) => c.table === 'scheduled_session_invitees' && c.op === 'delete' + ); + expect(del?.filters.id).toEqual(['i2']); + + // The code did not change, so retained invitees must not be told that it did. + const updateCall = mockSendMeetingUpdate.mock.calls[0]?.[0] as + | { codeChanged: boolean } + | undefined; + expect(updateCall?.codeChanged ?? false).toBe(false); + }); + }); + it('removes every invitee when given an empty list', async () => { const mock = setupService({ existingInvitees: [invitee('a@example.com', 'i1')] }); diff --git a/apps/web/src/app/api/scheduled-sessions/[id]/route.ts b/apps/web/src/app/api/scheduled-sessions/[id]/route.ts index 122ce98..ac1555c 100644 --- a/apps/web/src/app/api/scheduled-sessions/[id]/route.ts +++ b/apps/web/src/app/api/scheduled-sessions/[id]/route.ts @@ -9,6 +9,7 @@ import { sendMeetingUpdate, sendInviteeRemoval, } from '@/app/actions/meetings'; +import { getUniqueJoinCode, liveSessionExistsForCode } from '@/lib/join-code'; import { randomBytes } from 'crypto'; // GET /api/scheduled-sessions/[id] @@ -140,6 +141,44 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id } } + // Dropping someone from the list only revokes their access if the code they were + // already emailed stops working. The code is shared by the whole guest list, so + // rotating it means everyone still invited has to be told the new one — that is + // what `codeRotated` forces further down. + let joinCode = updated.join_code as string; + let codeRotated = false; + + if (removed.length > 0) { + if (await liveSessionExistsForCode(svc, joinCode)) { + // The host already started this meeting, and the live room holds its own copy + // of the code. Rotating the scheduled row would not lock anyone out of it — + // /api/sessions/{id}/regenerate-code is the lever for a running session. + console.warn(`Not rotating join code for scheduled session ${id}: already started`); + } else { + try { + const nextCode = await getUniqueJoinCode(svc); + + const { error: rotateErr } = await (svc as any) + .from('scheduled_sessions') + .update({ join_code: nextCode, updated_at: new Date().toISOString() }) + .eq('id', id) + .eq('host_user_id', user.id); + + if (rotateErr) { + // Keep the old code rather than failing the whole edit — the removal itself + // already succeeded, and a stale code is better than a half-applied PATCH. + console.error('Join code rotation error:', rotateErr); + } else { + joinCode = nextCode; + codeRotated = true; + updated = { ...updated, join_code: nextCode }; + } + } catch (err) { + console.error('Join code rotation error:', err); + } + } + } + // Host display name for the emails below const { data: profile } = await (svc as any) .from('profiles') @@ -148,7 +187,6 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id .maybeSingle(); const hostName = (profile?.display_name as string | undefined) ?? user.email ?? 'Someone'; - const joinCode = updated.join_code as string; const description = (updated.description as string | null) ?? undefined; if (added.length > 0) { @@ -173,11 +211,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id }); } - // Invitees who were already on the list only need a heads-up if something moved. + // Invitees who were already on the list only need a heads-up if something moved — + // or if the code they were given no longer opens the meeting. const removedIds = new Set(removed.map((i) => i.id)); const retained = currentInvitees.filter((i) => !removedIds.has(i.id)); - if (retained.length > 0 && detailsChanged(existing, updated)) { + if (retained.length > 0 && (codeRotated || detailsChanged(existing, updated))) { await sendMeetingUpdate({ title: updated.title as string, description, @@ -187,6 +226,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id joinCode, hostName, inviteeEmails: retained.map((i) => i.email), + codeChanged: codeRotated, }); } diff --git a/apps/web/src/app/api/scheduled-sessions/route.ts b/apps/web/src/app/api/scheduled-sessions/route.ts index af83eb3..ac5f8c5 100644 --- a/apps/web/src/app/api/scheduled-sessions/route.ts +++ b/apps/web/src/app/api/scheduled-sessions/route.ts @@ -4,37 +4,9 @@ import { serviceClient } from '@/lib/supabase/service'; import { successResponse, errorResponse, handleApiError } from '@/lib/api'; import { scheduleMeetingSchema } from '@/lib/validations'; import { sendMeetingInvites } from '@/app/actions/meetings'; +import { getUniqueJoinCode } from '@/lib/join-code'; import { randomBytes } from 'crypto'; -function generateJoinCode(): string { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - let code = ''; - for (let i = 0; i < 6; i++) { - code += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return code; -} - -async function getUniqueJoinCode(svc: ReturnType): Promise { - for (let i = 0; i < 10; i++) { - const code = generateJoinCode(); - - const { data: inSessions } = await (svc as any) - .from('sessions') - .select('id') - .eq('join_code', code) - .maybeSingle(); - - const { data: inScheduled } = await (svc as any) - .from('scheduled_sessions') - .select('id') - .eq('join_code', code) - .maybeSingle(); - if (!inSessions && !inScheduled) return code; - } - throw new Error('Failed to generate unique join code'); -} - // POST /api/scheduled-sessions — create a scheduled meeting + send invites export async function POST(request: Request) { try { diff --git a/apps/web/src/lib/join-code.ts b/apps/web/src/lib/join-code.ts new file mode 100644 index 0000000..c157133 --- /dev/null +++ b/apps/web/src/lib/join-code.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ +import type { serviceClient } from '@/lib/supabase/service'; + +type ServiceClient = ReturnType; + +// Uppercase only — create_session() upper-cases whatever it is handed, so generating +// anything else here would desync the scheduled code from the live room's code. +const CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + +export function generateJoinCode(): string { + let code = ''; + for (let i = 0; i < 6; i++) { + code += CODE_CHARS.charAt(Math.floor(Math.random() * CODE_CHARS.length)); + } + return code; +} + +/** + * Finds a code that is not already taken by a live session or another scheduled one. + * Both tables have to be checked: a scheduled meeting's code is handed straight to + * create_session() when the host starts it, and that insert fails on a collision. + */ +export async function getUniqueJoinCode(svc: ServiceClient): Promise { + for (let i = 0; i < 10; i++) { + const code = generateJoinCode(); + + const { data: inSessions } = await (svc as any) + .from('sessions') + .select('id') + .eq('join_code', code) + .maybeSingle(); + + const { data: inScheduled } = await (svc as any) + .from('scheduled_sessions') + .select('id') + .eq('join_code', code) + .maybeSingle(); + + if (!inSessions && !inScheduled) return code; + } + throw new Error('Failed to generate unique join code'); +} + +/** + * Has the host already started this meeting? Once a live session exists under the + * scheduled code, rotating the scheduled row no longer revokes anything — the live + * room keeps its own copy of the code. Callers use this to avoid pretending a + * removal revoked access when it did not. + */ +export async function liveSessionExistsForCode( + svc: ServiceClient, + joinCode: string +): Promise { + const { data } = await (svc as any) + .from('sessions') + .select('id') + .eq('join_code', joinCode) + .maybeSingle(); + + return Boolean(data); +} diff --git a/docs/API.md b/docs/API.md index ace2ff0..83fb7f1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1020,8 +1020,9 @@ Who gets email depends on what changed: |---|---| | Address added to `inviteeEmails` | Invitation, with the join code | | Address dropped from `inviteeEmails` | Invitation withdrawn | +| Anyone dropped (so the code rotated) | Update notice to everyone still invited, carrying the new code | | `title`, `description`, `scheduled_at`, or `duration_minutes` changed | Update notice to everyone still invited | -| Only the guest list changed | Nothing to retained invitees | +| Invitees only added, nothing else changed | Nothing to existing invitees | Editing a cancelled meeting returns `400`. @@ -1065,14 +1066,33 @@ the only scheduled-session endpoints that do not require a logged-in user — th An unrecognised token returns `404`. Both endpoints run through the service-role client, which is why the table needs no anon-facing RLS policy. -#### Known Limitation: Removal Does Not Revoke Access +#### Join Code Rotation on Removal -A scheduled meeting has a single shared `join_code` that is emailed to every invitee, and -removing an invitee does **not** rotate it. A removed invitee who kept their original -invitation email can still join using that code, and there is currently no endpoint to -re-issue a scheduled meeting's code. (`/api/sessions/{id}/regenerate-code` applies to live -sessions, not scheduled ones.) To genuinely lock someone out, cancel the meeting and -create a new one. +A scheduled meeting has a single shared `join_code` that is emailed to every invitee, so +deleting an invitee row on its own would revoke nothing — the removed person still holds a +working code. Whenever a `PATCH` drops at least one invitee, the meeting's `join_code` is +therefore **rotated**, and the invitees who remain are emailed the replacement. + +``` +PATCH { inviteeEmails: [...] } removing someone + → new unique join_code written to scheduled_sessions + → removed invitees : "Invitation withdrawn" + → retained invitees : "Updated", carrying the new code + → response body : the rotated join_code +``` + +Because the code is shared, there is no way to revoke one person without reissuing it to +everybody; that is inherent to a single shared code, not an implementation shortcut. +Adding an invitee never rotates the code. + +**Once the meeting has started, rotation is skipped.** Starting a meeting creates a row in +`sessions` carrying its own copy of the code, and rewriting the `scheduled_sessions` row +would not evict anyone from the live room. The API detects this and leaves the code alone +rather than reporting a lockout that did not happen — use +`POST /api/sessions/{id}/regenerate-code` to rotate a running session's code instead. + +If the rotation write itself fails, the removal still stands: the invitee is deleted, the +old code is kept, and retained invitees are not told the code changed. ### Profiles API