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..ef86a30 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,38 @@ 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 +257,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 e59063f..d2e4dc7 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -259,6 +259,105 @@ CREATE POLICY "Participants can view media sessions"
);
```
+#### scheduled_sessions
+
+A meeting booked for a future time. The `join_code` is reserved at creation and checked for
+uniqueness against both `sessions` and `scheduled_sessions`. When the host starts the
+meeting, the dashboard posts that same code to `POST /api/sessions`, which forwards it to
+`create_session(p_join_code => ...)` — so the code in the invitation email is the code that
+opens the live room.
+
+`session_id` is currently vestigial: no code path writes it, so it stays NULL even after
+the meeting has been started. The link back to the live room is the shared `join_code`,
+not this column.
+
+```sql
+CREATE TABLE public.scheduled_sessions (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ host_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
+ title TEXT NOT NULL,
+ description TEXT,
+ scheduled_at TIMESTAMPTZ NOT NULL,
+ duration_minutes INTEGER NOT NULL DEFAULT 60,
+ join_code TEXT NOT NULL UNIQUE,
+ session_id UUID REFERENCES public.sessions(id),
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'cancelled', 'completed')),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_scheduled_sessions_host ON public.scheduled_sessions (host_user_id);
+CREATE INDEX idx_scheduled_sessions_scheduled_at ON public.scheduled_sessions (scheduled_at);
+CREATE INDEX idx_scheduled_sessions_status ON public.scheduled_sessions (status);
+
+ALTER TABLE public.scheduled_sessions ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "Users manage own scheduled sessions"
+ ON public.scheduled_sessions FOR ALL
+ TO authenticated
+ USING (host_user_id = auth.uid())
+ WITH CHECK (host_user_id = auth.uid());
+```
+
+Cancellation is soft — the row is retained with `status = 'cancelled'` rather than deleted.
+
+#### scheduled_session_invitees
+
+One row per invited email address. `invite_token` is a **bearer credential**: it is the
+whole of the authentication on the RSVP link, so it must never reach a client other than
+the invitee it belongs to.
+
+```sql
+CREATE TABLE public.scheduled_session_invitees (
+ id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
+ scheduled_session_id UUID NOT NULL REFERENCES public.scheduled_sessions(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ name TEXT,
+ rsvp_status TEXT NOT NULL DEFAULT 'pending' CHECK (rsvp_status IN ('pending', 'accepted', 'declined')),
+ invite_token TEXT NOT NULL UNIQUE DEFAULT encode(gen_random_bytes(24), 'hex'),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (scheduled_session_id, email)
+);
+
+CREATE INDEX idx_invitees_scheduled_session ON public.scheduled_session_invitees (scheduled_session_id);
+CREATE INDEX idx_invitees_token ON public.scheduled_session_invitees (invite_token);
+
+ALTER TABLE public.scheduled_session_invitees ENABLE ROW LEVEL SECURITY;
+
+-- Hosts reach the invitees of their own meetings; nobody else reaches this table
+-- with the anon key at all.
+CREATE POLICY "Host manages invitees"
+ ON public.scheduled_session_invitees FOR ALL
+ TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.scheduled_sessions ss
+ WHERE ss.id = scheduled_session_invitees.scheduled_session_id
+ AND ss.host_user_id = auth.uid()
+ )
+ )
+ WITH CHECK (
+ EXISTS (
+ SELECT 1 FROM public.scheduled_sessions ss
+ WHERE ss.id = scheduled_session_invitees.scheduled_session_id
+ AND ss.host_user_id = auth.uid()
+ )
+ );
+
+REVOKE SELECT, INSERT, UPDATE, DELETE ON public.scheduled_session_invitees FROM anon;
+```
+
+The `UNIQUE (scheduled_session_id, email)` constraint is what makes re-inviting an existing
+address a no-op rather than a duplicate.
+
+> **Do not add a permissive `anon` policy to this table.** The original migration shipped
+> `USING (true)` SELECT and UPDATE policies for `anon` to serve the RSVP page. Because RLS
+> cannot restrict _which columns_ an UPDATE touches, that let anyone holding the anon key —
+> which ships to every browser — read every `invite_token` and rewrite any row.
+> `20260811120000_restrict_invitee_anon_access.sql` dropped both and revoked the grants.
+> The RSVP page is served by `/api/invite/[token]` through the service-role client, which
+> bypasses RLS, so no browser-facing policy is needed.
+
### Database Functions
#### Create Session
@@ -830,6 +929,171 @@ const { data: mediaSession, error } = await supabase.rpc('end_media_session', {
});
```
+### Scheduled Sessions API
+
+A scheduled session is a meeting booked for a future time. Unlike the endpoints above,
+these are Next.js route handlers served by `apps/web` (not PostgREST), so they are called
+with a plain `fetch` and authenticated by the Supabase session cookie rather than an
+explicit JWT header. Every endpoint is host-only: a meeting you do not host responds
+`404`, not `403`, so meeting ids cannot be probed.
+
+Responses follow the usual envelope — `{ "data": ... }` on success, `{ "error": "..." }`
+on failure.
+
+#### Create a Scheduled Meeting
+
+```typescript
+// POST /api/scheduled-sessions
+const res = await fetch('/api/scheduled-sessions', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ title: 'Design sync',
+ description: 'Weekly review', // optional, max 500 chars
+ scheduledAt: '2026-09-01T15:00:00Z', // ISO 8601
+ durationMinutes: 60, // 15–480, defaults to 60
+ inviteeEmails: ['a@example.com'], // optional, max 50
+ }),
+});
+
+// 201 Created
+{
+ "data": {
+ "id": "uuid",
+ "host_user_id": "uuid",
+ "title": "Design sync",
+ "scheduled_at": "2026-09-01T15:00:00Z",
+ "duration_minutes": 60,
+ "join_code": "ABC123",
+ "status": "pending",
+ "invitee_count": 1
+ }
+}
+```
+
+Each invitee row gets its own `invite_token`, and every invitee is emailed the meeting
+details plus the shared `join_code`. The join code is checked for uniqueness against both
+`sessions` and `scheduled_sessions` before it is assigned.
+
+#### List Scheduled Meetings
+
+```typescript
+// GET /api/scheduled-sessions?filter=upcoming
+// filter: 'upcoming' (default) | 'past' | 'all'
+```
+
+Returns the caller's own meetings ordered by `scheduled_at` ascending, each with an
+`invitees` array and an `invitee_count`. Cancelled meetings are always excluded.
+
+#### Get a Scheduled Meeting
+
+```typescript
+// GET /api/scheduled-sessions/{id}
+```
+
+#### Update a Scheduled Meeting
+
+`PATCH` edits meeting fields, the guest list, or both. Field names are accepted in either
+`snake_case` or `camelCase` (`scheduled_at` / `scheduledAt`).
+
+```typescript
+// PATCH /api/scheduled-sessions/{id}
+await fetch(`/api/scheduled-sessions/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ scheduledAt: '2026-09-02T15:00:00Z',
+ inviteeEmails: ['a@example.com', 'c@example.com'],
+ }),
+});
+```
+
+**`inviteeEmails` is the complete desired guest list, not a delta.** The API diffs it
+against the current invitees: addresses that are new get added and emailed an invite,
+addresses that have disappeared are deleted and emailed a withdrawal notice. Omitting the
+field entirely leaves the guest list untouched — sending `[]` removes everyone. Emails are
+lowercased, trimmed, and de-duplicated before the diff runs.
+
+Who gets email depends on what changed:
+
+| Change | Email sent |
+| --------------------------------------------------------------------- | -------------------------------------------------------------- |
+| 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 |
+| Invitees only added, nothing else changed | Nothing to existing invitees |
+
+Editing a cancelled meeting returns `400`.
+
+#### Cancel a Scheduled Meeting
+
+```typescript
+// DELETE /api/scheduled-sessions/{id}
+// → { "data": { "cancelled": true } }
+```
+
+This is a soft cancel: the row is kept with `status: 'cancelled'` and every invitee is
+emailed a cancellation. Cancelled meetings stop appearing in the list endpoint.
+
+#### RSVP (Invitee-Facing, No Login)
+
+The invitation email links to `/invite/{token}`, backed by these two endpoints. They are
+the only scheduled-session endpoints that do not require a logged-in user — the
+`invite_token` from the URL _is_ the credential, so treat the link as a secret.
+
+```typescript
+// GET /api/invite/{token}
+{
+ "data": {
+ "invitee": { "id": "uuid", "email": "a@example.com", "name": null, "rsvpStatus": "pending" },
+ "meeting": {
+ "id": "uuid",
+ "title": "Design sync",
+ "scheduledAt": "2026-09-01T15:00:00Z",
+ "durationMinutes": 60,
+ "joinCode": "ABC123",
+ "status": "pending",
+ "hostName": "Ada Lovelace"
+ }
+ }
+}
+
+// POST /api/invite/{token} body: { "rsvpStatus": "accepted" | "declined" }
+// → { "data": { "rsvpStatus": "accepted" } }
+```
+
+An unrecognised token returns `404`. Both endpoints run through the service-role client,
+which is why the table needs no anon-facing RLS policy.
+
+#### Join Code Rotation on Removal
+
+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
#### Get Profile