Skip to content

Classroom join codes - #25

Merged
mathewdunne merged 29 commits into
mainfrom
classroom-join-codes
Oct 1, 2026
Merged

mathewdunne merged 29 commits into
mainfrom
classroom-join-codes

Conversation

@mathewdunne

Copy link
Copy Markdown
Owner

Added classroom join codes: an admin starts a short-lived classroom (Admin → Classrooms) with a 6-digit code, and students join at /join with just the code and their name — no OAuth, no allowlist. Guests and their projects are deleted automatically when the classroom ends.

  • Custom Better Auth plugin (POST /api/auth/classroom/join) mints real sessions for guest users (user.classroomId set, role student); rejoin by the same name returns to the same workspace after a confirm step.
  • Join dispatcher: rate-limits failed codes (10/IP, 100 global per 10 min), disabled in demo mode, and retires the browser's previous guest session (stopping that guest's container).
  • Guest sessions die with the classroom (checked in getSessionFromRequest and on Better Auth's get-session).
  • Leave in the guest user menu signs out server-side and stops the container immediately, so rotating students free capacity.
  • ClassroomSweeper (60 s) and End now delete guests, workspaces and files via a deleteUserAndWorkspace helper shared with DELETE /admin/users/:id.
  • Workspace slug allocation falls back to a random numeric suffix after 16 collisions (non-Latin names all slugify to student).
  • Docs: decision 043, "Joining a classroom" (user guide), "Running a classroom session" (operator guide), AGENTS.md.

Known limitation: behind the Cloudflare Pages front the per-IP bucket collapses to one shared bucket — documented in 043 with the follow-up (forward CF-Connecting-IP + Caddy trusted_proxies).

Validation: typecheck; 501 control-plane, 153 web, 78 E2E, 12 E2E security tests pass (one pre-existing Preview E2E test flaked once, 3/3 on rerun). Manual check of the admin Classrooms tab in a browser still pending.

🤖 Generated with Claude Code

mathewdunne and others added 29 commits September 30, 2026 10:45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e setup

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The IDE keeps polling /api/sim/status (and the editor proxy reconnects)
until the page navigates, and any authenticated poll can restart the
container Leave just stopped. POST /u/:slug/api/leave now signs out via
Better Auth before stopping the workspace and forwards its cookie-clearing
Set-Cookie headers; stop failures return a structured error.
getSessionFromRequest also rejects a guest whose session row is gone, so
the 5-minute cookie cache cannot keep a left guest signed in. The client
only falls back to authClient.signOut() when the leave request fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…guests

The join endpoint read the guest before ensureWorkspace, so a guest whose
slug moved on allocation ("student" -> "student-1") got the old slug baked
into the cookie cache, and RootIndex sent them to another student's
workspace. Read the user after ensureWorkspace.

Better Auth's own GET /api/auth/get-session still reported a guest of an
ended classroom as signed in; the dispatcher now answers null (Better
Auth's no-session shape) whenever getSessionFromRequest rejects the
request, and passes through otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…join

retirePreviousSession runs after Better Auth has created the new guest's
session, so a docker stop failure there turned a successful join into a
500 without the session cookie. Log it and let the idle reaper catch up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Record that behind the Cloudflare Pages front the per-IP join bucket keys
on a Cloudflare egress IP, with the CF-Connecting-IP + Caddy
trusted_proxies follow-up, and list the four classroom audit actions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deleting a large classroom's guests can outlast the 60 s interval; a
second pass would start cleaning the same classroom again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rver

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…leave

- Decide rate-limit failures in one tick before the auth handler so a
  parallel burst of bad codes can't all pass the check.
- Reserve guest slots synchronously so concurrent joins can't exceed the
  60-guest cap.
- Close a workspace's run/import/lesson-load/gamepad sockets when it is
  stopped (Leave, replaced guest) or its user is deleted (End now, sweeper).
- Proxy /admin/classrooms in Vite dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A first-time student picking a lesson has no work to lose, so loading or
importing into an empty project starts straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mathewdunne
mathewdunne merged commit afd761a into main Oct 1, 2026
1 check passed
@mathewdunne
mathewdunne deleted the classroom-join-codes branch October 1, 2026 02:31
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