Spec 06 Class Join Codes - SekiroKenjii/Quizzivy GitHub Wiki
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
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(no0/O,1/I). Displayed groupedXXXX-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, whichServeruns 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, reasonlegacy_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_PREVIOUSto the old key andJOIN_CODE_KEYto the new one, and codes under either key redeem and read back.maintenance rekey-join-codes -applythen 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.
/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
sessionStorageacross sign-in. A must-change-password account goes through/change-passwordfirst, 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 fromRetry-After. The typed code and the state of a join are forgotten when the dialog closes./joinand/join/:codestay for links and QR codes. -
Already a member. The dialog compares the preview's
classIdwith 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/joinit reads as joined. -
Outcomes. On
/joinan 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.
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).
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.
getJoinCodereads 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,listClasseswithwithCodes=true, which opens a code exactly whengetJoinCodewould. - Rotate code (confirm dialog: "Mã cũ sẽ ngừng hoạt động ngay").
- An archived class takes no new code: Rotate answers
409 CLASS_ARCHIVEDand 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) andjoined_at, so the teacher can spot unexpected enrolments. - Remove member (revokes access; attempts are retained, not deleted).
A leaked code lets a stranger into the class. Mitigations, all required:
-
Rate limit the sign-in and join operations, and return
429withRetry-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/joinandPOST /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 ajoinCode. -
POST /auth/login: 120/min and 600/h per address; 10/min per address and email; 20/h per email. -
POST /auth/refreshandPOST /auth/logout: 120/min and 1,200/h per address. -
DELETE /auth/sessions/{familyId}: 10/min and 60/h, andPOST /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):
resetStudentPassword5/min and 30/h,resetStudentsPasswords2/min and 10/h,createStudent30/min and 300/h,rotateJoinCode10/min and 60/h,getJoinCode60/min and 600/h,listClasses60/min and 600/h (it can open every code of a page),openDocsSession5/min and 30/h. A caller with no session or without the permission is answered401or403and 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
413before 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/previewreturns 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.