Spec 06 Class Join Codes - SekiroKenjii/Quizzivy GitHub Wiki

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

6. Class join codes (self-signup)

6.1 Model

A join code belongs to a class and is a bearer secret: whoever holds it can enrol. Treat it accordingly.

  • Format: 8 characters from an unambiguous alphabet ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (no 0/O, 1/I). Displayed grouped XXXX-XXXX; accepted with or without the dash, case-insensitive.
  • Generated from a CSPRNG. Never sequential, never derived from the class ID.
  • Per-code controls: expires_at (default 30 days, counted to 23:59:59 of the last day in the issuer's calendar zone), max_uses (default null = unlimited), uses_count, revoked_at.
  • One active code per class at a time. Rotating issues a new code and revokes the old one; previously enrolled students are unaffected.
  • Stored encrypted, so it can be read back for its teacher and for admins (D5), and found by a keyed hash (§6.5). A code issued from v0.8.0 on is sealed under the server's JOIN_CODE_KEY, which the database never holds (§13.3). A code issued before v0.8.0 was held only as its SHA-256: it redeemed but could not be read back. v0.10.0 replaced every such code that was neither revoked nor expired, once, when the API started: jobs.RotateLegacyJoinCodes, which Serve runs beside the prunes and nothing else runs, under a two-minute budget and never on a timer. Each class is its own transaction, taking advisory lock (73819, 41), then the class row, then re-reading the class's active code, so machines that start together rotate a class once and a second run finds nothing; a class that lost a deadlock is tried once more after the others, and a class the run could not reach waits for the next start-up. The sealed code keeps the old one's expiry, use cap (a stored NULL stays NULL), use count and creator, so a code that had reached its cap is used up again and the rotation admits no student the old code refused (F-67); it leaves the class's self-join setting as it is, and is audited as the System (class.join_code_rotated, reason legacy_rotation, hints only, never a code). Archived classes are rotated too. Each class's teacher is told once in the app (join_codes.rotated, with how many of their classes and the first five names); the old code then answers as any replaced code does, "revoked" where self-join is open and as an unknown code where it is closed or the class archived. A revoked or expired scheme-1 row is left as it is, and the scheme-1 lookup that finds it stays until R5 removes it (docs/setup/operations.md, "Legacy join codes").
  • The key is standard base64 of exactly 32 random bytes, kept as a Fly secret with an offline copy. The API refuses to start without it and never logs it. HKDF-SHA256 derives three values from it, each under its own label: the AES-256-GCM key, the HMAC-SHA256 lookup key and a 16-bit key id; a key whose id derives to 0 is refused.
  • Rotating the key: one deploy sets JOIN_CODE_KEY_PREVIOUS to the old key and JOIN_CODE_KEY to the new one, and codes under either key redeem and read back. maintenance rekey-join-codes -apply then re-seals every code under the old key with the new one, and the old key is unset (docs/setup/operations.md, "Join-code key"). A lost key leaves every code sealed under it unreadable and unredeemable: rotate every class's code.

6.2 Student flow

/join                → enter code; at 8 valid characters the class is previewed inline
/join/:code          → deep link (QR / message): code prefilled and previewed
                     → preview card: class name + teacher, "Tham gia {class}"
                     → signed out: sign in (Google → POST /auth/google {code, joinCode};
                       or password → POST /app/classes/join after sign-in)
                     → signed in: POST /app/classes/join at once
                     → Joined: "Bạn đã vào lớp {class}", "Đến lớp của tôi" → /app/classes
/join/:code/confirm  → redirects to /join/:code
/app, /app/classes   → "Tham gia lớp" opens the Join dialog for a signed-in student:
                       the same field and preview, then POST /app/classes/join

The preview exists so the student sees which class they are joining before authenticating. Never create an account and enrol in one blind tap.

  • Typing. The field uppercases, drops spaces and dashes, and shows a dash after four characters. A character the alphabet never uses (0, O, 1, I) marks the field invalid and sends nothing.
  • Lookup. One lookup runs per complete code, after a 250 ms debounce. Every failure (invalid, expired, exhausted, revoked) shows the same message, and nothing about any class reaches the page. An answer is remembered for 30 seconds, a refusal as much as a class, so retyping a code sends nothing. An edit does not abandon a lookup already out, which the server has counted. A lookup that could not be made is sent again only when the code is retyped.
  • Signed out. The join context (code, class name, teacher) is kept for 30 minutes in sessionStorage across sign-in. A must-change-password account goes through /change-password first, and the join then continues.
  • In the app. A signed-in student joins without leaving the page. "Tham gia lớp" on Classes, and on Home for a student in no class with nothing assigned, opens the Join dialog: the same field and lookup, the class found (name and teacher) and "Tham gia", which calls POST /app/classes/join. On success the class and assignment lists refetch, a toast says "Bạn đã vào lớp {class}" and the dialog closes; it waits for the class list first, for at most three seconds. A 429 shows the server's message with the wait from Retry-After. The typed code and the state of a join are forgotten when the dialog closes. /join and /join/:code stay for links and QR codes.
  • Already a member. The dialog compares the preview's classId with the student's own classes (GET /app/classes): a member reads "Bạn đã ở trong lớp này rồi." and cannot join. The server checks the code's state before membership, so a member who types a dead code reads the failure message. The enrolment is idempotent: a member's join answers 200 with the class and counts no use of the code, so on /join it reads as joined.
  • Outcomes. On /join an enrolment failure shows its message on the Joined page. In the dialog a code that died after the preview shows the failure message, and any other failure "Không thể tham gia lớp. Vui lòng thử lại." An account that is not a student is told that joining is for student accounts.

6.3 Why Google-only for self-join

Self-signup with email + password requires a verified email, which requires transactional email infrastructure (provider, domain auth, deliverability, bounce handling) — a real dependency v1 does not have. Google hands us a verified email for free and is one tap on a phone, which is exactly the self-join case.

So: self-join requires Google. Password accounts remain staff-created. If Thuong later wants password self-signup, add an email provider and a email_verifications table; the join flow itself does not change. This is a v1 scoping decision, not a permanent constraint (§17.1).

6.4 Teacher controls

A class's code and members are managed by its teacher (classes.teacher_id), and by an Admin through scope.all (plan 70 §4.1). Another teacher's class answers 404, as a missing class does. The code operations and member changes need teaching.classes.write; the member list needs people.students.read.

On /teacher/classes/:id (the v0.7.0 path /admin/classes/:id redirects to it):

  • Show the active code in full, with copy button, QR code, expiry, and uses count. getJoinCode reads the code back (§6.1), so the screen shows it whenever the teacher opens it, not only once when issued. The class cards show every code from one call, listClasses with withCodes=true, which opens a code exactly when getJoinCode would.
  • Rotate code (confirm dialog: "Mã cũ sẽ ngừng hoạt động ngay").
  • An archived class takes no new code: Rotate answers 409 CLASS_ARCHIVED and leaves its current code as it is, because nobody can join an archived class (the code answers as an unknown one). Revoking stays allowed; restoring the class lets it rotate again.
  • Disable self-join toggle — revokes the code without issuing a new one.
  • Member list shows joined_via (admin / join_code) and joined_at, so the teacher can spot unexpected enrolments.
  • Remove member (revokes access; attempts are retained, not deleted).

6.5 Security requirements — non-optional

A leaked code lets a stranger into the class. Mitigations, all required:

  • Rate limit the sign-in and join operations, and return 429 with Retry-After. Without this, an 8-char code space is still worth probing at scale. The budgets let a class of 40 behind one school address make three tries at each step (T-R2.15):
    • POST /join/preview, POST /app/classes/join and POST /auth/google: 120/min and 600/h per address; 200/h per code, from any address. Google sign-in counts a code only when the body carries a joinCode.
    • POST /auth/login: 120/min and 600/h per address; 10/min per address and email; 20/h per email.
    • POST /auth/refresh and POST /auth/logout: 120/min and 1,200/h per address.
    • DELETE /auth/sessions/{familyId}: 10/min and 60/h, and POST /auth/sessions/revoke-others: 5/min and 30/h, per signed-in user. Neither hands out a credential; each moves the user's session epoch and so makes every device of the user refresh (T-R4.9).
    • A code is counted after normalization, so respelling it buys no fresh allowance. Guessing stays at 600 codes an hour per address on each operation that checks one.
    • The signed-in operations that hand out a credential are limited per signed-in user, after the permission check and wherever the user connects from, so staff behind one address do not share a budget (T-R4.53): resetStudentPassword 5/min and 30/h, resetStudentsPasswords 2/min and 10/h, createStudent 30/min and 300/h, rotateJoinCode 10/min and 60/h, getJoinCode 60/min and 600/h, listClasses 60/min and 600/h (it can open every code of a page), openDocsSession 5/min and 30/h. A caller with no session or without the permission is answered 401 or 403 and spends no budget.
  • Bounded bodies. The four operations with a bucket keyed on a body field (login, Google sign-in, the preview and the in-app join) accept at most 8 KiB and answer 413 before any handler, so padding cannot hide the key from its bucket.
  • Constant-time comparison on code lookup; look up by a keyed hash (HMAC-SHA256) of the normalized code, not by plaintext equality. A live code issued before v0.8.0 was found by its SHA-256 until v0.10.0's start-up rotation replaced it (§6.1); a revoked or expired one still is, until R5 removes the lookup.
  • No audit row or log line carries a code, its ciphertext or its hash.
  • POST /join/preview returns exactly three fields: the class's id, its name and the teacher's display name. Never student names, counts, a member list or assignment titles. The id is a uuidv7: it reveals only when the class was created and grants nothing by itself. The Join dialog uses it to tell a student who is already a member (§6.2). It is an unauthenticated endpoint.
  • Log every enrolment (class_id, user_id, ip, user_agent, at) to the audit table.
  • Expiry defaults to 30 days precisely so an abandoned code stops working on its own.

Deliberately not built: an approval queue. For a small practice, rotate-and-remove is sufficient and one less state machine. Revisit if enrolment volume grows (§17.2).


Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️