Skip to content

feat(meetings): rotate the join code when an invitee is removed - #64

Merged
ralyodio merged 1 commit into
docs/scheduled-sessions-apifrom
feat/rotate-join-code-on-removal
Aug 11, 2026
Merged

feat(meetings): rotate the join code when an invitee is removed#64
ralyodio merged 1 commit into
docs/scheduled-sessions-apifrom
feat/rotate-join-code-on-removal

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Stacked on #63review/merge that one first, since this builds on its docs and targets its branch. The base flips to master automatically once #63 lands.

The problem

Removing an invitee deleted their row and emailed them "Your join code for this meeting no longer applies." That was not true. The join_code is shared by the whole guest list and was never rotated, so a removed invitee could still open the room with the code from their original invitation. The email said one thing; the system did another.

This is the limitation I documented in #63, now fixed rather than just described.

The fix

A PATCH that drops at least one invitee rotates the meeting's join_code and emails the replacement to everyone still invited. Adding an invitee never rotates.

Because the code is shared, there's no way to revoke one person without reissuing to everybody — that's inherent to a single shared code, not something this PR could design around.

Edges worth a look in review

  • Rotation is skipped once the meeting has started. create_session() copies the code onto the live sessions row, so rewriting the scheduled row wouldn't evict anyone — it would only claim a lockout that didn't happen. POST /api/sessions/{id}/regenerate-code is the lever for a running session. Covered by a test.
  • A failed rotation write doesn't fail the PATCH. The removal already succeeded, and a stale code beats a half-applied edit. Retained invitees are then not told the code changed. Covered by a test.
  • The update email hardcoded "Your join code (unchanged)" — which would have become a lie the moment this shipped. Now switches on a codeChanged flag.

Test-mock bug found on the way

The service mock returned [] for unmatched tables, and Boolean([]) is true. The live-session probe would have read as "already started" and silently suppressed every rotation under test — the new tests would have passed while asserting nothing. Real maybeSingle() returns null; the mock now matches.

Verification

tsc --noEmit clean · eslint clean on all changed files · 633 tests pass (65 files, 5 new) · next build compiles.

🤖 Generated with Claude Code

Removing an invitee deleted their row and emailed them "your join code no
longer applies" — which was not true. The code is shared by the whole guest
list and was never rotated, so a removed invitee could still open the room
with the code from their original invitation. The email said one thing and
the system did another.

Now a PATCH that drops at least one invitee rotates the meeting's join_code
and emails the replacement to everyone still invited, which makes the
existing removal copy honest.

Notes on the edges:

- Rotation is skipped once the meeting has started. create_session() copies
  the code onto the live sessions row, so rewriting the scheduled row would
  not evict anyone — it would only claim a lockout that did not happen.
  /api/sessions/{id}/regenerate-code is the lever for a running session.
- A failed rotation write does not fail the PATCH. The removal already
  succeeded, and a stale code beats a half-applied edit; retained invitees
  are simply not told the code changed.
- The update email hardcoded "Your join code (unchanged)", which would have
  become a lie. It now switches on a codeChanged flag.
- Adding an invitee never rotates. Only removal revokes.

Because the code is shared, there is no way to revoke one person without
reissuing to everybody. That is inherent to a single shared code.

getUniqueJoinCode moved out of the create route into lib/join-code.ts so
both paths generate codes the same way, alongside a liveSessionExistsForCode
helper for the already-started check.

Also fixes the test service mock: it returned [] for unmatched tables, and
Boolean([]) is true, so the live-session probe would have read as "already
started" and silently suppressed every rotation under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

169 finding(s)

HIGH/CRITICAL: 16 | MEDIUM: 47 | LOW: 106

Severity Rule Location
HIGH sh-eval-expansion .githooks/pre-commit:33
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:691
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:715
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:813
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:815
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:894
HIGH sh-unquoted-expansion-destructive apps/installer/scripts/install.sh:910
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1064
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1066
HIGH sh-remote-script-execution apps/installer/scripts/install.sh:1128
HIGH secret-generic-credential apps/livekit/fly.toml:12
HIGH sh-remote-script-execution apps/livekit/setup-livekit-server.sh:93
HIGH sh-remote-script-execution apps/turn/deploy-droplet.sh:62
HIGH secret-generic-credential apps/turn/fly.toml:11
HIGH secret-generic-credential docs/API.md:747
HIGH secret-generic-credential docs/API.md:753
MEDIUM insecure-temp-file .githooks/commit-msg:19
MEDIUM insecure-temp-file .githooks/post-commit:22
MEDIUM insecure-temp-file apps/desktop/src/main/streaming/index.test.ts:64
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:370
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:393
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:402
MEDIUM insecure-temp-file apps/installer/scripts/install.sh:438
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:48
MEDIUM js-unescaped-html-sink apps/web/src/app/blog/[slug]/page.tsx:73
MEDIUM js-unescaped-html-sink apps/web/src/app/c/[handle]/page.tsx:191
MEDIUM js-unescaped-html-sink apps/web/src/app/l/[joinCode]/page.tsx:165
MEDIUM js-unescaped-html-sink apps/web/src/app/layout.tsx:128
MEDIUM js-unescaped-html-sink apps/web/src/app/live/page.tsx:145
MEDIUM js-unescaped-html-sink apps/web/src/app/page.tsx:122
MEDIUM js-unescaped-html-sink apps/web/src/app/pricing/page.tsx:284
MEDIUM js-open-redirect apps/web/src/app/pricing/UpgradeButton.tsx:50
MEDIUM js-unescaped-html-sink apps/web/src/app/u/[username]/page.tsx:282
MEDIUM manifest-install-lifecycle-script package.json:54
MEDIUM sql-template-interpolation packages/ai-core/src/prompts.ts:36
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:81
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:96
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:112
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:133
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:160
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:189
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:214
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:237
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:273
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:298
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:324
MEDIUM insecure-temp-file packages/remote-input/src/backends/waylandYdotool.test.ts:346
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:154
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:160
MEDIUM js-shell-exec-interpolation scripts/lib/package-managers/apt.ts:208

…and 119 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 4ad999f into docs/scheduled-sessions-api Aug 11, 2026
6 checks passed
ralyodio added a commit that referenced this pull request Aug 11, 2026
…#63)

* docs(api): document the scheduled sessions API and its invitee tables

The scheduled meetings feature shipped undocumented: docs/API.md covered
neither the two tables nor any of the /api/scheduled-sessions endpoints.

Adds the scheduled_sessions and scheduled_session_invitees schemas, the
create/list/read/update/cancel endpoints, and the invitee-facing RSVP
endpoints at /api/invite/[token].

Three things worth writing down rather than rediscovering:

- PATCH takes `inviteeEmails` as the complete desired guest list, not a
  delta, and which email goes out depends on what the diff turns up.
  Omitting the field leaves the list alone; sending [] clears it.
- Removing an invitee does not rotate the shared join_code, so it does
  not actually revoke access. Recorded as a known limitation instead of
  leaving removal to look like a lockout.
- scheduled_session_invitees must not get a permissive anon policy —
  the dropped USING (true) pair leaked every invite_token to the browser.
  Notes why the table needs no browser-facing policy at all.

Also corrects an assumption while documenting: scheduled_sessions.session_id
is never written by any code path, so it stays NULL even after a meeting
starts. The link to the live room is the join_code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(meetings): rotate the join code when an invitee is removed (#64)

Removing an invitee deleted their row and emailed them "your join code no
longer applies" — which was not true. The code is shared by the whole guest
list and was never rotated, so a removed invitee could still open the room
with the code from their original invitation. The email said one thing and
the system did another.

Now a PATCH that drops at least one invitee rotates the meeting's join_code
and emails the replacement to everyone still invited, which makes the
existing removal copy honest.

Notes on the edges:

- Rotation is skipped once the meeting has started. create_session() copies
  the code onto the live sessions row, so rewriting the scheduled row would
  not evict anyone — it would only claim a lockout that did not happen.
  /api/sessions/{id}/regenerate-code is the lever for a running session.
- A failed rotation write does not fail the PATCH. The removal already
  succeeded, and a stale code beats a half-applied edit; retained invitees
  are simply not told the code changed.
- The update email hardcoded "Your join code (unchanged)", which would have
  become a lie. It now switches on a codeChanged flag.
- Adding an invitee never rotates. Only removal revokes.

Because the code is shared, there is no way to revoke one person without
reissuing to everybody. That is inherent to a single shared code.

getUniqueJoinCode moved out of the create route into lib/join-code.ts so
both paths generate codes the same way, alongside a liveSessionExistsForCode
helper for the already-started check.

Also fixes the test service mock: it returned [] for unmatched tables, and
Boolean([]) is true, so the live-session probe would have read as "already
started" and silently suppressed every rotation under test.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* style(docs): apply prettier to API.md

CI runs format:check across **/*.md and the new scheduled-sessions section
did not match prettier's table and code-fence formatting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: apply prettier to the scheduled-sessions route tests

Same CI format:check gate as the previous commit; the rotation tests were
added without a prettier pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant