Repository navigation
Spec 05 Auth
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
Signing in (§5.1–§5.3) says who a user is; the user's role (§1.1) says what they may do. The permission catalogue, the built-in grants, the Admin wildcard, the hidden keys and the pseudo-keys (self, workspace.teacher, workspace.admin) are in docs/plan/70-redesign-overview.md §4.1. A role opens the Admin console (workspace.admin) when it holds people.users.manage, people.roles.manage, system.audit.read, system.settings.write, ai.credits.manage or scope.all. PermissionKey in api/openapi.yaml equals the rows of app.permissions, and the server refuses to start when the database lacks a key it was built with.
-
Enforcement. Every operation that requires the bearer token declares
x-permissioninapi/openapi.yaml: a key, a pseudo-key, or a list met by any one of its keys (70 §4.2).httpx.RequirePermissionenforces it on every request, afterRequireAuth; the path is not the gate. The server refuses to start when an operation declares nothing, declares a value outside the catalogue, or declares one that does not belong to its path tree (/teacher/*,/admin/*,/app/*,/auth/*,/me/*). -
Open operations do not require the bearer token, declare no permission and pass untouched. There are eleven: login, Google sign-in, refresh, logout, the two set-password operations (
POST /auth/set-password/checkandPOST /auth/set-password, §5.4),POST /join/preview,GET /public/status,GET /public/organization,GET /public/organization/logoand the integrity beacon (POST /app/attempts/:id/events).theOpenElevenincore/router/tests/auth_middleware_test.gopins the list. -
Refusals. No valid token, an unknown or disabled user, or a token older than the user's session epoch (§5.2) →
401 UNAUTHORIZEDwithWWW-Authenticate, which the single-flight refresh settles. An account still on a temporary password (mustChangePassword) →403 PASSWORD_CHANGE_REQUIREDfrom every operation butgetCurrentUserandchangePassword, the two that declarex-allows-temporary-password: true, before its permissions are weighed and whatever the console (T-R5.9b). An unmet permission →403 FORBIDDEN, with no detail. -
The principal. Each gated request resolves the caller's role, permissions,
disabled_atand session epoch through an in-process cache that keeps a user for 10 seconds. A write on the same machine forgets the entry at once, so the change applies on that machine's next request and on any other within 10 seconds. Polling does not query the database on every request. -
Scope is separate from permission. Repositories filter by
access.Scope: another teacher's id answers as a missing one does, and their rows never appear in a list or a count. A student is visible to every teacher who reaches them: a member of a class the teacher teaches, an account the teacher created, or an individual target of an assignment the teacher created.scope.all, which only the Admin holds, lifts the filter for a read or a write by id, and in the lists of classes, students and attempts. It does not lift it in the teacher workspace's six content lists (listTests,listQuestions,listQuestionGroups,listMedia,listWordImports,listAssignments, with their facets, tags and counts): those hold the caller's own rows for every caller, the Admin included, and for assignments that is those the caller created or that target a class the caller teaches (DG-53, T-R4.54). Both teacher home operations,getDashboardandgetTeacherSummary, pass.Own(), so their figures cover only the caller's teaching, admins included; the repository retains its widescope.allreading, which the Admin console does not use (its readings are its own, §13.8). The Admin reaches another teacher's content by id and, since R5, in the Admin console (§5.7, §8).
Two, both landing on the same users row keyed by verified email:
-
Email + password — student accounts created by staff (
people.students.create), and staff sign-in. - Google Sign-In — the primary path for students, and the only path for self-join (§6.3).
A user may have both. Linking rule: a Google sign-in whose ID token carries email_verified: true matching an existing user links to that user. An unverified email is rejected outright — no link, no create. This closes an account-takeover path.
- Access token: JWT, ~15 min, held in memory (Zustand). Never localStorage, never sessionStorage. It carries the user, its times and the session epoch (
sep) and no role; a token minted before v0.10.0 also names one, which is ignored. - Refresh token: opaque, rotating,
httpOnly; Secure; SameSite=Lax; Path=/authcookie. Stored server-side as a hash (§13.5). - On 401 the client calls
POST /auth/refreshonce and retries. The refresh is single-flight. - A refused refresh (401 or 403), or a second 401, ends the session.
- A refusal ends only the session its request was sent under. When the store's access token is no longer the one the refused request carried (a sign-in or a refresh replaced it, or the user signed out), nothing is expired or cleared, and the 401 returns to its caller.
- A user who was signed in keeps the page: the access token is dropped, the user stays, and the "Vui lòng đăng nhập lại" overlay covers the page (§9).
- A sign-out closes that overlay if it was raised while the sign-out was on its way.
- After the user's own sign-out, that tab's guard sends a visitor to
/loginwithoutnextuntil its next sign-in. The marker is in memory, so a reload does not preserve it. An expired session keeps its return path. A second tab left open after sign-out can still offernextthrough its expired-session card; that path remains unresolved.
- "Đăng nhập" clears the session, keeps the answer drafts, and goes to
/login?next=<path>. - While that overlay is up, a 401 returns to its caller without another refresh.
- A refresh that fails without refusing (a network failure, a 503 or another 5xx) is not a lost session. Waiting requests fail retryably and nobody is signed out.
- App load:
GET /auth/me, under the boot splash (§9).- 401 → signed out, unless a sign-in finished while the request was out.
- A network failure (no HTTP response at all, on
/auth/meor on the refresh it triggers) → the splash's offline state, which retries. - Any other failure (a 5xx, a 503 other than
MAINTENANCE, or a 4xx other than 401 and 403) → on/and the signed-in routes, the unexpected-error page with the server'srequestId; on the public routes the splash fades out and the page renders as usual. - 503
MAINTENANCE→ the maintenance overlay.
- Every request sends
Accept-Languageset to the app's locale, so server messages match the UI rather than the browser. - Reuse detection: presenting an already-rotated token revokes the whole family and forces re-login.
-
Races. A sign-in stores its refresh token under a lock on the user and compares the account with what it read: if the password, the session epoch or the disabled state changed meanwhile, a password sign-in answers
INVALID_CREDENTIALSand a Google sign-in reads the account again. A rotation and a logout take the lock an update of the user takes, so one user's rotations, reuse detections and logouts run one at a time and none overlaps a reset, a disable or a password change: a session never survives the event that should have ended it. -
Session epoch.
users.session_epochends live sessions at once. Sign-in, Google sign-in and refresh put the user's current epoch in the access token (claimsep; a token without it reads as 0), and a token older than the user's epoch is refused (§5). In R2 two writes move it, each in the same transaction that revokes every refresh family the student has: disabling a student (updateStudentwithdisabled: true) and resetting a student's password (resetStudentPassword, and each student ofresetStudentsPasswords). Enabling the account again does not bring those sessions back. R5's sign-out-everywhere, role changes, disables and resets move it too (T-R5.9b), and so do set-password links: using a link does, and so does issuing a reset to an account whose owner did not ask for it (70 §4.2). -
Signed-in devices. A session is a refresh-token family that is neither revoked nor expired.
GET /auth/sessionsreturns the caller's own, at most 100, the one the request's refresh cookie names first withcurrent: true: a handle (familyId), adevicelabel ("Mac · Chrome", built at read time from closed lists of systems and browsers and never from the user agent's own text, or null), adeviceKindfor the icon, alocation("City, CC", or null) andlastUsedAt. The location and the time are those of the latest sign-in or refresh, so the list shows where a device last refreshed, not where it first signed in: a rotation takes the label of its own request, and copies its predecessor's only when the request has none. The label is built fromCF-IPCityandCF-IPCountryonly whenCLIENT_IP_HEADERisCF-Connecting-IPand the request carries that header; under any other configuration it is null. It reaches no log and no audit row.DELETE /auth/sessions/{familyId}ends one session, andPOST /auth/sessions/revoke-othersevery one but the caller's. Each is one command: it takes the lock a rotation takes, revokes the family, moves the caller's session epoch, audits it and, after the commit, forgets the cached principal. A revoked device's access token stops working at once on that machine and within 10 seconds on any other; the caller's own next request answers 401 and recovers through the single-flight refresh, because the caller's family stays live and a refresh does not read the epoch. The caller's session is the one the refresh cookie names. A request whose cookie names no live session of the caller, such as one cleared by a sign-out in another tab or a bearer token alone, is answered401 UNAUTHORIZEDby both writes, which write nothing; in the client that is a refresh that fails, and the sign-in overlay shows. An id that is another user's, revoked, expired or unknown answers404; the caller's own answers409 SESSION_IS_CURRENT(signing out isPOST /auth/logout). Arevoke-othersthat ends nothing writes nothing and moves no epoch. -
A disabled user is refused on the next request. Every gated request reads
disabled_atthrough the principal cache (§5): at once on the machine that made the change, within 10 seconds on any other. Refresh refuses a disabled user and revokes the family, so the client's refresh ends the session.
- Frontend loads GIS and requests an authorization code (not implicit ID-token) with PKCE.
-
POST /auth/google{ code, codeVerifier, redirectUri, joinCode? }. - Backend exchanges with Google, verifies the ID token (
iss,aud,exp, signature via JWKS), readssub,email,email_verified,name,picture. - Resolution order:
- identity exists → log in;
- verified email matches a user → link identity, log in;
- no match and a valid
joinCodeis present → create account + enroll (§6.3); - no match, no join code →
403 ACCOUNT_NOT_PROVISIONED.
- Returns
{ accessToken, user }, whereuseris theCurrentUser(§7), and sets the refresh cookie.
VITE_GOOGLE_CLIENT_ID is public config. The client secret lives only in the backend.
-
RequireSession→/login?next=<path>when unauthenticated. -
The web guards read the signed-in user's
workspacesandpermissions(CurrentUser, §7), never therolerecord (it names the user's role on screen and decides nothing), throughfeatures/auth/permissions.ts(cananduseCan,hasWorkspaceanduseWorkspace). They decide what the SPA shows; the server enforces the permissions on every request (§5).-
TeacherWorkspaceholds/teacher/*, the teacher console since R4, for theteacherworkspace. Anyone else gets a 403 page, not a redirect (a redirect hides the misconfiguration). -
AdminWorkspaceholds/admin/*, the Admin console since R5, for theadminworkspace. A path under it that is not a console route is a teacher address from before R4 and goes to the redirect below. A console route for a user without the workspace answers the 403 page, except/adminand/admin/classes, which are also pages of the teacher's tree and redirect to/teacherand/teacher/classes. -
RequirePermissionsits inside it and aboveAdminLayout. It readshandle.permissionof the leaf route (a key, a pseudo-key or a list met by any one of its keys, asx-permissionis) and renders the 403 page when the user lacks it; a console route that declares none is refused too. -
StudentAreaholds/app/*for theappworkspace. A user without it who has theteacheroradminworkspace is redirected to their own console (homePathFor); a user with neither gets the 403 page. -
homePathForsends a built-in Admin (scope.all) who holds both consoles topreferences.workspace,/adminwhen it is unset; any other holder of theteacherworkspace to/teacher; a user with only theadminworkspace to/admin; and anyone else to/app. The switcher and the account menu writepreferences.workspacewhen the user crosses to the other console. Any holder oflearning.take_testswho also has theadminworkspace (an Admin with "Take tests" turned on, or a custom role holdingai.credits.manageand "Take tests") lands in a console and may also open/app; the switcher and the account menu draw only the two consoles, so the student app is reached by address (DG-218). - A v0.9.x address under
/admin/*that is not a console route redirects to the same path under/teacher, in any letter case, keeping its query and hash (/admin/classes/:idto/teacher/classes/:id,/admin/settings/profileto/teacher/settings/profile);/admin/assignments/:id/attemptsand/teacher/assignments/:id/attemptsgo to the assignment's Students tab. The redirect is the web's and stays, for the teacher's own bookmarks: it does not depend on the server's v0.7.0 alias, which v0.12.0 removes (T-R5.36), and no release is planned to remove it. -
learnsOnly(the student app is the user's only workspace) keeps v0.7.0's rules on/joinand the Settings role label (§6.2).
-
-
mustChangePassword: true→ all routes redirect to/change-password. Google-only users never hit this. -
Logout:
POST /auth/logout(revokes refresh token), clear store,queryClient.clear(), →/login. The server revokes the family under a budget of its own, ten seconds, that the end of the request does not cancel: a tab closed or a connection lost during sign-out still ends the session, whose cookie stays in a browser that never saw the answer. A refresh does the opposite and runs on the request's own context (docs/plan/30-risks.mdR-06). -
The frontend applies accepted server locale/theme/display zone/larger text over browser mirrors, with omitted defaults
vi/light/Asia/Ho_Chi_Minh/false and no default-materialization PATCH. Anonymous choices remain local until a sign-in whose account lacks that key: a valid device choice (one made on this browser, not a value copied from an account) is then applied and saved to the account once, and a failed save retries silently at the next sign-in. Authenticated controls preview pending changes, restore acknowledged presentation on failure and retain an explicit Retry. A browser-unsupported account zone retains its exact server value, reports compatibility and temporarily presents Vietnam time without saving that fallback. Actor departure invalidates stale asynchronous effects. -
The frontend orders refresh/login/Google-login/logout cookie sends and global draft cleanup before replacement admission. Refresh remains single-flight. A20s transport/admission UI deadline and10s cleanup deadline show pending status without abandoning raw ownership; late timed-out login never auto-admits. Permanent hangs may keep admission closed. Cross-tab ordering is not promised by this design.
-
Password reset in v1: a holder of
people.students.reset_passwordsets a temporary password (word-word-word-NN, three words and two digits, about 24.4 bits) from the student detail page, under the shared-student rule below, or for up to 40 students at once from the student list. From R5 an emailed set-password link (below) is another way to set a password: the Admin console issues it (createUserand the two Admin resets withdelivery: email_link, and a user import delivered by email, §5.7), and a Google-only account can ask for its own from Settings. A teacher's reset of a student still sets a temporary password. There is still no reset form for an account to ask for one while signed out (§17.1). -
New passwords have three rules:
- at least 8 characters;
- at least one number or symbol (
[\p{N}\p{P}\p{S}]); - different from the current password (the temporary one, while
mustChangePasswordis set).
The contract enforces the first two (
400 VALIDATION_FAILED) and the server the third (400 PASSWORD_UNCHANGED). Existing passwords are never re-validated./change-passwordshows the rules and a strength meter. -
/forgot-passwordmakes no request of its own; it reads the centre's public answer (§5.6) like the sign-in page. It tells a Google user that no password is needed, and everyone else to ask the front desk (the centre's phone and hours, else §4's build values, when configured) or their teacher; while password sign-in is off it goes to/login. -
Set-password links (R5, T-R5.3). A link carries a 256-bit token in the URL fragment (
/set-password#token=…), which the page removes from the address before it sends anything, so the token reaches neither a server log nor aReferer. Only the token's SHA-256 is stored; the token itself waits, sealed, in the email outbox until the message is sent (docs/plan/20-data-model.md§49). Issuing a link revokes the account's earlier unused links, so one is live at a time, and it lasts 24 hours and works once.POST /auth/set-password/checknames the account (email,fullName,purpose,expiresAt).POST /auth/set-passwordsets the password under the rules above (a weak one is400 VALIDATION_FAILEDand spends the token's budget, not the token), clearsmustChangePassword, stampspasswordChangedAt, revokes the account's other links and every refresh family, movessession_epoch, forgets the cached permissions and signs the account in, and auditsuser.password_setwith the link's purpose. Two submits of one link set the password once; the other is410. An unknown, used, expired or revoked link and the link of a disabled account all answer the same410 SET_PASSWORD_LINK_INVALIDwith the same body, so a link says nothing about why it fails. Disabling an account revokes its unused links in the same transaction that revokes its refresh families, and enabling it again restores none: what was out in the world when access ended stays ended, and an Admin who wants the person back sends a new link through Reset. A token is refused before any password is hashed. While the centre has password sign-in off, using a link is422 PASSWORD_SIGN_IN_DISABLEDbefore the token is looked at, an invite sends a sign-in invite and no link, and a reset is refused. A reset issued for someone else ends the account's sessions as every reset does;POST /auth/set-password/request, the signed-in account's own request from Settings, keeps the session it is made from, and using the link ends the others. Apurpose: invitelink is an account's first password;resetis any later one. A Google link is never removed by setting a password. The set-password page and Settings read the centre's public answer, asking the browser to revalidate it and keeping it for thirty seconds, so the page shows no form and Settings offers no link for a password the centre no longer accepts. The page waits for the answer three seconds and never changes its mind once it has decided; Settings draws the control until the answer says off. When the answer is late, fails or was stale, the form or the control shows and the server's422is the backstop. -
Guards that are not permissions (70 §4.3), enforced by the server. A guard on a student runs after the student is found in the caller's scope, so another teacher's student answers
404, never403.-
Strict student targets. Every student read and write (the Students list and record, update, reset, bulk reset and delete, class membership, individual assignment targets, and the dashboard's counts) accepts only a role in
app.student_like_roles: the built-in Student, or a custom role granted nothing butlearning.take_tests. An Admin with "Take tests" turned on is never a student target. For an assignment's individual targets the rule applies to a target being added: a student who is already a target stays on the assignment when they are disabled or their role stops being student-like, so the teacher can still publish, edit and close it (#210). -
The subset rule.
updateStudent,resetStudentPassword,resetStudentsPasswords(for each student in it) anddeleteUserneed the target's permissions, withoutlearning.take_tests, to be a subset of the caller's (access.CanActOn); otherwise403 FORBIDDEN. Since R5 it also covers every Admin write to an account (§5.7: create, role change, disable, enable, sign-out, resets, transfer and every user-import row, refused with403 TARGET_ROLE_NOT_ALLOWED), every role write (§15) and the AI credit writes that name a teacher. -
Disabling.
updateStudentwithdisabled, either value, also needspeople.users.manage; otherwise403 FORBIDDEN. -
Shared students. A reset, single or bulk, or a new
email, by a caller withoutpeople.users.manageneeds a student no one else reaches: every class they are in, archived ones included, is the caller's; no other account created them; no other account's assignment targets them individually; and a student in no class was created by the caller. Otherwise403 STUDENT_SHARED, and nothing is written. Sending the address the student already has is not a change. A new address could take the account over through Google sign-in, which links by email (§5.1). -
The last Admin. The
users_last_admintrigger refuses any demotion, disable or delete that would leave no active Admin, whatever the path. Since R5 a role change or a disable that would do so answers409 LAST_ADMIN(§5.7): the command locks every active Admin in id order first, so two Admins ending each other's access at once leave one, and the trigger stays behind it. -
Not oneself. A role change, a disable and a reset of the caller's own account answer
422 CANNOT_ACT_ON_SELF; one's own password changes through Change password, and one's own sign-out everywhere is allowed. - The built-in Student role cannot lose
learning.take_tests.
-
Strict student targets. Every student read and write (the Students list and record, update, reset, bulk reset and delete, class membership, individual assignment targets, and the dashboard's counts) accepts only a role in
-
/docsand/docs/openapi.jsonare served by the API beside the contract and open only to a holder ofsystem.api_reference, a hidden key only the Admin holds (70 §4.1). A browser navigating there sends no bearer token, so they check a separate cookie instead. -
POST /admin/docs-session(openDocsSession,x-permission: system.api_reference, rate-limited per signed-in user, §6.5) setsquizzivy_docs:Path=/docs; HttpOnly; Secure; SameSite=Strict; Max-Age=900. Its value is a JWT for thedocsaudience that carries the caller's session epoch, signed with a key derived from the access-token key: a docs token is never accepted as an access token, and an access token never opens the docs. -
GET /admin/api-reference(getApiReferenceSummary) andGET /admin/api-reference/openapi.json(downloadOpenApi), bothx-permission: system.api_reference, rate-limited per signed-in user and reached with the bearer token, not the docs session, back the console's API reference page. The first reports the embedded contract's OpenAPI and document versions, its operation count and the count under each tag, read once per process; the second downloadsapi/openapi.yamlconverted to JSON unchanged (server/gen/contract/openapi.json, written bymake gen: the operation ids and descriptions as authored, not the PascalCased embedded spec the docs page renders) asquizzivy-openapi.jsonwithCache-Control: no-store. The body is declared as a byte stream so the generated server writes the JSON as it is. - The gate resolves the cookie's user through the same principal cache as the API (§5). A missing, tampered or expired cookie, an unknown or disabled user, or a cookie older than the user's session epoch →
401; a role withoutsystem.api_reference→403, both in the error envelope with no page or contract leaked.POST /auth/logoutclearsquizzivy_docstogether with the refresh cookie, so signing out also ends an open docs session. - The SPA's admin settings open the reference: the new tab is opened synchronously in the click,
openeris cleared, and only then is the session requested and the tab pointed at/docs. -
DOCS_PUBLIC=trueskips only the cookie check, for local development. The server refuses to start with it whenAPP_ENV=production; SRI, the page's CSP and the rate limit apply everywhere.
- One settings document for the installation (
app.organization_settings,docs/plan/20-data-model.md§51): profile (name1 to 120,shortName,website,contactEmail,frontDeskPhone,frontDeskHours,address; an optional text empty after trimming isnull), logos (light and dark), sign-in (google,password,forcePasswordChange,joinCodes,allowedGoogleDomains) and retention (keepAnswersMonths,keepFocusEventsMonths,anonymiseDisabledAfterMonths).GET /admin/settingsreads it andPUT /admin/settingsreplaces it, bothsystem.settings.write, Admin only. A save names theexpectedRevisionit read and answers409 STALE_WRITEwhen someone saved since; it writes anorganization.updatedaudit row in the same transaction, naming the changed fields and no values. A save that changes nothing writes nothing: the revision stays and no audit row is added. -
Field rules. The name and short name hold no line break, other control character or invisible (format) character. A website is a domain (
quizzivy.com) or anhttpsaddress, with no spaces and no credentials;http, other schemes and a bare word are refused. A contact email is a local part and a domain with a dot, as an address and nothing wrapped around it. -
The front door.
GET /public/organization(open,Cache-Control: public, max-age=300, 120 a minute and 2,000 an hour per address) returns only what the sign-in page prints: name, short name, website, contact, hours, address, the logo URLs, which of Google and password sign-in are on, and whether joining by code is on. It carries no counts, plan, domain list, retention value or revision. -
Logos.
POST /admin/settings/logo(multipart{file, variant}, 1 MiB, 20 an hour per signed-in user) checks and stores a logo content-addressed atbranding/{sha256}.png; it applies only when aPUTreferences it. The server accepts a PNG and nothing else: it is square within 2%, 256 to 2048 px a side, decoded under the shared image gate and encoded afresh, which drops its metadata. An SVG answers415 MEDIA_TYPE_UNSUPPORTED, because proving an SVG cheap to draw means resolving its references as three browsers do; the settings page instead draws the Admin's SVG to a 1024 x 1024 canvas in their browser and uploads the PNG (D-019).GET /public/organization/logoserves the bytes as an attachment undernosniff, immutable whenvis the current hash. A logo object that no setting names is removed by the daily housekeeping (branding_objects) once it is 24 hours old, so the save that names a fresh upload still finds it; identical bytes are one object. The database still admits the typeimage/svg+xml, a value the application never writes and reads as no logo. -
The switches. Password off answers
403 PASSWORD_SIGN_IN_DISABLEDto every password sign-in alike; Google off answers403 GOOGLE_SIGN_IN_DISABLEDto sign-in and linking; a non-empty domain list refuses a Google email outside it with403 GOOGLE_DOMAIN_NOT_ALLOWED; unlinking the last way in answers409 LAST_LOGIN_METHOD. "Force password change" decidesmustChangePasswordfor every temporary password issued afterwards, and leaves existing flags alone. Joining by code off makes/join/preview,POST /app/classes/joinand Google-with-a-code answer exactly as an unknown code does. -
Lockout. A save that would leave the Admin saving it, or every active built-in Admin, with no way in is refused with
422 SIGN_IN_POLICY_LOCKOUT, judged inside the save's transaction.POST /admin/settings/sign-in-impactpreviews the judgement and writes nothing. - Propagation. Each server keeps the document for at most 30 seconds and replaces it at once when it saved, so another server serves the old policy for up to 30 seconds after a change.
-
The settings page. System settings is
/admin/settings/:sectionin the Admin console: Organization (also the bare address), Sign-in, Data & privacy (data) and Plan & usage (plan); an address for any other section is replaced by the bare one. The sections edit one form over the whole document and share one unsaved-changes bar; they stay mounted, so an edit survives a visit to another, and leaving the page with unsaved changes asks first. Save changes sends the whole document at the revision the form was last reset from, never a newer one that a background read has seen, so a save made elsewhere answers409 STALE_WRITE: the bar shows the server's message with Reload, which reads the document again and replaces the form. A rule the server refuses (400 VALIDATION_FAILED) is shown on its field and opens the section that holds the first such field. Once a save succeeds the front door is read again past the browser's five minutes (GET /public/organizationwithcache: no-cache, then every query under its key is invalidated), so the sign-in page and the teacher join-code callout follow the setting at once. -
The Sign-in section. The four switches and the Google domains, typed as chips: Enter, a comma or a space adds one, which is kept lower-case, without a leading
@, once; Backspace in the empty input removes the last. While the edited policy differs from the saved one, and has a method on and only valid domains, the page asksPOST /admin/settings/sign-in-impacta moment after the last edit and warns "{n} người sẽ không đăng nhập được nữa." (with the Admins among them). A policy with no method, or one the server counts as locking out the caller or every Admin, pauses Save changes, which stays focusable and says why in the bar and under the card; a422 SIGN_IN_POLICY_LOCKOUTfrom a save is shown the same way until the policy changes. - The logo dialog. "Thay logo" takes a required logo for a light ground and an optional one for a dark ground, each a PNG of at most 1 MiB or an SVG, square within 2% and at least 256 x 256. A file the browser can tell is unusable is refused on its field before anything is sent; an SVG is drawn to a 1024 x 1024 canvas and sent as the PNG it exports (D-019); a refusal from the server shows its message on the field until another file is chosen. A stored logo shows in the Organization tile at once and applies when the settings are saved.
-
Data & privacy (T-R5.28b). The three retention choices join the one form: answers kept 1 year, 2 years or for ever; focus-loss events 6 months, 1 year, 13 months (the default) or 2 years; disabled accounts anonymised never, after 1 year or after 2. The nightly sweep applies them (§13.3). Saving a retention that keeps anything for less time first asks
POST /admin/settings/retention-impactwhat tonight's sweep would remove and lists it in a confirmation; a retention that keeps everything as long or longer saves without asking. "Export all data", beside the form and not part of it, asks (undersystem.data_export, which only the Admin holds) for a ZIP of the centre's data with or without the attempt answers (§15); the email that says it is ready links to/admin/exports/:id(§8). -
Plan & usage is read-only, from
GET /admin/usage: the plan and its renewal whenPLAN_NAMEis set, a meter each for seats, media storage, import storage and teachers (the figure alone where no limit is configured; no limit is enforced, DG-55), and the month's AI credits while the assistant is on.
The Admin console's Users screen (§8) reaches every account whatever its role, under people.users.manage; a role change also needs people.roles.manage and a transfer scope.all. The operations are in §15.
-
Reading.
listUsersis the table: every account, newest first, in five tabs (All; Teachers, everyone who is neither the built-in Admin nor a strict student target; Students, the strict student targets of §5.4; Admins, the built-in Admin role; Disabled), a search over the name and the email together without case or accents (users_search_trgm_idx, from three characters), an exactemail, and the status chip.getUseradds who created the account, up to 20 classes, what it owns, the tests it is taking, up to 8 recent events andcan, the guards' answer for this caller and this account, which the screen follows to show or hide each action. -
Creating.
createUseradds an account of any role the caller may give (the subset rule, §5.4; never the Assistant,422 ROLE_NOT_ASSIGNABLE), withdelivery:email_linkcreates it with no password and queues aninvitelink in the same transaction;temporary_passwordshows the password once and marks it for change unless the centre allows keeping it (§5.6). A student can be enrolled in a class at once, by a caller withscope.all. An address in use in any letter case is409 EMAIL_TAKEN. Names are read as NFC, trimmed, with white space collapsed, and refused when empty, over 200 characters or holding a control, format or filler character; an address that holds a space or an invisible character is refused by one rule in Add user, the student forms and the import. -
Ending access. A role change, a disable, a sign-out everywhere and each kind of reset revoke the account's refresh families and move its
session_epochin the transaction of the change, and forget its cached principal after it, so its next request is refused, or authorised by the new role, at once on this machine and within ten seconds on another. A disable also revokes its unused set-password links and fails the messages still queued for it; enabling it again restores none of them. A reset either sets a temporary password or clears the password and queues aresetlink, and ends the account's older links. The guards run in this order: the account exists (404), the subset rule (403 TARGET_ROLE_NOT_ALLOWED), not oneself (§5.4), the last Admin (409 LAST_ADMIN); a reset of a disabled account is409 ACCOUNT_DISABLED, and a reset or a temporary password while password sign-in is off422 PASSWORD_SIGN_IN_DISABLED. A role change to a role that opens no teacher workspace, for an account that owns classes, tests, questions, media or assignments, is409 USER_OWNS_CONTENTwith the counts. A write whose account or role kept changing under it is409 STALE_WRITEafter three tries, with nothing written. The bulk disable and reset (up to 100 accounts; 40 for temporary passwords) run one transaction per account and answer what was done and, per account refused, its code. Each write is audited (user.role_changed,user.disabled,user.enabled,user.signed_out_everywhere,user.password_reset), and a write that changes nothing writes no row. -
Transfer content (DG-56).
transferUserContentgives everything a disabled account owns, every class it teaches and every test, question, question group, media asset and assignment, deleted and archived ones too, to another active holder ofworkspace.teacher, in one transaction; who made each row stays. Unfinished Word imports stay with their creator, and the students the account created whom the recipient does not reach are reported as left behind. A live account is409 USER_NOT_DISABLED, a recipient that cannot take it422 INVALID_RECIPIENT. Withnotifythe recipient is told (content.transferred). -
Importing users (T-R5.14). A CSV or XLSX file of at most 5 MiB and 2,000 rows (
full_name,email,role,class) is previewed, then stored as a job with its accepted rows; the import worker creates the accounts 25 to a transaction, each audited asuser.created, re-reading the importer's rights from the database at every batch. An address that already has an account is skipped and never changed, and the file's text is not stored. Delivery is byemail_link(one invite per created account) or bysheet: the temporary passwords, sealed underSECRETS_KEY, are handed to the importer once as a CSV within 24 hours and erased in the same transaction, and erased unread when the 24 hours end. Every cell a spreadsheet would run as a formula is written with a leading', and an address of that kind is refused when the file is read. -
Activity and statuses (DG-54, T-R5.4). An account is active on a day when it makes an authenticated request that day: the principal resolver stamps
users.last_active_atand a row ofuser_activity_days, at most once per user every five minutes per instance and again at the first request of a new local day inAPP_TIMEZONE, written in batches every 30 seconds or at 200 entries, so an idle API writes nothing. A sign-in stampslast_sign_in_atand its method (password,googleorset_password_link), and a change of one's own passwordpassword_changed_at, which Settings shows as "Changed …". An account's status is the first ofdisabled,must_change_password,never_signed_in(last_sign_in_atis null) andactive; the Users table and the teacher's Students list (accountStatus,neverSignedIn) read it from the server, andgetAdminOverviewcounts by the same order.
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.
Generated from the repository's documentation on every push to develop.
Start here
Product
Implementation plan
- 00 Architecture overview
- 10 Phase 0: Scaffold and contract
- 11 Phase 1: Auth, join, shells
- 12 Phase 2: Admin authoring
- 13 Phase 3: Assign and take
- 14 Phase 4: Grade and results
- 15 Phase 5: Hardening
- 16 Phase 4.5: The student at a desk
- 17 Word import: milestone plan
- 18 Word import: teacher workflow
- 19 Versioned content contract
- 20 Data model
- 30 Risk register
- 40 Open items
- 50 Non-functional requirements
- 60 Backend architecture
- 91 September 2026 issue remediation
- 92 Student layouts and interaction rules
Redesign (Phase R)
- 70 Redesign overview
- 71 R1: Foundations (v0.7.0)
- 72 R2: Access (v0.8.0)
- 73 R3: Student console (v0.9.0)
- 74 R4: Teacher workspace (v0.10.0)
- 74d D4: Deck update (v0.11.0)
- 74e E1: Moving a question across a group
- 75 R5: Admin console and email (v0.12.0)
- 76 R6: Question types and scoring (v0.13.0)
- 77 R7: Collaboration (v0.14.0)
- 78 R8: Schedule (v0.15.0)
- 79 R9: Insights (v0.16.0)
- 80 R10: Learn (v0.17.0)
- 81 R11: Landing, leads, v1.0 (v1.0.0)
Setup and operations
- CI
- Deploying
- DNS for quizzivy.com
- Google OAuth client
- Production operations
- Cloudflare R2 bucket
- Word import worker
- R4 rehearsal on a Neon branch
Design
Team
- Agent team
- Verification environment
- Team ledger
- R4 readiness
- R4 verification strategy
- T-R4.51 browser verification
Code READMEs