Plan 11 Phase 1 - SekiroKenjii/Quizzivy GitHub Wiki
Deliverable (§16): password login, Google login, refresh + rotation, join-code flow end-to-end, guards, both layouts, settings.
Exit criteria — corrected. §16 states "E2E 2, 3, 4 pass". E2E 2 is
"Student logs in with password → starts → answers → reloads mid-test → answers
persist → submits → sees result", which requires test authoring (Phase 2), the
take-test engine (Phase 3) and the result page (Phase 4). It cannot pass here.
The phase exits on E2E 3, E2E 4, and a new E2E 2a — password login lands the
student on /app with an empty assignment list — which is the part of E2E 2 that
is actually in scope. E2E 2 in full becomes a Phase 4 exit criterion.
Ordered so the phase builds at every boundary: schema, then backend auth, then the public join surface, then the frontend that consumes it.
Depends on: T-0.15
Touches: migrations/
Size: M
Migrations: 00004_create_users.sql, 00005_create_refresh_tokens.sql,
00006_create_classes.sql, 00007_create_class_members.sql,
00008_create_audit_log.sql, 00009_grant_app_role.sql
Done when:
- Tables match
20-data-model.md§3–§5 exactly, including deviations D-08, D-09, D-10 and D-16 -
seed/creates one admin user and one class; never in a migration (§13.7) - Test:
migrate_test.goup/down/up still clean, and mutation-tested — removing oneDROP TABLEfrom aDownmust fail it - That round trip is opt-in (
TEST_DESTRUCTIVE=1, set in CI). It drops schemaapp, so leaving it on by default meantgo test ./...silently wiped a developer's seeded database with no hint as to why - Constraint tests build their fixtures with generated ids, never
hard-coded ones. The first version used fixed uuids and every case broke
the moment
make seedran — a suite that assumes an empty database only passes on a machine nobody has used - Test:
server/internal/db/constraints_test.go— inserting a user withmust_change_password = trueandpassword_hash = NULLis rejected (D-16) - Test:
constraints_test.go— two users differing only in email case collide onusers_email_lower_key -
00009grantsquizzivy_appDML and setsALTER DEFAULT PRIVILEGESso every later table is covered automatically. It was planned for Phase 3; that left the app role unable to read anything until then - Test: connected as
quizzivy_app,UPDATE app.audit_logis refused — §13.4's append-only claim as a privilege, not a promise - DDL reviewed against §13 and the Neon skill (§14)
Depends on: T-1.1, T-0.14
Touches: server/internal/auth/
Size: M
Done when:
-
POST /auth/loginverifies against Argon2id (§13.5) - A disabled user (
disabled_at IS NOT NULL) is rejected with the same generic message and the same timing as a wrong password - Response is
{ accessToken, user }per §15; the JWT is ~15 minutes (§5.2) - Failed logins are rate-limited per IP and per email
- Test:
auth/password_test.go— correct password succeeds, wrong password fails, and both take comparable wall time - Test:
auth/login_test.go— a disabled account cannot log in - Public endpoint: rate-limited and leak-reviewed — the response distinguishes neither "no such user" nor "wrong password" (§6.5, §14)
Depends on: T-1.2
Touches: server/internal/auth/
Size: M
Done when:
-
POST /auth/refreshreads the opaque token from the cookie, rotates it, and sets the replacement (§5.2) - Cookie is
httpOnly; Secure; SameSite=Lax; Path=/auth, host-only — noDomainattribute (00-overview.md§4.1) - Tokens are stored SHA-256 hashed, and looked up BY that hash (§13.5) — this checkbox previously said "compared in constant time", which misquotes §13.5: that phrase belongs to the JOIN CODE bullet. A join code is short and human-typed; a refresh token is 256 bits from a CSPRNG, and the lookup is a b-tree probe an attacker cannot aim without already holding a candidate. No Go-side compare is added, because comparing a value against the row it was used to find would assert nothing
- Presenting an already-rotated token revokes the whole family and forces re-login (§5.2)
-
POST /auth/logoutrevokes the presented token's family - An expired-token cleanup path exists, runs at startup and daily, and
prunes by family — never individual rows.
replaced_byisON DELETE SET NULL, and rotation reads it to tell a replayed token from a wholesale-revoked one; deleting a successor nulls its predecessor's link and silently downgrades reuse detection on it. Latent today (a successor outlives its predecessor), live the momentREFRESH_TOKEN_TTLis reduced. This means the scan does not userefresh_tokens_expiry_idx— the index the original checkbox named. Correctness over the index, at this size - Test:
auth/rotation_test.go— "replaying a rotated token revokes every token in the family" - Test:
auth/rotation_test.go— "rotation issues a token in the same family withreplaced_byset on the predecessor" - Test:
auth/cookie_test.go— asserts the exactSet-Cookieattributes, including the absence ofDomain -
POST /auth/logoutis authenticated by the refresh COOKIE, not the access token: an expired access token must not be able to strand a live refresh family - Contract additions:
Set-Cookieon/auth/refresh200 (rotation cannot deliver its replacement without it) and on/auth/logout204 - Test:
auth/rotation_test.go— concurrent refreshes of one token elect exactly one winner. The row lock is the guarantee; nothing else asserts it - Public endpoint: rate-limited and leak-reviewed (§14)
Depends on: T-1.3
Touches: server/internal/auth/
Size: S
Done when:
-
GET /auth/mereturns §7'sUser, includinghasPassword,linkedProvidersandmustChangePassword -
POST /auth/change-passwordrequires the current password, clearsmust_change_password, and revokes all other refresh families for that user - Test:
auth/me_test.go— a Google-only user reportshasPassword: false,linkedProviders: ['google'] - Test:
auth/change_password_test.go— a second device's refresh token is dead after a password change - Bearer authentication middleware. T-1.4 is where access tokens start
being CHECKED; before it,
/auth/loginminted them and nothing verified one. Driven from the contract, not a list in Go: an operation inherits the document's top-levelsecurityunless it overrides it, so a new endpoint is protected by default. Fail-CLOSED — anything not explicitly open needs a token, because a route missing from a protected-list serves data silently while one missing from an open-list returns a visible 401 - Test: exactly five operations are reachable without an access token
(
login,google,refresh,logout,join/preview). The list is pinned so opening a sixth takes an argument, not an unreviewed diff - A wrong
currentPasswordreturns 400, not 401.client.tstreats 401 as a dead session: refresh once, retry, sign out on the second. A 401 here means mistyping your own password silently logs you out - Contract:
UNAUTHORIZEDadded to theErrorCodeenum. It had none, so 401s were being sent with codeFORBIDDEN— the client could not tell "log in" from "you may not" except by reading the status line
Depends on: T-1.4
Touches: server/internal/httpx/, server/internal/api/router.go
Size: S
Added during T-1.4, not in the original plan. oapi-codegen generates types
and binds JSON; it enforces none of the contract's constraints. minLength,
format, enum, required and additionalProperties were all decorative on
the server side, so POST /auth/login accepted an empty password and a
{"email": "banana"}. Every one of the 60 remaining endpoints would have had to
restate its own rules in Go, or silently have none.
Done when:
-
nethttp-middleware'sOapiRequestValidatorruns on every generated route, driven by the same embedded spec the rest of the router uses - Failures return the §7 envelope with
VALIDATION_FAILED, notkin-openapi's default — which is English, unreadable, and quotes the failing schema back at an anonymous caller - Authentication runs before validation: an anonymous caller is told to log in, not handed a critique of their request body
-
spec.Serversis cleared for the validator. Left set, it validates theHostheader against the production URLs and rejects every request in development and in tests - Authentication is not delegated to the validator. It would enforce the
security requirements it can see, knows nothing about our tokens, and
would need a second copy of
RequireAuth - Middleware is written in execution order.
oapi-codegenapplies the slice so the LAST entry runs FIRST; the chain had been reading backwards, with two comments describing an order that was not the real one.NewRouterreverses explicitly and a test pins the direction - Test: contract constraints rejected — short password, bad email, unknown field, wrong type, unparseable body, malformed uuid path parameter
- Test: a well-formed request still reaches its handler
- Test: validation messages do not echo the schema
Depends on: T-1.4, T-0.2
Touches: server/internal/auth/google/
Size: L
Done when:
- No GIS SDK. Per O-13 the frontend builds the authorization request
itself: CSPRNG
code_verifier, S256code_challenge, astatevalue checked on return, popup to Google'sauthorization_endpoint. Approved deviation from §2 — do not reintroducegsi/client -
stateis single-use and compared in constant time; a mismatch aborts before any network call to our backend -
POST /auth/googleexchanges{ code, codeVerifier, redirectUri }with Google server-side; the client secret never leaves the backend (§5.3) - ID token verified for
iss,aud,expand signature via JWKS, with the JWKS response cached and refreshed on unknownkid -
email_verified: falseis rejected outright — no link, no create (§5.1). This closes an account-takeover path and is not negotiable - Resolution order is exactly §5.3 step 4: identity exists → log in;
verified email matches a user → link identity, log in; no match with a
valid
joinCode→ create + enrol (T-1.7); no match, no join code →403 ACCOUNT_NOT_PROVISIONED - Test:
google/verify_test.go— a token with a wrongaudis rejected - Test:
google/verify_test.go—email_verified: falseis rejected even when the email matches an existing user - Test:
google/resolve_test.go— one case per branch of the §5.3 order - Public endpoint: rate-limited and leak-reviewed (§6.5, §14). Both buckets:
per-IP, and per-
joinCode— one code must not be usable to create accounts from a hundred addresses. A request without a join code is a plain sign-in and uses the per-IP bucket alone - Contract:
Set-Cookieon the 200. It was missing, exactly as it was on/auth/refresh; §5.3 step 5 requires the sign-in to set the refresh cookie, and without the header the generated response cannot carry it - Contract: the 403 also documents
ACCOUNT_DISABLEDandIDENTITY_ALREADY_LINKED. Both are named explicitly rather than hidden: the caller has just proved to Google that they control the address, so neither discloses anything they could not confirm themselves
Branch 3 was a seam until T-1.8, and is now closed. *join.Service
satisfies auth.SelfEnroller directly — the interface is declared in terms of
internal/join's types, so there is no adapter to keep in step. The dependency
runs one way (join knows nothing about auth) and §5.3's third branch genuinely
is "sign-in creates an enrolment", so auth depending on enrolment is the real
shape rather than a convenience. TestBranch3AJoinCodeCreatesAndEnrols now
asserts a real enrolment.
Depends on: T-1.1
Touches: server/internal/join/, server/internal/classes/
Size: M
Done when:
- Codes are 8 characters from
ABCDEFGHJKLMNPQRSTUVWXYZ23456789, drawn from a CSPRNG, never sequential and never derived from the class ID (§6.1) - Normalization accepts the code with or without the dash and in any case, before hashing
- Only
code_hashand a 4-charactercode_hintare stored; the plaintext is returned exactly once, fromPOST /admin/classes/:id/join-code(§13.3) - Rotation revokes the old code and issues a new one in one transaction,
so the
class_join_codes_one_activepartial unique cannot be violated -
DELETE /admin/classes/:id/join-coderevokes and setsself_join_enabled = false(§6.4) - Default expiry 30 days (§6.1); default
max_usesper40-open-items.md - Generation, rotation and revocation are written to
audit_log(§13.4) - Test:
join/code_test.go— "normalize" is idempotent acrossabcd-1234,ABCD1234,abcd 1234 - Test:
join/code_test.go— 100k generated codes contain no character from the ambiguous set0O1I. NotL— this checkbox said0O1IL, which contradicts §6.1's own alphabet:ABCDEFGHJKLMNPQRSTUVWXYZ23456789containsL. The spec's "(no 0/O, 1/I/L)" names the two confusion GROUPS, not a blocklist; from {1, I, L} it keeps one member, and with1andIgone there is nothing left forLto be mistaken for. Dropping it too would make the alphabet 31 characters — no longer a power of two, so uniform selection needs rejection sampling for no gain in legibility - Test:
join/rotate_test.go— after rotation the old code fails and existing members are untouched (§6.1) - Test:
join/rotate_test.go— six concurrent rotations of one class leave exactly one active code.class_join_codes_one_activeis a partial unique index: insert-then-revoke violates it, revoke-then-insert leaves a window with no code. The transaction plus a row lock on the class is what makes neither state observable - Role authorization. T-1.6 adds the first real
/adminendpoint, so/admin/*must stop being reachable by any authenticated student. Driven by the path prefix, because the path IS §3's route-tree structure — a per-operation annotation is a second thing to remember, and the cost of forgetting is a student reading every attempt in the school. The contract'sForbiddenresponse already documented this rule; nothing enforced it - Contract:
maxUsesis not nullable on the request, unlike the stored column. An omitted field and an explicitnullboth arrive as "no value", sonull = unlimitedwas indistinguishable from "use the default 40" and one of the two would silently never happen. Capped at 1000 instead
Depends on: T-1.6
Touches: server/internal/join/
Size: M
Done when:
- Returns only
classId,classNameandteacherName— never student names, never counts, never other IDs (§6.5) - Lookup is by hash of the normalized code with a constant-time comparison (§6.5); no plaintext equality anywhere
- Invalid, expired, exhausted and revoked each return a distinct error
codewith a plain message that reveals nothing about which classes exist (§9) - Rate limited per IP (10/min, 60/hour) and per code (30/hour), returning
429withRetry-After(§6.5) -
self_join_enabled = falsebehaves identically to an invalid code - Test:
join/preview_test.go— the response body contains no key other than the three permitted ones, asserted structurally - Test:
join/preview_test.go— the four failure modes produce four codes and none echoes the class name - Test: the 11th request in a minute from one IP is 429; the 31st for one
code is 429 even across different IPs. Lives in
api/join_preview_test.go, notjoin/ratelimit_test.go— the limiter is router middleware, and testing it needs the router - The per-code bucket keys on the NORMALIZED code. §6.1 accepts a code
with or without the dash and in any case, so
K7M3-P9QRandk7m3p9qrare one code — and keyed on the raw body value they are two buckets, which hands an attacker a fresh allowance for every spelling of the same secret. Same fix applies to thejoinCodebucket onPOST /auth/googleadded in T-1.5. A test walks five spellings and requires the 31st to be 429 - Note: because revoke ALSO closes self-join, and self-join is checked
first, a revoked code reports
JOIN_CODE_INVALID. The path that surfacesJOIN_CODE_REVOKEDis a rotation, which re-opens the class — which is the case that matters: a student holding last month's code is told to ask for the new one instead of being told they mistyped it - Public endpoint: rate-limited and leak-reviewed (§6.5, §14)
Depends on: T-1.5, T-1.7
Touches: server/internal/auth/google/, server/internal/join/
Size: M
Done when:
-
POST /auth/googlewith a validjoinCodeand no matching identity creates the user, creates the identity, and enrols in one transaction (§5.3, §6.3) -
class_membersrecordsjoined_via = 'join_code'and thejoin_code_id(D-10) -
uses_countincrements in the same transaction; theuses_count <= max_usesconstraint (D-09) makes an over-limit enrolment impossible - Every enrolment writes
class_id,user_id,ip,user_agent,attoaudit_log(§6.5) -
POST /app/classes/joinhandles the already-authenticated path (§6.2) and is idempotent when the student is already a member - Test:
join/enrol_test.go— concurrent enrolments against a code withmax_uses = 1produce exactly one member - Test:
join/enrol_test.go— an expired code creates no user (E2E 4's backend half) - Test:
join/audit_test.go— an enrolment writes exactly one audit row with a non-null ip - Public endpoint: rate-limited and leak-reviewed (§6.5, §14). All three code-redemption endpoints carry both buckets, keyed on the NORMALIZED code
-
uses_countincrements only for a NEW membership. Counting a repeat would let a student exhaust their own class's code by tapping the link twice - Refusals reuse
/join/preview's outcome taxonomy and its single error mapping, so the three redemption paths cannot drift into disagreeing about what a code's state means — or into leaking different amounts about it
Worth knowing, from the mutation check. Removing the FOR UPDATE row lock
on the code does not oversell the class: D-09's uses_count <= max_uses
CHECK refuses the extra rows. What the lock buys is the difference between a
clean JOIN_CODE_EXHAUSTED and a constraint violation surfacing as a 500. The
belt and the braces do different jobs, and both are load-bearing.
Depends on: T-1.5
Touches: server/internal/auth/
Size: S
Done when:
-
POST /auth/google/linklinks an identity to the current account, subject to the sameemail_verifiedrule (§5.1) -
DELETE /auth/google/linkis rejected if it would leave no login method — a Google-only account cannot unlink (§15) - Test:
auth/link_test.go— unlinking from a passwordless account returns a 409 with a specific error code - Test:
auth/link_test.go— linking a Google identity already bound to another user is rejected (D-08'sUNIQUE (provider, provider_user_id)) - Linking a Google address that is another account's email is refused.
Not in the original list, and it is about where the link LEADS rather than
where it starts: linking binds the
subto this account, and §5.3's first branch matches onsubbefore email — so the owner of that address signing in with their own Google would land in someone else's account. Silent, and very hard to diagnose from the inside - Re-linking the SAME Google account is a no-op success, not a conflict. The requested state already holds
-
email_verifiedis enforced on link exactly as on sign-in, and the reasoning is stronger here: the account is already chosen, so an unverified address is a takeover with the target picked out - Link and unlink are both written to
audit_log— they add and remove a way into an account
Depends on: T-0.12, T-1.4
Touches: web/src/features/auth/, web/src/app/guards/
Size: M
Done when:
- Zustand store holds the access token in memory only (§5.2)
- App load calls
GET /auth/me; 401 →/login(§5.4) -
RequireAuthredirects to/login?next=<path> -
RequireRole: anadminon/app/*redirects to/admin; astudenton/admin/*gets a 403 page, not a redirect — a redirect hides the misconfiguration (§5.4) -
mustChangePasswordforces/change-passwordfrom every route; Google-only users never reach it (§5.4) - Logout calls
POST /auth/logout, clears the store, callsqueryClient.clear(), and navigates to/login(§5.4) - Test:
guards.test.tsx— one case per rule above, including that the student-on-admin case renders 403 rather than navigating - Test:
auth/store.test.ts— the token is absent fromlocalStorageandsessionStorageafter login - All strings via
t()in both locales; keyboard-operable (§14)
Depends on: T-1.10
Touches: web/src/features/auth/pages/
Size: M
Done when:
- Password form with react-hook-form + zod, and a "Tiếp tục với Google" button (§9)
- Requests an authorization code with PKCE, not an implicit ID token
(§5.3). Without GIS — this checkbox said "GIS loaded lazily", which
contradicts O-13 and T-1.5's own "do not reintroduce
gsi/client".initCodeClientcannot send acode_challenge, which is the entire reason O-13 chose to own the authorization request -
code_verifierandstateare CSPRNG, S256, single-use, and held insessionStorageonly for the round trip. That is NOT a contradiction of §5.2's "never sessionStorage": the rule is about the ACCESS TOKEN, a durable bearer credential. A PKCE verifier is single-use, worthless without the matchingcode, and has to survive a redirect by definition - Popup with a redirect fallback. §1.1 puts students on phones, where popups are blocked often enough that a popup-only flow is a dead end
- Errors map from the envelope
codeto localized copy; the account-not- provisioned case explains that a class code is needed (§5.3) - Design follows §12:
zinc-900primary button, no gradient, no glow, no oversized radii. Split layout (shadcn's authentication example), not the "single centered card" this checkbox used to say — §12 asks for a centered card on join screens specifically, and /login is not one. The left panel carries the product name and one plain line; no invented testimonial - Test:
login.test.tsx— invalid email blocks submit; server error renders the localized message; the Google button is keyboard-reachable - Loading / error / empty states present; all strings via
t()(§14) - A zod schema with a type-level
Expect<Equal<…>>against the generated request type, per AGENTS.md. Verified by mutation — adding a field the contract lacks must failtsc -b -
?next=is treated as untrusted even though a guard set it. It arrives through the URL, so?next=https://evil.testwould make sign-in an open redirect;//evil.testis protocol-relative andstartsWith("/")alone accepts it - Post-sign-in destination is role-aware. Navigating to
/sends the user to the index route, which redirects anonymous visitors to/login— so a fresh sign-in lands back on the form it just completed
Depends on: T-1.11, T-1.7
Touches: web/src/features/join/
Size: L
Done when:
-
/join(enter code),/join/:code(deep link, prefilled), and/join/:code/confirm(class name + teacher name, one primary button) — §6.2's three steps - The confirm step is mandatory: no path creates an account and enrols in one blind tap (§6.2)
- Code input normalizes as the user types, accepts with or without the dash, and is case-insensitive (§6.1)
- Invalid / expired / exhausted render distinct plain messages that hint nothing about which classes exist (§9)
- An already-authenticated student on
/join/:codeskips toPOST /app/classes/join(§6.2) - Design per §12's join-screen rule: single centered card, class name large, calm and legitimate — this is the first thing a new student sees
- Test:
join/normalize.test.ts— the unit cases from §14 - Test:
join/confirm.test.tsx— the confirm step renders the class name before any auth call is made - E2E 3 passes: anonymous visitor opens
/join/:code→ sees class name → mocked GIS sign-in → account created, enrolled, lands on/app - E2E 4 passes: expired code → plain error, no account created, nothing leaked about the class
- Loading / error / empty states; keyboard-operable; both locales (§14)
- The E2E suite gained an API stub layer (
tests/e2e/support/api.ts). It runs a production build againstvite previewwith no backend — a deliberate boundary: these tests are about what the BROWSER does, and the server's behaviour is covered by Go tests against a real Postgres - E2E 3 drives the REAL PKCE round trip. Only Google is replaced, and the
stateis echoed from the authorization request rather than invented, so a broken state check fails the test rather than passing it
Two bugs T-1.10/T-1.11 introduced, both found only by E2E — neither is visible to a unit test, and both broke the flow for the exact person it exists for:
-
onSessionLostnavigated to/loginunconditionally. An anonymous student following a join link bootstraps withGET /auth/me, gets the 401 a visitor with no account is supposed to get, and was thrown off/joinbefore ever seeing which class invited them. Losing a session now only CLEARS state; the guards decide where anyone goes, and a public route has no guard. -
queryClient.clear()ran on that same 401 and wiped the join preview mid-flight, leaving the component pending forever — "Đang tải…" and nothing else. The cache clear now requires a session to have existed, which is the only case it was ever for (§5.4: one user's data must not reach the next).
Depends on: T-1.12, T-1.6
Touches: web/src/features/classes/
Size: M
Done when:
-
/admin/classes/:idshows the active code with a copy button, QR code, expiry and uses count (§6.4) - The plaintext code is displayed only in the response to a rotation;
thereafter the panel shows
code_hintand a "rotate to get a new code" affordance (§13.3) - Rotate has the §6.4 confirm dialog: "Mã cũ sẽ ngừng hoạt động ngay"
- A "Disable self-join" toggle revokes without issuing a new code (§6.4)
- Member list shows
joined_viaandjoined_at; remove-member revokes access and retains attempts (§6.4) - The QR library is either avoided (canvas-drawn) or justified in the PR description as a new dependency (§14)
- Test:
classes/join-code-panel.test.tsx— after mount without a rotation, no full code appears in the DOM - Test:
classes/rotate.test.tsx— the confirm dialog blocks rotation until accepted - Loading / error / empty states; both locales; keyboard-operable (§14)
Scope the task did not name. T-1.13's "Touches" listed only
web/src/features/classes/, but its Done-when needs GET /admin/classes/{id}
(with the code metadata), GET .../members and DELETE .../members/{userId} —
none of which existed, and no Phase 1 task built them. The panel cannot exist
without them, so internal/classes/ was built here. ListClasses came too,
because the panel is otherwise unreachable. CreateClass, UpdateClass and
AddClassMember remain stubs: nothing in Phase 1 needs them.
New dependency: qrcode.react (4 KB installed). §6.2 puts the code on a QR
for a whiteboard or a message, and a QR encoder is Reed–Solomon over GF(256) —
several hundred lines of exactly the code that is wrong in ways you find later.
It lands in the admin chunk, which router-chunks.test.ts keeps out of the
entry bundle.
Depends on: T-1.13, T-1.9
Touches: web/src/features/auth/, web/src/app/
Size: M
Done when:
-
/admin/settings— profile, password, link/unlink Google, language (§8) -
/app/settings— password, link/unlink Google, language (§9) -
/change-passwordhandles themustChangePasswordredirect target - Unlink is disabled with an explanation when it would leave no login method
- E2E 2a passes: student logs in with password → lands on
/app→ the three §9 sections render their empty states - E2E 3 and E2E 4 still pass — Phase 1 exit criteria met
-
release/phase-1merges tomainand back todevelop; the phase is deployable (§16)
Exit criteria met. E2E 2a, E2E 3 and E2E 4 pass (14 E2E in total), and the
phase builds: pnpm build produces a bundle with the admin tree in its own
chunk, and both Go binaries compile against migrations 00001–00009.
What Phase 1 turned out to include that the plan did not say:
| Added | Why it could not wait |
|---|---|
00009_grant_app_role.sql (T-1.2) |
The app role had no privileges at all until Phase 3 |
| Bearer authentication (T-1.4) | Access tokens were minted from T-1.2 and nothing verified one |
| Request validation (T-1.4b) | Every minLength in the contract was decorative server-side |
| Role authorization (T-1.6) | The first real /admin endpoint hands out join codes |
internal/classes (T-1.13) |
The §6.4 panel needs three endpoints no task built |
| Argon2id concurrency bound | 8 simultaneous logins exceed the 512 MB machine (R-13) |
Still stubs, deliberately: CreateClass, UpdateClass, AddClassMember,
and everything from Phase 2 onward — 50 operations. Nothing in Phase 1 needs
them, and the 501 is honest.
Carried into Phase 2: the teacher cannot create a class from the UI, only use the seeded one. That is Phase 2's own class-management work; it is not a Phase 1 gap, but it does mean a fresh deployment needs a class seeded before the join flow has anything to join.
Generated from docs/plan/11-phase-1.md. Edit that file in the repository; changes made on this page are overwritten.