From 0c6bf97d1939437bd63e7f3107995b811cf3a7cd Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:43:10 -0400 Subject: [PATCH 01/79] docs: define live classroom management contract --- api/AGENTS.md | 25 ++ docs/classroom-api.md | 17 ++ docs/classroom-live-management.md | 481 ++++++++++++++++++++++++++++++ docs/classroom-roadmap.md | 2 +- docs/classroom-workstream.md | 20 ++ 5 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 docs/classroom-live-management.md diff --git a/api/AGENTS.md b/api/AGENTS.md index 217b9e4..70f1bc9 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -90,3 +90,28 @@ Classroom API changes must preserve automated coverage for: - legacy Standalone collaboration behavior. - coaching class scoping, independent feedback revisions, Student read-only access, and zero Student snapshot-revision changes. - protected Case Study Instructor class scoping, Student membership-bound access, rejection of legacy collaboration capabilities, metadata-only catalogs, POST-only payload selection, and private response headers. + + +## #312 live-class capability direction + +Read `docs/classroom-live-management.md` before implementing #312. + +The new capability chain is intentionally different from the legacy two-code admission path: + +```text +human join code (admission locator only) + -> high-entropy Student class-session capability + -> own assignment-status API only + -> assignment-specific classroom-student workspace capability + -> existing collaboration edit APIs +``` + +Rules: +- never accept the human join code on workspace/observer/coaching/resource APIs; +- never accept the Student class-session capability on collaboration edit APIs; +- unassigned participants have no workspace edit capability; +- on assign/reassign/unassign, revoke the previous workspace-access capability before destination access is issued; +- never remap one still-active edit token from an old workspace to a new workspace; +- no assignment operation copies or merges Intake snapshots; +- keep legacy `/api/classes/join` and `classroom_memberships` working during the additive migration; +- Student status endpoints return only that Student's assignment; they never enumerate classmates/workspaces. diff --git a/docs/classroom-api.md b/docs/classroom-api.md index e725723..954ec3f 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -290,3 +290,20 @@ No classroom credential is stored in browser Intake state. The repository test-change guard treats `api/` as runtime code, so future server changes require test changes. For current execution state, read `docs/classroom-workstream.md`. + + +## #312 live-class management contract + +The next-generation live-class contract is defined in `docs/classroom-live-management.md`. + +Key direction: +- the human-facing join code is an admission locator, not a workspace bearer capability; +- admitted Students receive a stable high-entropy **class-session** capability; +- Students may exist in a waiting/unassigned state; +- Instructor assignment/reassignment is server-authoritative; +- editable workspace capabilities are assignment-specific and revoked on move/unassign; +- the Student class-session capability is never accepted by collaboration edit endpoints; +- destination workspace state wins on reassignment; no automatic Intake merge occurs; +- legacy two-code admission remains supported during the additive migration. + +Planned new endpoints are `POST /api/classes/admit`, `GET/PATCH /api/classes/participants`, `GET /api/classes/student`, and `POST /api/classes/student/access`. Existing `POST /api/classes/join` remains a compatibility path until a later explicit migration. diff --git a/docs/classroom-live-management.md b/docs/classroom-live-management.md new file mode 100644 index 0000000..0764214 --- /dev/null +++ b/docs/classroom-live-management.md @@ -0,0 +1,481 @@ +# Classroom Live Management Architecture + +## Purpose + +This is the implementation contract for #312, the first post-foundation Classroom slice. + +The #288 foundation proved secure Student/Instructor roles, collaboration, observation, coaching, protected Case Studies, and required real-browser CI. #312 changes the **operating model** from capability-oriented setup to an Instructor-run live class: + +```text +Instructor starts class + -> product shows one human-friendly Student join code + -> Students enter the class and may wait unassigned + -> Instructor creates teams / individual workspaces + -> Instructor assigns or reassigns Students live + -> Student automatically reconnects to the assigned workspace + -> Instructor observes/coaches teams from one dashboard +``` + +#313 staged Case Study simulation builds on this substrate. Do not implement staged exercise state inside #312. + +## Product invariants + +1. **Start Class is the normal Instructor path.** A normal Instructor should not have to invent or type an Instructor bearer capability. +2. **Students type one human join code plus a display name.** The current class-capability + assignment-capability UI is transitional. +3. **The human join code is not workspace authorization.** It may admit a participant into one class, but cannot enumerate the class or edit an Intake. +4. **An admitted Student may be unassigned.** Late arrivals must be able to wait safely while a class/exercise is already running. +5. **Instructor assignment is server-authoritative.** Moving a Student changes the workspace they are authorized to edit. +6. **Reassignment never merges Intake snapshots.** The destination team's current Intake is authoritative; the old team's Intake stays with the old team. +7. **Old edit authority stops on reassignment.** A Student must not retain indefinite write access to a previous team. +8. **Instructor observation remains read-only.** Team management does not route Instructor authority through editable collaboration aliases. +9. **Coaching stays workspace-scoped.** Existing feedback remains attached to the workspace/reasoning it reviewed; reassignment does not silently copy it. +10. **Student credentials cannot enumerate classmates, teams, or other workspaces.** +11. **Standalone remains backend-optional and unchanged.** +12. **Legacy Classroom sessions remain compatible during migration.** Do not break existing saved Student/Instructor sessions in the first #312 server tranche. + +## Why one stable workspace edit token is unsafe + +Do **not** implement reassignment by simply moving one active Student edit token from Workspace A to Workspace B. + +An old in-flight PUT may have been created from Workspace A state. If the same token suddenly resolves to Workspace B, that stale request could overwrite Team B with Team A's snapshot. + +The safe contract is: + +- Student **class session** capability is stable across assignment changes and is not accepted by collaboration edit endpoints. +- Student **workspace edit** capability is assignment-specific. +- Instructor reassignment revokes the old workspace edit capability before a new destination capability can be used. +- The Student obtains a fresh workspace capability after observing the new assignment. +- A late old request can therefore affect only its old workspace before revocation, or fail after revocation. It can never be reinterpreted as a write to the destination workspace. + +## Human-facing join code + +### Format + +Target an 8-character unambiguous uppercase code, displayed in two groups such as: + +```text +K7FM-P4Q2 +``` + +Use an alphabet that avoids easily confused characters such as `0/O` and `1/I`. Normalize case and separators on input. + +The exact alphabet is an implementation detail; the security properties are not: + +- code is random and class-scoped; +- code is unique among active classes; +- code expires/revokes with the class; +- code can be rotated by the Instructor; +- joins can still be globally enabled/disabled per class. + +### Security classification + +The join code is an **admission locator**, not a privileged bearer capability. + +It may be stored server-side in normalized form so the authorized Instructor dashboard can redisplay it. Possession of the code can at most attempt class admission; it cannot: + +- list participants; +- list workspaces; +- observe Student Intake; +- retrieve protected Case Studies; +- write coaching; +- edit a workspace. + +Public exposure may eventually justify rate limiting / abuse controls. That operational hardening is compatible with this contract. + +## Capability model + +### Instructor capability + +High-entropy bearer returned once by class creation and persisted only in the Instructor's separate same-device Classroom session envelope. + +Authority: +- administer exactly one class; +- read join code; +- list participants/workspaces; +- create/rename workspaces; +- assign/reassign/unassign participants; +- use existing observer/coaching/protected-teaching APIs. + +It is never an editable collaboration alias. + +### Student class-session capability + +High-entropy bearer minted at admission. + +Authority: +- read only that participant's class/assignment status; +- request a workspace access capability for the participant's **current** assignment; +- leave/revoke own class session if implemented. + +It cannot enumerate participants or workspaces and is not accepted by `/api/workspaces/*`. + +The Student client persists this token for same-device class resume. + +### Student workspace capability + +High-entropy, assignment-specific editable collaboration alias using the existing `classroom-student` capability kind. + +Authority: +- existing collaboration session/presence APIs for exactly one current workspace; +- existing Student coaching/protected Case Study reads that resolve active membership. + +On reassignment/unassignment, this capability is revoked before the Student can receive destination edit access. + +## Additive persistence model + +Do not mutate the legacy two-code membership contract destructively in the first tranche. Add the live-class model beside it, then move the UI after server behavior is proven. + +### `classroom_classes` + +Add a human-facing join code field, conceptually: + +```text +student_join_code VARCHAR(...) UNIQUE +``` + +The existing high-entropy `student_join_token_hash` remains temporarily for the legacy `/api/classes/join` path. + +### `classroom_participants` + +New canonical live-class admission record: + +```text +class_id BIGINT +participant_id UUID +display_name VARCHAR(60) +session_token_hash CHAR(64) UNIQUE +workspace_id BIGINT NULL +workspace_access_token_hash CHAR(64) UNIQUE NULL +assignment_revision INTEGER NOT NULL DEFAULT 0 +joined_at TIMESTAMPTZ +updated_at TIMESTAMPTZ +assigned_at TIMESTAMPTZ NULL +revoked_at TIMESTAMPTZ NULL + +PRIMARY KEY (class_id, participant_id) +FOREIGN KEY (class_id, workspace_id) + -> classroom_workspaces(class_id, workspace_id) +``` + +Interpretation: + +- `workspace_id IS NULL` = waiting/unassigned; +- `assignment_revision` increments on every assign, reassign, or unassign; +- `session_token_hash` authorizes only Student class-session APIs; +- `workspace_access_token_hash` records the currently issued assignment-specific collaboration capability, if any; +- raw capabilities are never stored. + +Existing `classroom_memberships` remains valid for legacy sessions until a later explicit migration/removal. + +## API contract + +Names below are the intended #312 contract. Keep route files thin and handlers injectable. + +### Existing `POST /api/classes` — Start Class + +Normal Instructor Start Class calls the existing create endpoint with a title. + +New response adds a human join code: + +```json +{ + "class": { "id": "...", "title": "...", "joinsEnabled": true, "expiresAt": "..." }, + "instructorToken": "", + "joinCode": "K7FM-P4Q2" +} +``` + +During compatibility migration, the server may also return the existing legacy `studentJoinToken`; the new Instructor UI must not depend on or present it. + +Instructor client: +- persists `instructorToken` in the existing Instructor session envelope; +- immediately enters the dashboard; +- displays/copies the Student join code; +- supports join-code rotation and joins on/off. + +### `POST /api/classes/admit` — Student admission + +No Instructor/workspace capability is accepted. + +Request: + +```json +{ + "joinCode": "K7FM-P4Q2", + "participantId": "", + "displayName": "Alex" +} +``` + +Success returns only the represented Student's class/session context: + +```json +{ + "class": { "id": "...", "title": "...", "expiresAt": "..." }, + "participant": { + "id": "...", + "displayName": "Alex", + "assignmentRevision": 0 + }, + "assignment": null, + "studentSessionToken": "" +} +``` + +Admission creates the waiting participant immediately. Re-admission by the same class + participant ID may update display name and rotate that participant's Student session capability while preserving current assignment. + +Failures remain generic enough to avoid class enumeration. + +### `GET /api/classes/participants` — Instructor roster + +Instructor capability only. + +Returns admitted participants including unassigned/late arrivals and current assignment metadata, but no raw capability values. + +Representative record: + +```json +{ + "id": "...", + "displayName": "Alex", + "joinedAt": "...", + "assignmentRevision": 3, + "assignment": { + "id": "...", + "kind": "group", + "label": "Team B" + }, + "activity": { + "state": "editing", + "lastSeenAt": "..." + } +} +``` + +Activity is derived from the currently assigned collaboration workspace when available. + +### `PATCH /api/classes/participants` — assign / reassign / unassign + +Instructor capability only. + +Request: + +```json +{ + "participantId": "...", + "workspaceId": "..." +} +``` + +Use `workspaceId: null` to return a Student to the waiting/unassigned state. + +Server transaction/ordering requirements: + +1. prove participant and destination belong to the Instructor's class; +2. capture the old workspace + old workspace-access hash; +3. revoke the old `classroom-student` workspace capability; +4. remove stale presence from the old workspace; +5. update participant `workspace_id`; +6. clear `workspace_access_token_hash`; +7. increment `assignment_revision`; +8. commit the new assignment metadata. + +Do **not** copy or merge snapshots. + +### `GET /api/classes/student` — Student assignment status + +Requires Student class-session capability. + +Returns only that Student's own status: + +```json +{ + "class": { "id": "...", "title": "...", "expiresAt": "..." }, + "participant": { + "id": "...", + "displayName": "Alex", + "assignmentRevision": 3 + }, + "assignment": { + "id": "...", + "kind": "group", + "label": "Team B" + } +} +``` + +For waiting Students, `assignment` is `null`. + +A later optimization may support `afterAssignmentRevision` / 204 long-poll semantics. It must never add a workspace list. + +### `POST /api/classes/student/access` — obtain current workspace access + +Requires Student class-session capability. + +If unassigned, return a clear authenticated waiting/unassigned response. + +If assigned: + +1. generate a fresh high-entropy workspace capability; +2. create a `classroom-student` alias for the current workspace; +3. store only its hash as the participant's current `workspace_access_token_hash`; +4. revoke any prior active workspace-access capability; +5. return the raw token once with assignment metadata/revision. + +If a response is lost, retry may safely rotate again. No raw token needs server persistence. + +The Student client requests access: +- after initial assignment; +- after assignment revision changes; +- on resume when its locally cached workspace access is missing/stale. + +## Student client lifecycle + +### Waiting + +After admission: +- retain Student class-session capability; +- discard the human join code; +- show class/participant context and a waiting-for-assignment state; +- do not connect collaboration; +- poll only the Student assignment-status endpoint. + +### Assigned + +When assignment appears: +- request current workspace access; +- connect the existing collaboration controller with the returned assignment-specific token; +- show the normal Student Intake and class/team chrome. + +### Reassigned + +When `assignmentRevision` changes: + +1. stop/leave old collaboration locally; +2. discard old local workspace access token; +3. request current workspace access; +4. connect to destination workspace from a clean collaboration revision; +5. render destination snapshot as authoritative; +6. notify the learner that the Instructor moved them. + +Never merge old local/team Intake into the destination. + +### Unassigned after work has begun + +Disconnect collaboration and return to waiting state. Keep the Student class-session capability so the Instructor can assign them again without a new join code. + +### Terminal class/session failure + +Retain the existing safe recovery philosophy: clear invalid Classroom credentials and restore pre-class local Intake where appropriate. + +## Instructor experience contract + +### Start Class + +Instructor experience should offer: +- **Start a class** as the primary path; +- optional **Resume/Open existing class** as recovery/advanced path; +- title input; +- immediate dashboard after creation; +- prominent Student join code with copy/share affordance. + +A QR/share link can be added after the code contract is stable. If a join code is included in a URL later, prefer a client-side fragment or another design that avoids accidental referrer/log leakage. + +### Live roster and teams + +Dashboard should expose: +- Waiting / Unassigned; +- each team/individual workspace; +- participant count and names; +- active/editing state; +- coaching / needs-improvement / changed-since-review signals where available. + +Instructor can: +- create/rename workspaces; +- assign a waiting Student; +- move a Student between teams; +- unassign a Student; +- observe/coach the selected team. + +Drag-and-drop may be offered, but accessible buttons/keyboard interaction are the canonical contract. + +## Reassignment content policy + +When moving Student A from Team A to Team B: + +- Team A keeps its existing Intake unchanged; +- Team B keeps its existing Intake unchanged; +- Student A begins seeing/editing Team B after reconnect; +- Student A's local copy does not overwrite Team B; +- no automatic field merge is attempted; +- coaching records remain attached to the workspace they reviewed. + +This is intentionally predictable for a facilitated exercise. + +## Compatibility plan + +The first #312 server tranche is additive. + +Keep working: +- legacy `POST /api/classes/join`; +- legacy `classroom_memberships`; +- existing Student saved workspace sessions; +- existing Instructor bearer resume; +- current observation/coaching/protected-resource paths. + +New Student UI moves to admit/status/access only after: +- repository/API tests prove assignment/reassignment safety; +- new protected Case Study/coaching Student authorization accepts the new workspace-access path; +- browser fixture supports waiting/assign/reassign. + +Do not delete legacy tables/routes in #312 unless a separate migration explicitly proves no supported client depends on them. + +## Security tests required before UI cutover + +Server/API: +- join-code normalization and active-class uniqueness; +- invalid/expired/rotated join code rejected generically; +- join code cannot enumerate participants/workspaces; +- admitted unassigned Student cannot access collaboration; +- Student class-session token cannot access collaboration directly; +- Instructor lists only own class participants; +- cross-class workspace assignment rejected; +- assign -> workspace access works; +- reassign -> old workspace token fails and new destination token works; +- unassign -> old workspace token fails and no new access is issued; +- stale old-team request can never resolve as destination-team write; +- another Student's session token cannot fetch this Student's status; +- legacy two-code admission still passes; +- new workspace-access token still works with coaching and protected Case Study Student reads. + +Browser: +- Instructor starts class and sees one Student-facing code; +- Student joins and waits unassigned; +- late Student joins after class work begins; +- Instructor creates Team A / Team B and assigns Students; +- two Team A Students sync one Intake; Team B remains isolated; +- Instructor moves Student A -> B; Student reconnects to B and old A edit access stops; +- accessible non-drag assignment path works; +- Instructor observer/coaching continues across team changes. + +## Rollback boundary + +Because the first implementation is additive: +- old class/join/workspace APIs remain available; +- existing collaboration schema remains authoritative for Intake snapshots; +- new participant/join-code structures can be left unused if the new UI must be rolled back; +- Student UI should not cut over until new API tests and browser fixture are green. + +Schema/data cleanup, if ever required, is a later migration—not part of an emergency rollback. + +## Relationship to staged simulation + +#313 consumes this contract: + +- class exists and has a human Student join path; +- Instructor owns a live roster; +- teams/workspaces can change during class; +- Students have a stable class session independent of current workspace; +- assignment changes are observable and safe. + +Only after those are stable should #313 add exercise/stage state and progressive protected Case Study release. diff --git a/docs/classroom-roadmap.md b/docs/classroom-roadmap.md index 75df2b7..18b36e2 100644 --- a/docs/classroom-roadmap.md +++ b/docs/classroom-roadmap.md @@ -221,7 +221,7 @@ The eight-slice #288 program establishes the secure Classroom foundation; it doe - **#312 — Instructor-run live class, roster, and dynamic team management:** Instructor-created classes, one human-friendly Student join path, waiting/late roster, accessible team formation, live reassignment, automatic Student workspace switching, and high-signal team monitoring. - **#313 — Instructor-orchestrated case simulation and staged debrief:** staged protected Case Study release, case reference beside Intake, Instructor pause/debrief/advance controls, progressive disclosure, team progress, and server-gated future-stage / Instructor-only material. -#312 is the next product slice after #308 closes; #313 should build on #312 rather than bypassing the live-class/team-management substrate. +#312 is now active on `feature/classroom-live-management`. Its first tranche is architecture/server-contract work in `docs/classroom-live-management.md`: human join code, waiting participants, stable Student class sessions, assignment-specific workspace edit capability, and safe reassignment. #313 remains dependent on this substrate. ## Stacked PR rules diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 0ddf627..3545210 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -313,6 +313,26 @@ Continue #295 on draft PR #307 from the current branch head. - First required-gate validation on `c9b03f30...`: repository quality succeeded and browser regression reported **22 passed, 6 intentional skips**; CodeQL, Dependency Review, and Template Manifest Guard also green. - Exact next action: synchronize final docs/PR/issues, review the complete `main → #308` diff and unresolved review/security state, then make #308 ready for review/merge if the documented head remains green. After #308 / #296 / #279 close, begin #312 as the next product slice; #313 follows on the live-class/team-management substrate. +## Active #312 implementation + +- Issue: #312. +- Branch: `feature/classroom-live-management`. +- Base: `main` at #308 merge `6c58336ac1677764641c09eadb201abcad8eeac1`. +- First tranche: architecture + server contract only; no schema/runtime mutation until the admission/assignment/reassignment security contract is documented. +- Canonical design: `docs/classroom-live-management.md`. +- Decided model: + - Instructor normal path is **Start a class**; + - one human-friendly Student join code; + - join code is admission-only, not workspace authority; + - admission mints a stable high-entropy Student class-session capability; + - Students may wait unassigned; + - workspace edit capabilities are assignment-specific and revoked on move/unassign; + - Student polls only own assignment status and never receives workspace lists; + - reassignment never merges snapshots; destination team state wins; + - legacy two-code admission remains supported during additive migration. +- Critical race protection: never remap one active workspace edit token from Team A to Team B. Old edit capability is revoked; Student obtains fresh destination access after assignment revision changes. +- Exact next action after this architecture checkpoint: implement the additive join-code + `classroom_participants` schema and repository methods with API tests, while leaving the existing Student UI and legacy `/api/classes/join` path unchanged. + ## Approved Classroom follow-on direction The current #288 program establishes the secure Classroom foundation, but it is **not** the final Instructor-led teaching experience. From 99c31f2e9c59180ed297db96512b62b310692b4c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:55:15 -0400 Subject: [PATCH 02/79] feat: add live classroom participant persistence --- api/_classroom.js | 200 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 198 insertions(+), 2 deletions(-) diff --git a/api/_classroom.js b/api/_classroom.js index 91040c7..313c554 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -22,6 +22,8 @@ import { export const CLASS_TITLE_MAX_LENGTH = 120; export const CLASS_WORKSPACE_LABEL_MAX_LENGTH = 120; export const CLASS_WORKSPACE_KINDS = Object.freeze(['individual', 'group']); +export const CLASS_JOIN_CODE_LENGTH = 8; +export const CLASS_JOIN_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; export const DEFAULT_CLASS_EXPIRY_DAYS = 30; export const COACHING_STATUSES = Object.freeze(['meets-standard', 'needs-improvement']); export const COACHING_NOTE_MAX_LENGTH = 2000; @@ -84,6 +86,37 @@ export function validateClassroomId(value) { return validateParticipantId(value); } + +/** + * Normalize a human-facing live Classroom join code. + * + * Spaces and hyphens are presentation-only. Ambiguous characters such as + * 0/O and 1/I are deliberately excluded from the accepted alphabet. + * + * @param {unknown} value - Candidate join code. + * @returns {string|null} Canonical eight-character code or null. + */ +export function normalizeClassJoinCode(value) { + if (typeof value !== 'string') return null; + const compact = value.trim().toUpperCase().replace(/[\s-]+/gu, ''); + if (compact.length !== CLASS_JOIN_CODE_LENGTH) return null; + for (const character of compact) { + if (!CLASS_JOIN_CODE_ALPHABET.includes(character)) return null; + } + return compact; +} + +/** + * Format a normalized join code for human display. + * + * @param {unknown} value - Candidate join code. + * @returns {string|null} Grouped display form or null. + */ +export function formatClassJoinCode(value) { + const normalized = normalizeClassJoinCode(value); + return normalized ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : null; +} + /** * Normalize a stable coaching target identifier. * @@ -199,6 +232,11 @@ async function initializeClassroomRepository() { )`; await sql`CREATE INDEX IF NOT EXISTS classroom_classes_expiry_idx ON classroom_classes (expires_at)`; + await sql`ALTER TABLE classroom_classes + ADD COLUMN IF NOT EXISTS student_join_code VARCHAR(8)`; + await sql`CREATE UNIQUE INDEX IF NOT EXISTS classroom_classes_student_join_code_idx + ON classroom_classes (student_join_code) + WHERE student_join_code IS NOT NULL`; await sql`CREATE TABLE IF NOT EXISTS classroom_workspaces ( workspace_id BIGINT PRIMARY KEY REFERENCES collaboration_workspaces(id) ON DELETE CASCADE, @@ -216,6 +254,29 @@ async function initializeClassroomRepository() { await sql`CREATE INDEX IF NOT EXISTS classroom_workspaces_class_idx ON classroom_workspaces (class_id, created_at)`; + await sql`CREATE TABLE IF NOT EXISTS classroom_participants ( + class_id BIGINT NOT NULL REFERENCES classroom_classes(id) ON DELETE CASCADE, + participant_id UUID NOT NULL, + display_name VARCHAR(60) NOT NULL, + session_token_hash CHAR(64) UNIQUE NOT NULL, + workspace_id BIGINT, + workspace_access_token_hash CHAR(64) UNIQUE, + assignment_revision INTEGER NOT NULL DEFAULT 0 CHECK (assignment_revision >= 0), + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + PRIMARY KEY (class_id, participant_id), + FOREIGN KEY (class_id, workspace_id) + REFERENCES classroom_workspaces(class_id, workspace_id) + ON DELETE SET NULL + )`; + await sql`CREATE INDEX IF NOT EXISTS classroom_participants_class_idx + ON classroom_participants (class_id, joined_at)`; + await sql`CREATE INDEX IF NOT EXISTS classroom_participants_workspace_idx + ON classroom_participants (workspace_id) + WHERE workspace_id IS NOT NULL`; + await sql`CREATE TABLE IF NOT EXISTS classroom_memberships ( class_id BIGINT NOT NULL, workspace_id BIGINT NOT NULL, @@ -271,21 +332,156 @@ async function initializeClassroomRepository() { return rows[0] || null; } + async function classByJoinCode(joinCode) { + const rows = await sql`SELECT id AS internal_id, public_id AS id, title, + joins_enabled AS "joinsEnabled", expires_at AS "expiresAt" + FROM classroom_classes + WHERE student_join_code = ${joinCode} + AND joins_enabled = TRUE + AND revoked_at IS NULL + AND expires_at > NOW()`; + return rows[0] || null; + } + return { - async createClass({ publicId, title, instructorHash, studentJoinHash, expiryDays }) { + async createClass({ publicId, title, instructorHash, studentJoinHash, studentJoinCode = null, expiryDays }) { const rows = await sql`INSERT INTO classroom_classes - (public_id, title, instructor_token_hash, student_join_token_hash, expires_at) + (public_id, title, instructor_token_hash, student_join_token_hash, student_join_code, expires_at) VALUES ( ${publicId}::uuid, ${title}, ${instructorHash}, ${studentJoinHash}, + ${studentJoinCode}, NOW() + (${expiryDays} * INTERVAL '1 day') ) RETURNING public_id AS id, title, joins_enabled AS "joinsEnabled", expires_at AS "expiresAt"`; return rows[0]; }, + async getClassByJoinCode(joinCode) { + return classByJoinCode(joinCode); + }, + + async admitParticipant({ joinCode, participantId, displayName, sessionHash }) { + const classroom = await classByJoinCode(joinCode); + if (!classroom) return null; + + const rows = await sql`INSERT INTO classroom_participants + (class_id, participant_id, display_name, session_token_hash) + VALUES ( + ${classroom.internal_id}, + ${participantId}::uuid, + ${displayName}, + ${sessionHash} + ) + ON CONFLICT (class_id, participant_id) DO UPDATE + SET display_name = EXCLUDED.display_name, + session_token_hash = EXCLUDED.session_token_hash, + updated_at = NOW() + WHERE classroom_participants.revoked_at IS NULL + RETURNING + participant_id AS id, + display_name AS "displayName", + workspace_id AS "workspaceInternalId", + assignment_revision AS "assignmentRevision", + joined_at AS "joinedAt", + updated_at AS "updatedAt"`; + const participant = rows[0]; + if (!participant) return null; + + let assignment = null; + if (participant.workspaceInternalId) { + const assignments = await sql`SELECT + public_id AS id, + workspace_kind AS kind, + label + FROM classroom_workspaces + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${participant.workspaceInternalId} + AND revoked_at IS NULL`; + assignment = assignments[0] || null; + } + + return { classroom, participant, assignment }; + }, + + async getParticipantBySession(sessionHash) { + const rows = await sql`SELECT + c.id AS "classInternalId", + c.public_id AS "classId", + c.title AS "classTitle", + c.expires_at AS "classExpiresAt", + cp.participant_id AS id, + cp.display_name AS "displayName", + cp.workspace_id AS "workspaceInternalId", + cp.assignment_revision AS "assignmentRevision", + cp.joined_at AS "joinedAt", + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM classroom_participants cp + JOIN classroom_classes c ON c.id = cp.class_id + LEFT JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + AND cw.revoked_at IS NULL + WHERE cp.session_token_hash = ${sessionHash} + AND cp.revoked_at IS NULL + AND c.revoked_at IS NULL + AND c.expires_at > NOW()`; + const row = rows[0]; + if (!row) return null; + return { + classroom: { id: row.classId, title: row.classTitle, expiresAt: row.classExpiresAt }, + participant: { + id: row.id, + displayName: row.displayName, + assignmentRevision: row.assignmentRevision, + joinedAt: row.joinedAt + }, + assignment: row.workspaceId + ? { id: row.workspaceId, kind: row.workspaceKind, label: row.workspaceLabel } + : null, + internal: { classId: row.classInternalId, workspaceId: row.workspaceInternalId || null } + }; + }, + + async listParticipants(instructorHash) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + const participants = await sql`SELECT + cp.participant_id AS id, + cp.display_name AS "displayName", + cp.assignment_revision AS "assignmentRevision", + cp.joined_at AS "joinedAt", + cp.updated_at AS "updatedAt", + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM classroom_participants cp + LEFT JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + AND cw.revoked_at IS NULL + WHERE cp.class_id = ${classroom.internal_id} + AND cp.revoked_at IS NULL + ORDER BY cp.joined_at, cp.participant_id`; + return { + classroom, + participants: participants.map(participant => ({ + id: participant.id, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt, + assignment: participant.workspaceId + ? { id: participant.workspaceId, kind: participant.workspaceKind, label: participant.workspaceLabel } + : null + })) + }; + }, + async getClassByInstructor(instructorHash) { return classByInstructor(instructorHash); }, From fa6180ada0183dfc4b19790925e61deea60bdfbf Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:55:37 -0400 Subject: [PATCH 03/79] fix: preserve participant class on workspace deletion --- api/_classroom.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/_classroom.js b/api/_classroom.js index 313c554..b644d77 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -269,7 +269,7 @@ async function initializeClassroomRepository() { PRIMARY KEY (class_id, participant_id), FOREIGN KEY (class_id, workspace_id) REFERENCES classroom_workspaces(class_id, workspace_id) - ON DELETE SET NULL + ON DELETE SET NULL (workspace_id) )`; await sql`CREATE INDEX IF NOT EXISTS classroom_participants_class_idx ON classroom_participants (class_id, joined_at)`; From 5665637e6f550bebb861c73109e991b6e96a73c9 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:56:07 -0400 Subject: [PATCH 04/79] test: model live classroom participants in memory --- tests/helpers/classroom-test-repositories.mjs | 105 +++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 5be0650..7e9e96e 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -150,10 +150,14 @@ export function createClassroomRepository() { const classes = []; const workspaces = []; const memberships = new Map(); + const participants = new Map(); const coaching = new Map(); const activeByInstructor = hash => classes.find(item => item.instructorHash === hash && !item.revoked) || null; const activeByJoin = hash => classes.find(item => item.studentJoinHash === hash && item.joinsEnabled && !item.revoked) || null; + const activeByJoinCode = joinCode => classes.find(item => ( + item.studentJoinCode === joinCode && item.joinsEnabled && !item.revoked + )) || null; const publicClass = item => item ? { internal_id: item.internalId, id: item.id, @@ -166,14 +170,16 @@ export function createClassroomRepository() { classes, workspaces, memberships, + participants, coaching, - async createClass({ publicId, title, instructorHash, studentJoinHash }) { + async createClass({ publicId, title, instructorHash, studentJoinHash, studentJoinCode = null }) { const item = { internalId: nextInternalId++, id: publicId, title, instructorHash, studentJoinHash, + studentJoinCode, joinsEnabled: true, expiresAt: 'future', revoked: false @@ -184,6 +190,103 @@ export function createClassroomRepository() { async getClassByInstructor(hash) { return publicClass(activeByInstructor(hash)); }, + async getClassByJoinCode(joinCode) { + return publicClass(activeByJoinCode(joinCode)); + }, + async admitParticipant({ joinCode, participantId, displayName, sessionHash }) { + const item = activeByJoinCode(joinCode); + if (!item) return null; + const key = item.internalId + ':' + participantId; + const previous = participants.get(key); + if (previous?.revoked) return null; + const participant = { + classInternalId: item.internalId, + participantId, + displayName, + sessionHash, + workspaceId: previous?.workspaceId || null, + workspaceAccessHash: previous?.workspaceAccessHash || null, + assignmentRevision: previous?.assignmentRevision || 0, + joinedAt: previous?.joinedAt || 'joined', + updatedAt: 'updated', + revoked: false + }; + participants.set(key, participant); + const assignment = participant.workspaceId + ? workspaces.find(workspace => ( + workspace.classInternalId === item.internalId + && workspace.workspaceId === participant.workspaceId + && !workspace.revoked + )) + : null; + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt + }, + assignment: assignment + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null + }; + }, + async getParticipantBySession(sessionHash) { + const participant = [...participants.values()].find(value => value.sessionHash === sessionHash && !value.revoked); + if (!participant) return null; + const item = classes.find(candidate => candidate.internalId === participant.classInternalId && !candidate.revoked); + if (!item) return null; + const assignment = participant.workspaceId + ? workspaces.find(workspace => ( + workspace.classInternalId === item.internalId + && workspace.workspaceId === participant.workspaceId + && !workspace.revoked + )) + : null; + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt + }, + assignment: assignment + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null, + internal: { classId: item.internalId, workspaceId: participant.workspaceId || null } + }; + }, + async listParticipants(instructorHash) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + return { + classroom: publicClass(item), + participants: [...participants.values()] + .filter(participant => participant.classInternalId === item.internalId && !participant.revoked) + .map(participant => { + const assignment = participant.workspaceId + ? workspaces.find(workspace => ( + workspace.classInternalId === item.internalId + && workspace.workspaceId === participant.workspaceId + && !workspace.revoked + )) + : null; + return { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt, + assignment: assignment + ? { id: assignment.id, kind: assignment.kind, label: assignment.label } + : null + }; + }) + }; + }, async rotateStudentJoin(instructorHash, nextHash) { const item = activeByInstructor(instructorHash); if (!item) return null; From df3633d008bb7e352509e15c1964b7d27ebf441f Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:56:38 -0400 Subject: [PATCH 05/79] test: cover live join code and waiting roster primitives --- tests/classroom-api.unit.test.mjs | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/classroom-api.unit.test.mjs b/tests/classroom-api.unit.test.mjs index 8b0b243..eefc985 100644 --- a/tests/classroom-api.unit.test.mjs +++ b/tests/classroom-api.unit.test.mjs @@ -5,6 +5,8 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + CLASS_JOIN_CODE_ALPHABET, + CLASS_JOIN_CODE_LENGTH, CLASS_TITLE_MAX_LENGTH, CLASS_WORKSPACE_LABEL_MAX_LENGTH, COACHING_NOTE_MAX_LENGTH, @@ -14,6 +16,8 @@ import { classObserveHandler, classStudentCoachingHandler, classWorkspacesHandler, + formatClassJoinCode, + normalizeClassJoinCode, normalizeClassroomLabel, normalizeCoachingNote, normalizeCoachingStatus, @@ -48,6 +52,106 @@ test('classroom labels, kinds, and public IDs validate conservatively', () => { assert.equal(validateClassroomId('not-an-id'), false); }); +test('live Classroom join codes normalize to an unambiguous eight-character contract', () => { + assert.equal(CLASS_JOIN_CODE_LENGTH, 8); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('0'), false); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('O'), false); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('1'), false); + assert.equal(CLASS_JOIN_CODE_ALPHABET.includes('I'), false); + assert.equal(normalizeClassJoinCode(' k7fm-p4q2 '), 'K7FMP4Q2'); + assert.equal(formatClassJoinCode('k7fmp4q2'), 'K7FM-P4Q2'); + assert.equal(normalizeClassJoinCode('K7F0-P4Q2'), null); + assert.equal(normalizeClassJoinCode('too-short'), null); +}); + +test('live participant admission creates a waiting Student and rotates only the class-session capability', async () => { + const classrooms = createClassroomRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + const first = await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + + assert.equal(first.classroom.id, CLASS_A_ID); + assert.equal(first.participant.id, PARTICIPANT_A); + assert.equal(first.participant.displayName, 'Alex'); + assert.equal(first.participant.assignmentRevision, 0); + assert.equal(first.assignment, null); + + const firstSession = await classrooms.getParticipantBySession(testTokenHash('S')); + assert.equal(firstSession.classroom.id, CLASS_A_ID); + assert.equal(firstSession.assignment, null); + + const readmission = await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex Updated', + sessionHash: testTokenHash('T') + }); + + assert.equal(readmission.participant.displayName, 'Alex Updated'); + assert.equal(readmission.participant.assignmentRevision, 0); + assert.equal(await classrooms.getParticipantBySession(testTokenHash('S')), null); + assert.equal((await classrooms.getParticipantBySession(testTokenHash('T'))).participant.displayName, 'Alex Updated'); + + const roster = await classrooms.listParticipants(testTokenHash('A')); + assert.equal(roster.classroom.id, CLASS_A_ID); + assert.deepEqual(roster.participants.map(item => ({ + id: item.id, + assignment: item.assignment + })), [{ id: PARTICIPANT_A, assignment: null }]); +}); + +test('live participant admission is class-scoped and invalid join codes do not create roster entries', async () => { + const classrooms = createClassroomRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + await classrooms.createClass({ + publicId: CLASS_B_ID, + title: 'Class B', + instructorHash: testTokenHash('B'), + studentJoinHash: testTokenHash('D'), + studentJoinCode: 'M8RNQ5W3' + }); + + const denied = await classrooms.admitParticipant({ + joinCode: 'ZZZZZZZZ', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + assert.equal(denied, null); + + await classrooms.admitParticipant({ + joinCode: 'M8RNQ5W3', + participantId: PARTICIPANT_B, + displayName: 'Blair', + sessionHash: testTokenHash('T') + }); + + const classA = await classrooms.listParticipants(testTokenHash('A')); + const classB = await classrooms.listParticipants(testTokenHash('B')); + assert.deepEqual(classA.participants, []); + assert.deepEqual(classB.participants.map(item => item.id), [PARTICIPANT_B]); + assert.equal(await classrooms.listParticipants(testTokenHash('C')), null); +}); + test('class creation returns raw capabilities once and stores only hashes', async () => { const classrooms = createClassroomRepository(); const handler = classHandler({ From 104a736144f6311d8bcb14d8e185cef1c28fbca0 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:59:30 -0400 Subject: [PATCH 06/79] docs: checkpoint live classroom persistence tranche --- docs/classroom-live-management.md | 33 +++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/classroom-live-management.md b/docs/classroom-live-management.md index 0764214..6407ee8 100644 --- a/docs/classroom-live-management.md +++ b/docs/classroom-live-management.md @@ -167,6 +167,39 @@ Interpretation: Existing `classroom_memberships` remains valid for legacy sessions until a later explicit migration/removal. +## Implementation checkpoint — additive persistence + +Completed on #314 before any Student UI cutover: + +- human join-code helpers enforce the eight-character unambiguous alphabet and normalize display separators/case; +- `classroom_classes.student_join_code` is additive and uniquely indexed when present; +- new `classroom_participants` persists admitted live-class participants separately from legacy `classroom_memberships`; +- a participant may exist with `workspace_id = NULL` as a first-class waiting/unassigned state; +- Student class-session capability hashes are independent from assignment-specific workspace-access hashes; +- re-admitting the same participant may rotate the class-session capability and update display name without inventing a workspace assignment; +- Instructor-scoped participant listing and Student-session lookup repository primitives are in place; +- legacy two-code admission and `classroom_memberships` remain unchanged. + +Durable implementation commits: +- `99c31f2e9c59180ed297db96512b62b310692b4c` — additive schema/repository primitives; +- `fa6180ada0183dfc4b19790925e61deea60bdfbf` — participant composite FK preserves required class identity when a workspace is deleted; +- `5665637e6f550bebb861c73109e991b6e96a73c9` — deterministic in-memory participant/session model; +- `df3633d008bb7e352509e15c1964b7d27ebf441f` — focused join-code/waiting-roster/session-rotation tests. + +Validation on `df3633d...`: +- canonical repository quality: **241 tests, 1 intentional skip**; +- required Browser E2E: **22 passed, 6 intentional project-scoped skips, 0 failed**; +- CodeQL, Dependency Review, and Template Manifest Guard: green. + +**Exact next tranche:** implement the new live-class HTTP layer around these primitives: +- Start Class emits a generated human join code while preserving the legacy high-entropy join token for compatibility; +- `POST /api/classes/admit` creates/rotates a waiting Student class session; +- `GET /api/classes/student` returns only the represented Student's own assignment state; +- `POST /api/classes/student/access` issues assignment-specific edit authority only when assigned; +- `GET /api/classes/participants` gives only the Instructor their live roster. + +Do not implement assign/reassign UI or cut over the current Student client until these APIs and their authorization tests are green. + ## API contract Names below are the intended #312 contract. Keep route files thin and handlers injectable. From 8eed407c19f9f0034b518fead649bc6dbbf8f917 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:59:46 -0400 Subject: [PATCH 07/79] docs: advance live classroom cold-start checkpoint --- docs/classroom-workstream.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 3545210..427b79e 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -331,7 +331,14 @@ Continue #295 on draft PR #307 from the current branch head. - reassignment never merges snapshots; destination team state wins; - legacy two-code admission remains supported during additive migration. - Critical race protection: never remap one active workspace edit token from Team A to Team B. Old edit capability is revoked; Student obtains fresh destination access after assignment revision changes. -- Exact next action after this architecture checkpoint: implement the additive join-code + `classroom_participants` schema and repository methods with API tests, while leaving the existing Student UI and legacy `/api/classes/join` path unchanged. +- Persistence/repository checkpoint: + - `99c31f2e9c59180ed297db96512b62b310692b4c` adds the human join-code column and additive `classroom_participants` model plus live participant/session repository primitives; + - `fa6180ada0183dfc4b19790925e61deea60bdfbf` corrects the participant/workspace FK deletion behavior so required class identity is preserved; + - `5665637e6f550bebb861c73109e991b6e96a73c9` mirrors the model in the deterministic test repository; + - `df3633d008bb7e352509e15c1964b7d27ebf441f` adds focused join-code normalization, waiting-roster, class-scoping, and class-session-rotation tests. + - validation: canonical quality **241 tests / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. + - current Student UI and legacy `POST /api/classes/join` remain unchanged. +- Exact next action: implement the live-class HTTP admission/status/access layer on top of these primitives (Start Class join code, `POST /api/classes/admit`, Instructor participant roster, Student own-status, Student current-workspace access), with authorization tests before any UI cutover or assignment/reassignment UI. ## Approved Classroom follow-on direction From 746559e67620aab3457b6cda90e42168d5350dde Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:07:07 -0400 Subject: [PATCH 08/79] feat: add live class access primitives --- api/_classroom.js | 136 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 127 insertions(+), 9 deletions(-) diff --git a/api/_classroom.js b/api/_classroom.js index b644d77..9c8b814 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -6,7 +6,7 @@ * collaboration workspace engine; they never duplicate snapshot/revision state. */ -import { randomUUID } from 'node:crypto'; +import { randomInt, randomUUID } from 'node:crypto'; import { DISPLAY_NAME_MAX_LENGTH, generateWorkspaceToken, @@ -117,6 +117,19 @@ export function formatClassJoinCode(value) { return normalized ? `${normalized.slice(0, 4)}-${normalized.slice(4)}` : null; } +/** + * Generate a random normalized human-facing Classroom join code. + * + * @returns {string} Eight-character unambiguous code. + */ +export function generateClassJoinCode() { + let code = ''; + for (let index = 0; index < CLASS_JOIN_CODE_LENGTH; index += 1) { + code += CLASS_JOIN_CODE_ALPHABET[randomInt(CLASS_JOIN_CODE_ALPHABET.length)]; + } + return code; +} + /** * Normalize a stable coaching target identifier. * @@ -313,6 +326,7 @@ async function initializeClassroomRepository() { async function classByInstructor(tokenHash) { const rows = await sql`SELECT id AS internal_id, public_id AS id, title, + student_join_code AS "joinCode", joins_enabled AS "joinsEnabled", expires_at AS "expiresAt" FROM classroom_classes WHERE instructor_token_hash = ${tokenHash} @@ -482,6 +496,73 @@ async function initializeClassroomRepository() { }; }, + async issueParticipantWorkspaceAccess({ sessionHash, accessHash, workspaceRepository }) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + if (!context.internal.workspaceId || !context.assignment) { + return { ...context, waiting: true }; + } + + const assignments = await sql`SELECT + cw.workspace_id AS "internalId", + cw.public_id AS id, + cw.workspace_kind AS kind, + cw.label, + w.expires_at AS "expiresAt" + FROM classroom_workspaces cw + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cw.class_id = ${context.internal.classId} + AND cw.workspace_id = ${context.internal.workspaceId} + AND cw.revoked_at IS NULL + AND w.expires_at > NOW()`; + const assignment = assignments[0]; + if (!assignment) return null; + + const previousRows = await sql`SELECT workspace_access_token_hash AS "accessHash" + FROM classroom_participants + WHERE session_token_hash = ${sessionHash} + AND revoked_at IS NULL`; + const previousHash = previousRows[0]?.accessHash || null; + + const capabilityCreated = await workspaceRepository.createCapability( + assignment.internalId, + accessHash, + CLASSROOM_STUDENT_CAPABILITY_KIND, + context.classroom.expiresAt + ); + if (!capabilityCreated) throw new Error('Unable to create live Student workspace capability'); + + const updated = await sql`UPDATE classroom_participants + SET workspace_access_token_hash = ${accessHash}, + updated_at = NOW() + WHERE session_token_hash = ${sessionHash} + AND class_id = ${context.internal.classId} + AND workspace_id = ${context.internal.workspaceId} + AND assignment_revision = ${context.participant.assignmentRevision} + AND revoked_at IS NULL + RETURNING participant_id`; + if (!updated[0]) { + await workspaceRepository.revokeCapability(accessHash); + return null; + } + + if (previousHash && previousHash !== accessHash) { + await workspaceRepository.revokeCapability(previousHash); + } + + return { + classroom: context.classroom, + participant: context.participant, + assignment: { + id: assignment.id, + kind: assignment.kind, + label: assignment.label, + expiresAt: assignment.expiresAt + }, + waiting: false + }; + }, + async getClassByInstructor(instructorHash) { return classByInstructor(instructorHash); }, @@ -682,7 +763,7 @@ async function initializeClassroomRepository() { }, async getStudentContext(accessHash) { - const rows = await sql`SELECT + const liveRows = await sql`SELECT c.id AS "classInternalId", c.public_id AS "classId", c.title AS "classTitle", @@ -691,17 +772,42 @@ async function initializeClassroomRepository() { cw.public_id AS "workspaceId", cw.workspace_kind AS "workspaceKind", cw.label AS "workspaceLabel" - FROM classroom_memberships cm - JOIN classroom_classes c ON c.id = cm.class_id + FROM classroom_participants cp + JOIN classroom_classes c ON c.id = cp.class_id JOIN classroom_workspaces cw - ON cw.class_id = cm.class_id AND cw.workspace_id = cm.workspace_id + ON cw.class_id = cp.class_id AND cw.workspace_id = cp.workspace_id JOIN collaboration_workspaces w ON w.id = cw.workspace_id - WHERE cm.access_token_hash = ${accessHash} + WHERE cp.workspace_access_token_hash = ${accessHash} + AND cp.revoked_at IS NULL AND c.revoked_at IS NULL AND c.expires_at > NOW() AND cw.revoked_at IS NULL AND w.expires_at > NOW()`; - const scope = rows[0]; + let scope = liveRows[0]; + + if (!scope) { + const legacyRows = await sql`SELECT + c.id AS "classInternalId", + c.public_id AS "classId", + c.title AS "classTitle", + c.expires_at AS "classExpiresAt", + cw.workspace_id AS "workspaceInternalId", + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel" + FROM classroom_memberships cm + JOIN classroom_classes c ON c.id = cm.class_id + JOIN classroom_workspaces cw + ON cw.class_id = cm.class_id AND cw.workspace_id = cm.workspace_id + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cm.access_token_hash = ${accessHash} + AND c.revoked_at IS NULL + AND c.expires_at > NOW() + AND cw.revoked_at IS NULL + AND w.expires_at > NOW()`; + scope = legacyRows[0]; + } + if (!scope) return null; return { classroom: { id: scope.classId, title: scope.classTitle, expiresAt: scope.classExpiresAt }, @@ -901,6 +1007,7 @@ function requireBearer(req) { export function classHandler({ getRepository = getClassroomRepository, tokenFactory = generateWorkspaceToken, + joinCodeFactory = generateClassJoinCode, idFactory = randomUUID } = {}) { return async (req, res) => { @@ -916,14 +1023,22 @@ export function classHandler({ const repository = await getRepository(); const instructorToken = tokenFactory(); const studentJoinToken = tokenFactory(); + const joinCode = normalizeClassJoinCode(joinCodeFactory()); + if (!joinCode) throw new Error('Join-code generator returned an invalid code'); const classroom = await repository.createClass({ publicId: idFactory(), title, instructorHash: hashWorkspaceToken(instructorToken), studentJoinHash: hashWorkspaceToken(studentJoinToken), + studentJoinCode: joinCode, expiryDays: getClassExpiryDays() }); - return send(res, 201, { class: classroom, instructorToken, studentJoinToken }); + return send(res, 201, { + class: classroom, + instructorToken, + studentJoinToken, + joinCode: formatClassJoinCode(joinCode) + }); } const authorization = requireBearer(req); @@ -936,7 +1051,10 @@ export function classHandler({ if (req.method === 'GET') { const classroom = await repository.getClassByInstructor(instructorHash); return classroom - ? send(res, 200, { class: classroom }) + ? send(res, 200, { + class: classroom, + joinCode: formatClassJoinCode(classroom.joinCode) + }) : send(res, 404, { error: 'Class not found.' }); } From 1b81d4a4c80a7fe16cee5523d6c13811f51f20b3 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:07:41 -0400 Subject: [PATCH 09/79] test: support live student access in repository harness --- tests/helpers/classroom-test-repositories.mjs | 78 ++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 7e9e96e..fc88bff 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -163,7 +163,8 @@ export function createClassroomRepository() { id: item.id, title: item.title, joinsEnabled: item.joinsEnabled, - expiresAt: item.expiresAt + expiresAt: item.expiresAt, + joinCode: item.studentJoinCode || null } : null; return { @@ -287,6 +288,64 @@ export function createClassroomRepository() { }) }; }, + async issueParticipantWorkspaceAccess({ sessionHash, accessHash, workspaceRepository }) { + const participant = [...participants.values()].find(value => value.sessionHash === sessionHash && !value.revoked); + if (!participant) return null; + const item = classes.find(candidate => candidate.internalId === participant.classInternalId && !candidate.revoked); + if (!item) return null; + if (!participant.workspaceId) { + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt + }, + assignment: null, + internal: { classId: item.internalId, workspaceId: null }, + waiting: true + }; + } + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.workspaceId === participant.workspaceId + && !candidate.revoked + )); + if (!workspace) return null; + + if (!await workspaceRepository.createCapability( + workspace.workspaceId, + accessHash, + 'classroom-student', + item.expiresAt + )) throw new Error('Unable to create live capability'); + + const previous = participant.workspaceAccessHash; + participant.workspaceAccessHash = accessHash; + participant.updatedAt = 'updated'; + if (previous && previous !== accessHash) { + await workspaceRepository.revokeCapability(previous); + } + + return { + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt + }, + assignment: { + id: workspace.id, + kind: workspace.kind, + label: workspace.label, + expiresAt: item.expiresAt + }, + internal: { classId: item.internalId, workspaceId: workspace.workspaceId }, + waiting: false + }; + }, async rotateStudentJoin(instructorHash, nextHash) { const item = activeByInstructor(instructorHash); if (!item) return null; @@ -414,6 +473,23 @@ export function createClassroomRepository() { return { classroom: scope.classroom, workspace: scope.workspace, cleared, targetId }; }, async getStudentContext(accessHash) { + const live = [...participants.values()].find(value => value.workspaceAccessHash === accessHash && !value.revoked); + if (live) { + const item = classes.find(candidate => candidate.internalId === live.classInternalId && !candidate.revoked); + const workspace = item && workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.workspaceId === live.workspaceId + && !candidate.revoked + )); + if (item && workspace) { + return { + classroom: publicClass(item), + workspace: { id: workspace.id, kind: workspace.kind, label: workspace.label }, + internal: { classId: item.internalId, workspaceId: workspace.workspaceId } + }; + } + } + const member = [...memberships.values()].find(value => value.accessHash === accessHash); if (!member) return null; const item = classes.find(candidate => candidate.internalId === member.classInternalId && !candidate.revoked); From 2d6f92d05c693c35818e4457685c311c66a65807 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:08:29 -0400 Subject: [PATCH 10/79] feat: add live classroom HTTP handlers --- api/_classroom.js | 159 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/api/_classroom.js b/api/_classroom.js index 9c8b814..11152dc 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -1367,6 +1367,165 @@ export function classStudentCoachingHandler({ getRepository = getClassroomReposi } }; } +/** + * Create the one-code live Student admission handler. + * + * The human join code is only an admission locator. Successful admission mints + * a high-entropy Student class-session capability that cannot edit collaboration + * directly and may represent a waiting/unassigned Student. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classAdmitHandler({ + getRepository = getClassroomRepository, + tokenFactory = generateWorkspaceToken +} = {}) { + return async (req, res) => { + if (req.method !== 'POST') return methodNotAllowed(res, 'POST'); + + const joinCode = normalizeClassJoinCode(req.body?.joinCode); + const participantId = req.body?.participantId; + const displayName = normalizeCollaborationName(req.body?.displayName, DISPLAY_NAME_MAX_LENGTH); + if (!joinCode || !validateParticipantId(participantId) || displayName === null) { + return send(res, 400, { error: 'Invalid class admission request.' }); + } + + try { + const repository = await getRepository(); + const studentSessionToken = tokenFactory(); + const result = await repository.admitParticipant({ + joinCode, + participantId, + displayName, + sessionHash: hashWorkspaceToken(studentSessionToken) + }); + if (!result) { + return send(res, 404, { error: 'Class not available.' }); + } + return send(res, 200, { + class: { + id: result.classroom.id, + title: result.classroom.title, + expiresAt: result.classroom.expiresAt + }, + participant: result.participant, + assignment: result.assignment, + studentSessionToken + }); + } catch { + return send(res, 500, { error: 'Unable to enter class.' }); + } + }; +} + +/** + * Create the Instructor-only live participant roster handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classParticipantsHandler({ getRepository = getClassroomRepository } = {}) { + return async (req, res) => { + if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const result = await repository.listParticipants(hashWorkspaceToken(authorization.token)); + return result + ? send(res, 200, { class: result.classroom, participants: result.participants }) + : send(res, 404, { error: 'Class not found.' }); + } catch { + return send(res, 500, { error: 'Unable to load class participants.' }); + } + }; +} + +/** + * Create the Student own-assignment status handler. + * + * A Student class-session capability resolves only the represented participant. + * It never returns a class roster, workspace catalog, or editable capability. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentHandler({ getRepository = getClassroomRepository } = {}) { + return async (req, res) => { + if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const result = await repository.getParticipantBySession(hashWorkspaceToken(authorization.token)); + return result + ? send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.assignment + }) + : send(res, 404, { error: 'Student class session not found.' }); + } catch { + return send(res, 500, { error: 'Unable to load Student class status.' }); + } + }; +} + +/** + * Create the Student current-workspace access handler. + * + * The stable Student class-session capability is exchanged for a fresh + * assignment-specific edit capability only while the represented participant is + * currently assigned to a live workspace. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentAccessHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository, + tokenFactory = generateWorkspaceToken +} = {}) { + return async (req, res) => { + if (req.method !== 'POST') return methodNotAllowed(res, 'POST'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const workspaceRepository = await getWorkspaceRepo(); + const workspaceToken = tokenFactory(); + const result = await repository.issueParticipantWorkspaceAccess({ + sessionHash: hashWorkspaceToken(authorization.token), + accessHash: hashWorkspaceToken(workspaceToken), + workspaceRepository + }); + if (!result) { + return send(res, 404, { error: 'Student class session not found.' }); + } + if (result.waiting) { + return send(res, 409, { + status: 'waiting', + class: result.classroom, + participant: result.participant, + assignment: null + }); + } + return send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.assignment, + workspaceToken + }); + } catch { + return send(res, 500, { error: 'Unable to issue Student workspace access.' }); + } + }; +} + /** * Create the student class-join handler. * From 11a83da6d96588022b8879b3c5ca1e6961e75ed8 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:08:41 -0400 Subject: [PATCH 11/79] feat: expose live classroom API routes --- api/classes/admit.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/admit.js diff --git a/api/classes/admit.js b/api/classes/admit.js new file mode 100644 index 0000000..478c4d4 --- /dev/null +++ b/api/classes/admit.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/admit + * @description One-code Student admission into a live class waiting/assigned session. + */ +import { classAdmitHandler } from '../_classroom.js'; + +export default classAdmitHandler(); From 50dd07a44464928bf9565a2201584ad0b821c2f2 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:08:43 -0400 Subject: [PATCH 12/79] feat: expose live classroom API routes --- api/classes/participants.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/participants.js diff --git a/api/classes/participants.js b/api/classes/participants.js new file mode 100644 index 0000000..1dbb459 --- /dev/null +++ b/api/classes/participants.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/participants + * @description Instructor-only live Classroom participant roster. + */ +import { classParticipantsHandler } from '../_classroom.js'; + +export default classParticipantsHandler(); From 4d1910de601b294552f22f6c87250ea9f581d02e Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:08:46 -0400 Subject: [PATCH 13/79] feat: expose live classroom API routes --- api/classes/student.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/student.js diff --git a/api/classes/student.js b/api/classes/student.js new file mode 100644 index 0000000..826e591 --- /dev/null +++ b/api/classes/student.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/student + * @description Student class-session endpoint for own assignment status only. + */ +import { classStudentHandler } from '../_classroom.js'; + +export default classStudentHandler(); From dc698c1103285efe95932c253f6cdbd1172b3227 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:08:48 -0400 Subject: [PATCH 14/79] feat: expose live classroom API routes --- api/classes/student/access.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/student/access.js diff --git a/api/classes/student/access.js b/api/classes/student/access.js new file mode 100644 index 0000000..aab6b6e --- /dev/null +++ b/api/classes/student/access.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/student/access + * @description Exchanges one Student class-session capability for current assignment-specific workspace access. + */ +import { classStudentAccessHandler } from '../../_classroom.js'; + +export default classStudentAccessHandler(); From 04f95f24dd63d2ce75260034d75c9c514d07e84e Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:10:05 -0400 Subject: [PATCH 15/79] test: cover live classroom HTTP authorization --- tests/classroom-api.unit.test.mjs | 228 ++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/tests/classroom-api.unit.test.mjs b/tests/classroom-api.unit.test.mjs index eefc985..9ead390 100644 --- a/tests/classroom-api.unit.test.mjs +++ b/tests/classroom-api.unit.test.mjs @@ -10,11 +10,15 @@ import { CLASS_TITLE_MAX_LENGTH, CLASS_WORKSPACE_LABEL_MAX_LENGTH, COACHING_NOTE_MAX_LENGTH, + classAdmitHandler, classCoachingHandler, classHandler, classJoinHandler, + classParticipantsHandler, classObserveHandler, + classStudentAccessHandler, classStudentCoachingHandler, + classStudentHandler, classWorkspacesHandler, formatClassJoinCode, normalizeClassJoinCode, @@ -157,6 +161,7 @@ test('class creation returns raw capabilities once and stores only hashes', asyn const handler = classHandler({ getRepository: async () => classrooms, tokenFactory: tokenFactory(['I', 'J']), + joinCodeFactory: () => 'K7FMP4Q2', idFactory: () => CLASS_A_ID }); @@ -167,12 +172,235 @@ test('class creation returns raw capabilities once and stores only hashes', asyn assert.equal(res.body.class.title, 'Fall Workshop'); assert.equal(res.body.instructorToken, 'I'.repeat(43)); assert.equal(res.body.studentJoinToken, 'J'.repeat(43)); + assert.equal(res.body.joinCode, 'K7FM-P4Q2'); + assert.equal(classrooms.classes[0].studentJoinCode, 'K7FMP4Q2'); assert.equal(classrooms.classes[0].instructorHash, testTokenHash('I')); assert.equal(classrooms.classes[0].studentJoinHash, testTokenHash('J')); assert.equal(res.headers['Cache-Control'], 'no-store'); assert.equal(res.headers['Referrer-Policy'], 'no-referrer'); }); +test('one-code admission creates a waiting Student class session without workspace edit authority', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + const admit = classAdmitHandler({ + getRepository: async () => classrooms, + tokenFactory: tokenFactory(['S']) + }); + const admitted = response(); + await admit({ + method: 'POST', + body: { + joinCode: ' k7fm-p4q2 ', + participantId: PARTICIPANT_A, + displayName: ' Alex ' + } + }, admitted); + + assert.equal(admitted.statusCode, 200); + assert.equal(admitted.body.class.id, CLASS_A_ID); + assert.equal(admitted.body.participant.displayName, 'Alex'); + assert.equal(admitted.body.participant.assignmentRevision, 0); + assert.equal(admitted.body.assignment, null); + assert.equal(admitted.body.studentSessionToken, 'S'.repeat(43)); + + const directWorkspace = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + admitted.body.studentSessionToken } + }, directWorkspace); + assert.equal(directWorkspace.statusCode, 404, 'class-session authority is not collaboration edit authority'); + + const invalid = response(); + await classAdmitHandler({ + getRepository: async () => classrooms, + tokenFactory: tokenFactory(['T']) + })({ + method: 'POST', + body: { + joinCode: 'M8RN-Q5W3', + participantId: PARTICIPANT_B, + displayName: 'Blair' + } + }, invalid); + assert.equal(invalid.statusCode, 404); + assert.deepEqual(invalid.body, { error: 'Class not available.' }); +}); + +test('Instructor participant roster is class-scoped while Student status returns only self', async () => { + const classrooms = createClassroomRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_B, + displayName: 'Blair', + sessionHash: testTokenHash('T') + }); + + const rosterHandler = classParticipantsHandler({ getRepository: async () => classrooms }); + const roster = response(); + await rosterHandler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, roster); + assert.equal(roster.statusCode, 200); + assert.deepEqual(roster.body.participants.map(item => item.displayName), ['Alex', 'Blair']); + + const studentCannotEnumerate = response(); + await rosterHandler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentCannotEnumerate); + assert.equal(studentCannotEnumerate.statusCode, 404); + + const statusHandler = classStudentHandler({ getRepository: async () => classrooms }); + const alex = response(); + await statusHandler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, alex); + assert.equal(alex.statusCode, 200); + assert.equal(alex.body.participant.id, PARTICIPANT_A); + assert.equal(alex.body.participant.displayName, 'Alex'); + assert.equal(alex.body.assignment, null); + assert.equal('participants' in alex.body, false); + assert.equal('workspaceToken' in alex.body, false); +}); + +test('Student workspace access waits while unassigned and rotates assignment-specific edit authority', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + const workspace = await workspaceRepo.create( + testTokenHash('P'), + { pre: { oneLine: 'Team A current Intake' } }, + 30, + 'Team A' + ); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: workspace.id, + kind: 'group', + label: 'Team A', + claimHash: testTokenHash('X') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + + const waitingHandler = classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['W']) + }); + const waiting = response(); + await waitingHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, waiting); + assert.equal(waiting.statusCode, 409); + assert.equal(waiting.body.status, 'waiting'); + assert.equal(waiting.body.assignment, null); + assert.equal('workspaceToken' in waiting.body, false); + + const participant = [...classrooms.participants.values()][0]; + participant.workspaceId = workspace.id; + participant.assignmentRevision = 1; + + const firstAccess = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['W']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, firstAccess); + assert.equal(firstAccess.statusCode, 200); + assert.equal(firstAccess.body.assignment.id, WORKSPACE_A_ID); + assert.equal(firstAccess.body.participant.assignmentRevision, 1); + assert.equal(firstAccess.body.workspaceToken, 'W'.repeat(43)); + + const firstLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, firstLoad); + assert.equal(firstLoad.statusCode, 200); + assert.equal(firstLoad.body.snapshot.pre.oneLine, 'Team A current Intake'); + + await classrooms.upsertFeedback(testTokenHash('A'), WORKSPACE_A_ID, { + targetId: 'problem.one-line', + status: 'meets-standard', + note: 'Clear', + reviewedWorkspaceRevision: 1, + reviewedFieldFingerprint: 'v1-0123456789abcdef' + }); + const liveCoaching = response(); + await classStudentCoachingHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, liveCoaching); + assert.equal(liveCoaching.statusCode, 200); + assert.deepEqual(liveCoaching.body.feedback.map(item => item.targetId), ['problem.one-line']); + + const rotated = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['V']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, rotated); + assert.equal(rotated.statusCode, 200); + assert.equal(rotated.body.workspaceToken, 'V'.repeat(43)); + + const oldLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, oldLoad); + assert.equal(oldLoad.statusCode, 404); + + const newLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'V'.repeat(43) } + }, newLoad); + assert.equal(newLoad.statusCode, 200); +}); + test('class admin rejects missing or malformed authorization before repository access', async () => { let accessed = false; const handler = classHandler({ From 86632f2ffebbaad87d63a60d680646013d065938 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:12:59 -0400 Subject: [PATCH 16/79] docs: checkpoint live classroom HTTP tranche --- docs/classroom-live-management.md | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/classroom-live-management.md b/docs/classroom-live-management.md index 6407ee8..e05dc4c 100644 --- a/docs/classroom-live-management.md +++ b/docs/classroom-live-management.md @@ -362,6 +362,42 @@ The Student client requests access: - after assignment revision changes; - on resume when its locally cached workspace access is missing/stale. +## Implementation checkpoint — live HTTP admission/status/access + +Completed on #314 after the additive persistence tranche and before any Student UI cutover. + +Implemented: +- Start Class now generates and returns a formatted human Student join code while preserving the legacy high-entropy `studentJoinToken` for compatibility; +- `POST /api/classes/admit` accepts only join code + participant ID + display name and returns a high-entropy Student class-session token; +- admitted Students may remain waiting/unassigned with no collaboration edit authority; +- `GET /api/classes/participants` is Instructor-only and lists only the represented class; +- `GET /api/classes/student` returns only the represented Student's own assignment state and never a roster/workspace catalog; +- `POST /api/classes/student/access` exchanges a Student class-session token for a fresh assignment-specific `classroom-student` workspace capability only when assigned; +- rotating current workspace access revokes the prior edit token; +- the class-session token itself is not accepted by collaboration workspace APIs; +- live workspace-access tokens now resolve through the existing Student coaching/protected-resource membership boundary, while legacy membership tokens continue to work; +- workspace-access issuance checks class/workspace/assignment revision before persisting the token so a concurrent assignment change cannot silently bind stale access to the wrong team. + +Durable implementation commits: +- `746559e67620aab3457b6cda90e42168d5350dde` — Start Class join code + live workspace-access repository path + live/legacy Student context resolution; +- `1b81d4a4c80a7fe16cee5523d6c13811f51f20b3` — deterministic repository parity for live access; +- `2d6f92d05c693c35818e4457685c311c66a65807` — live admission/roster/status/access handlers; +- `11a83da6d96588022b8879b3c5ca1e6961e75ed8` through `dc698c1103285efe95932c253f6cdbd1172b3227` — Vercel route entrypoints; +- `04f95f24dd63d2ce75260034d75c9c514d07e84e` — focused HTTP authorization tests. + +Validation on `04f95f2...`: +- repository quality: **244 tests / 243 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **22 passed / 6 intentional project-scoped skips / 0 failed**; +- CodeQL, Dependency Review, and Template Manifest Guard: green. + +**Exact next tranche:** implement Instructor assign/reassign/unassign repository + HTTP semantics before building the live management UI. The critical acceptance proof is: +1. assign waiting Student -> access token edits destination workspace; +2. reassign A -> B -> old A token is revoked before B access can be issued; +3. unassign -> old token is revoked and Student returns to waiting; +4. cross-class destination assignment is rejected; +5. no assignment operation copies or merges Intake snapshots; +6. stale Team A authority can never become Team B authority. + ## Student client lifecycle ### Waiting From 6e1a023fb2e9e2a504f85b4d236856d25efc4722 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:13:02 -0400 Subject: [PATCH 17/79] docs: advance live classroom assignment checkpoint --- docs/classroom-workstream.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 427b79e..7d77ed0 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -338,7 +338,15 @@ Continue #295 on draft PR #307 from the current branch head. - `df3633d008bb7e352509e15c1964b7d27ebf441f` adds focused join-code normalization, waiting-roster, class-scoping, and class-session-rotation tests. - validation: canonical quality **241 tests / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. - current Student UI and legacy `POST /api/classes/join` remain unchanged. -- Exact next action: implement the live-class HTTP admission/status/access layer on top of these primitives (Start Class join code, `POST /api/classes/admit`, Instructor participant roster, Student own-status, Student current-workspace access), with authorization tests before any UI cutover or assignment/reassignment UI. +- HTTP admission/status/access checkpoint: + - `746559e67620aab3457b6cda90e42168d5350dde` adds Start Class join-code return, race-safe live workspace-access issuance, and live/legacy Student-context resolution; + - `1b81d4a4c80a7fe16cee5523d6c13811f51f20b3` mirrors live access in the deterministic repository; + - `2d6f92d05c693c35818e4457685c311c66a65807` adds one-code admission, Instructor participant roster, Student own-status, and Student current-workspace access handlers; + - route entrypoints land in `11a83da6...` through `dc698c110...`; + - `04f95f24dd63d2ce75260034d75c9c514d07e84e` adds the focused authorization matrix. + - validation: repository quality **244 tests / 243 pass / 0 fail / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. + - current Student UI remains on the legacy two-code path; no live team-management UI has been cut over yet. +- Exact next action: implement Instructor assign/reassign/unassign semantics with old-workspace capability revocation, cross-class rejection, assignment-revision increments, and no snapshot merge. Prove the server race/security contract before building Instructor drag/drop/team UI or Student automatic reassignment. ## Approved Classroom follow-on direction From 742240e92156a9d43a96752a2cb6acc5908f4172 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:24:25 -0400 Subject: [PATCH 18/79] feat: add server-side workspace presence cleanup --- api/_workspace.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/_workspace.js b/api/_workspace.js index 695eefe..61e281a 100644 --- a/api/_workspace.js +++ b/api/_workspace.js @@ -309,6 +309,13 @@ async function initializeRepository() { RETURNING participant_id`; return rows.length > 0; }, + async removePresenceByWorkspaceId(workspaceId, participantId) { + const rows = await sql`DELETE FROM collaboration_participants + WHERE workspace_id = ${workspaceId} + AND participant_id = ${participantId}::uuid + RETURNING participant_id`; + return rows.length > 0; + }, async createCapability(workspaceId, tokenHash, capabilityKind, expiresAt = null) { if (!isWorkspaceEditCapabilityKind(capabilityKind)) { return false; From 2c1d6fdf6872eb1390424503f08692a581da6322 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:24:53 -0400 Subject: [PATCH 19/79] test: support assignment presence cleanup --- tests/helpers/classroom-test-repositories.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index fc88bff..f5d7117 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -129,6 +129,14 @@ export function createWorkspaceRepository() { participants: next }; }, + async removePresenceByWorkspaceId(workspaceId, participantId) { + const workspace = workspaces.get(Number(workspaceId)) || workspaces.get(workspaceId); + if (!workspace) return false; + const participants = presenceByWorkspace.get(workspace.id) || []; + const next = participants.filter(item => item.id !== participantId); + presenceByWorkspace.set(workspace.id, next); + return next.length !== participants.length; + }, async observeById(workspaceId) { const workspace = workspaces.get(Number(workspaceId)) || workspaces.get(workspaceId); if (!workspace) return null; From b48e6bbb71c2d6ec818ca1f69b656b3bffbed473 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:26:45 -0400 Subject: [PATCH 20/79] feat: add safe live participant assignment --- api/_classroom.js | 178 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 171 insertions(+), 7 deletions(-) diff --git a/api/_classroom.js b/api/_classroom.js index 11152dc..f177498 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -496,6 +496,128 @@ async function initializeClassroomRepository() { }; }, + async assignParticipant(instructorHash, { participantId, workspacePublicId, workspaceRepository }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const participantRows = await sql`SELECT + workspace_id AS "workspaceInternalId", + workspace_access_token_hash AS "workspaceAccessHash", + assignment_revision AS "assignmentRevision" + FROM classroom_participants + WHERE class_id = ${classroom.internal_id} + AND participant_id = ${participantId}::uuid + AND revoked_at IS NULL`; + const participant = participantRows[0]; + if (!participant) return null; + + let destination = null; + if (workspacePublicId !== null) { + const destinationRows = await sql`SELECT + cw.workspace_id AS "internalId", + cw.public_id AS id, + cw.workspace_kind AS kind, + cw.label + FROM classroom_workspaces cw + JOIN collaboration_workspaces w ON w.id = cw.workspace_id + WHERE cw.class_id = ${classroom.internal_id} + AND cw.public_id = ${workspacePublicId}::uuid + AND cw.revoked_at IS NULL + AND w.expires_at > NOW()`; + destination = destinationRows[0] || null; + if (!destination) return null; + + if (destination.kind === 'individual') { + const claimed = await sql`UPDATE classroom_workspaces + SET individual_participant_id = COALESCE(individual_participant_id, ${participantId}::uuid), + updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${destination.internalId} + AND (individual_participant_id IS NULL OR individual_participant_id = ${participantId}::uuid) + RETURNING workspace_id`; + if (!claimed[0]) { + return { status: 'occupied', classroom }; + } + } + } + + const nextWorkspaceInternalId = destination?.internalId || null; + if ((participant.workspaceInternalId || null) === nextWorkspaceInternalId) { + return { + status: 'unchanged', + classroom, + participant: { + id: participantId, + assignmentRevision: participant.assignmentRevision + }, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + } + + if (participant.workspaceAccessHash) { + await workspaceRepository.revokeCapability(participant.workspaceAccessHash); + } + if (participant.workspaceInternalId) { + await workspaceRepository.removePresenceByWorkspaceId(participant.workspaceInternalId, participantId); + } + + const updatedRows = await sql`UPDATE classroom_participants + SET workspace_id = ${nextWorkspaceInternalId}, + workspace_access_token_hash = NULL, + assignment_revision = assignment_revision + 1, + assigned_at = CASE WHEN ${nextWorkspaceInternalId} IS NULL THEN NULL ELSE NOW() END, + updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND participant_id = ${participantId}::uuid + AND assignment_revision = ${participant.assignmentRevision} + AND revoked_at IS NULL + RETURNING + participant_id AS id, + display_name AS "displayName", + assignment_revision AS "assignmentRevision", + joined_at AS "joinedAt", + updated_at AS "updatedAt"`; + const updated = updatedRows[0]; + + if (!updated) { + if (destination?.kind === 'individual') { + const currentRows = await sql`SELECT workspace_id AS "workspaceInternalId" + FROM classroom_participants + WHERE class_id = ${classroom.internal_id} + AND participant_id = ${participantId}::uuid + AND revoked_at IS NULL`; + if (currentRows[0]?.workspaceInternalId !== destination.internalId) { + await sql`UPDATE classroom_workspaces + SET individual_participant_id = NULL, updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${destination.internalId} + AND individual_participant_id = ${participantId}::uuid`; + } + } + return { status: 'conflict', classroom }; + } + + if (participant.workspaceInternalId && participant.workspaceInternalId !== nextWorkspaceInternalId) { + await sql`UPDATE classroom_workspaces + SET individual_participant_id = NULL, updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND workspace_id = ${participant.workspaceInternalId} + AND workspace_kind = 'individual' + AND individual_participant_id = ${participantId}::uuid`; + } + + return { + status: 'updated', + classroom, + participant: updated, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + }, + async issueParticipantWorkspaceAccess({ sessionHash, accessHash, workspaceRepository }) { const context = await this.getParticipantBySession(sessionHash); if (!context) return null; @@ -777,6 +899,12 @@ async function initializeClassroomRepository() { JOIN classroom_workspaces cw ON cw.class_id = cp.class_id AND cw.workspace_id = cp.workspace_id JOIN collaboration_workspaces w ON w.id = cw.workspace_id + JOIN collaboration_workspace_capabilities cap + ON cap.token_hash = cp.workspace_access_token_hash + AND cap.workspace_id = cw.workspace_id + AND cap.capability_kind = ${CLASSROOM_STUDENT_CAPABILITY_KIND} + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) WHERE cp.workspace_access_token_hash = ${accessHash} AND cp.revoked_at IS NULL AND c.revoked_at IS NULL @@ -1425,20 +1553,56 @@ export function classAdmitHandler({ * @param {object} [dependencies] Injectable dependencies. * @returns {Function} Vercel handler. */ -export function classParticipantsHandler({ getRepository = getClassroomRepository } = {}) { +export function classParticipantsHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository +} = {}) { return async (req, res) => { - if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + if (!['GET', 'PATCH'].includes(req.method)) return methodNotAllowed(res, 'GET, PATCH'); const authorization = requireBearer(req); if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + const instructorHash = hashWorkspaceToken(authorization.token); + try { const repository = await getRepository(); - const result = await repository.listParticipants(hashWorkspaceToken(authorization.token)); - return result - ? send(res, 200, { class: result.classroom, participants: result.participants }) - : send(res, 404, { error: 'Class not found.' }); + if (req.method === 'GET') { + const result = await repository.listParticipants(instructorHash); + return result + ? send(res, 200, { class: result.classroom, participants: result.participants }) + : send(res, 404, { error: 'Class not found.' }); + } + + const participantId = req.body?.participantId; + const workspaceId = req.body?.workspaceId ?? null; + if (!validateParticipantId(participantId) + || (workspaceId !== null && !validateClassroomId(workspaceId))) { + return send(res, 400, { error: 'Invalid participant assignment.' }); + } + + const workspaceRepository = await getWorkspaceRepo(); + const result = await repository.assignParticipant(instructorHash, { + participantId, + workspacePublicId: workspaceId, + workspaceRepository + }); + if (!result) { + return send(res, 404, { error: 'Participant or workspace not found.' }); + } + if (result.status === 'occupied') { + return send(res, 409, { error: 'Individual workspace is already assigned.' }); + } + if (result.status === 'conflict') { + return send(res, 409, { error: 'Participant assignment changed. Retry.' }); + } + return send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.assignment, + changed: result.status === 'updated' + }); } catch { - return send(res, 500, { error: 'Unable to load class participants.' }); + return send(res, 500, { error: 'Unable to update class participant.' }); } }; } From a382acad42de25f4fa1ae217acf34d69a047606b Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:27:26 -0400 Subject: [PATCH 21/79] test: model live assignment transitions --- tests/helpers/classroom-test-repositories.mjs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index f5d7117..b67300d 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -296,6 +296,87 @@ export function createClassroomRepository() { }) }; }, + async assignParticipant(instructorHash, { participantId, workspacePublicId, workspaceRepository }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const participant = participants.get(item.internalId + ':' + participantId); + if (!participant || participant.revoked) return null; + + const destination = workspacePublicId === null + ? null + : workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === workspacePublicId + && !candidate.revoked + )); + if (workspacePublicId !== null && !destination) return null; + + if (destination?.kind === 'individual') { + if (destination.individualParticipantId && destination.individualParticipantId !== participantId) { + return { status: 'occupied', classroom: publicClass(item) }; + } + destination.individualParticipantId = participantId; + } + + const nextWorkspaceId = destination?.workspaceId || null; + if ((participant.workspaceId || null) === nextWorkspaceId) { + return { + status: 'unchanged', + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt + }, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + } + + const previousWorkspaceId = participant.workspaceId; + if (participant.workspaceAccessHash) { + await workspaceRepository.revokeCapability(participant.workspaceAccessHash); + } + if (previousWorkspaceId) { + await workspaceRepository.removePresenceByWorkspaceId(previousWorkspaceId, participantId); + } + + const previousWorkspace = previousWorkspaceId + ? workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.workspaceId === previousWorkspaceId + )) + : null; + + participant.workspaceId = nextWorkspaceId; + participant.workspaceAccessHash = null; + participant.assignmentRevision += 1; + participant.updatedAt = 'updated'; + + if (previousWorkspace?.kind === 'individual' + && previousWorkspace.workspaceId !== nextWorkspaceId + && previousWorkspace.individualParticipantId === participantId) { + previousWorkspace.individualParticipantId = null; + } + + return { + status: 'updated', + classroom: publicClass(item), + participant: { + id: participant.participantId, + displayName: participant.displayName, + assignmentRevision: participant.assignmentRevision, + joinedAt: participant.joinedAt, + updatedAt: participant.updatedAt + }, + assignment: destination + ? { id: destination.id, kind: destination.kind, label: destination.label } + : null + }; + }, async issueParticipantWorkspaceAccess({ sessionHash, accessHash, workspaceRepository }) { const participant = [...participants.values()].find(value => value.sessionHash === sessionHash && !value.revoked); if (!participant) return null; From 7bfd662e2dcbe5511a4065b7b19222129485d08a Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:28:41 -0400 Subject: [PATCH 22/79] test: prove live assignment authority cutoff --- tests/classroom-api.unit.test.mjs | 300 ++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/tests/classroom-api.unit.test.mjs b/tests/classroom-api.unit.test.mjs index 9ead390..f1bfacf 100644 --- a/tests/classroom-api.unit.test.mjs +++ b/tests/classroom-api.unit.test.mjs @@ -43,6 +43,7 @@ const CLASS_A_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const CLASS_B_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const WORKSPACE_C_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const PARTICIPANT_A = '11111111-1111-4111-8111-111111111111'; const PARTICIPANT_B = '22222222-2222-4222-8222-222222222222'; @@ -401,6 +402,305 @@ test('Student workspace access waits while unassigned and rotates assignment-spe assert.equal(newLoad.statusCode, 200); }); +test('Instructor assign -> reassign -> unassign revokes old authority and never merges team Intake', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + await classrooms.createClass({ + publicId: CLASS_B_ID, + title: 'Class B', + instructorHash: testTokenHash('B'), + studentJoinHash: testTokenHash('D'), + studentJoinCode: 'M8RNQ5W3' + }); + + const teamA = await workspaceRepo.create( + testTokenHash('P'), + { pre: { oneLine: 'Team A original' } }, + 30, + 'Team A' + ); + const teamB = await workspaceRepo.create( + testTokenHash('Q'), + { pre: { oneLine: 'Team B original' } }, + 30, + 'Team B' + ); + const otherClass = await workspaceRepo.create( + testTokenHash('R'), + { pre: { oneLine: 'Other class' } }, + 30, + 'Other Class Team' + ); + + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: teamA.id, + kind: 'group', + label: 'Team A', + claimHash: testTokenHash('X') + }); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_B_ID, + workspaceId: teamB.id, + kind: 'group', + label: 'Team B', + claimHash: testTokenHash('Y') + }); + await classrooms.addWorkspace(testTokenHash('B'), { + publicId: WORKSPACE_C_ID, + workspaceId: otherClass.id, + kind: 'group', + label: 'Other Class Team', + claimHash: testTokenHash('Z') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + + const participantHandler = classParticipantsHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo + }); + + const assigned = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_A_ID } + }, assigned); + assert.equal(assigned.statusCode, 200); + assert.equal(assigned.body.changed, true); + assert.equal(assigned.body.assignment.id, WORKSPACE_A_ID); + assert.equal(assigned.body.participant.assignmentRevision, 1); + + const repeated = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_A_ID } + }, repeated); + assert.equal(repeated.statusCode, 200); + assert.equal(repeated.body.changed, false); + assert.equal(repeated.body.participant.assignmentRevision, 1); + + const teamAAccess = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['W']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, teamAAccess); + assert.equal(teamAAccess.statusCode, 200); + assert.equal(teamAAccess.body.workspaceToken, 'W'.repeat(43)); + + const editedA = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 1, + snapshot: { pre: { oneLine: 'Alex changed Team A only' } } + } + }, editedA); + assert.equal(editedA.statusCode, 200); + + await workspaceRepo.upsertPresence(testTokenHash('W'), PARTICIPANT_A, 'Alex'); + + const moved = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_B_ID } + }, moved); + assert.equal(moved.statusCode, 200); + assert.equal(moved.body.changed, true); + assert.equal(moved.body.assignment.id, WORKSPACE_B_ID); + assert.equal(moved.body.participant.assignmentRevision, 2); + + const staleRead = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, staleRead); + assert.equal(staleRead.statusCode, 404); + + const staleWrite = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 2, + snapshot: { pre: { oneLine: 'Must never become Team B' } } + } + }, staleWrite); + assert.equal(staleWrite.statusCode, 404); + + const staleCoaching = response(); + await classStudentCoachingHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) } + }, staleCoaching); + assert.equal(staleCoaching.statusCode, 404); + + const oldPresence = await workspaceRepo.observeById(teamA.id); + assert.deepEqual(oldPresence.participants, []); + + const teamASnapshot = await workspaceRepo.load(testTokenHash('P')); + const teamBSnapshotBeforeAccess = await workspaceRepo.load(testTokenHash('Q')); + assert.equal(teamASnapshot.snapshot.pre.oneLine, 'Alex changed Team A only'); + assert.equal(teamBSnapshotBeforeAccess.snapshot.pre.oneLine, 'Team B original'); + + const studentStatus = response(); + await classStudentHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentStatus); + assert.equal(studentStatus.statusCode, 200); + assert.equal(studentStatus.body.assignment.id, WORKSPACE_B_ID); + assert.equal(studentStatus.body.participant.assignmentRevision, 2); + + const teamBAccess = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['V']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, teamBAccess); + assert.equal(teamBAccess.statusCode, 200); + assert.equal(teamBAccess.body.workspaceToken, 'V'.repeat(43)); + + const teamBLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'V'.repeat(43) } + }, teamBLoad); + assert.equal(teamBLoad.statusCode, 200); + assert.equal(teamBLoad.body.snapshot.pre.oneLine, 'Team B original'); + + const crossClass = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_C_ID } + }, crossClass); + assert.equal(crossClass.statusCode, 404); + + const afterCrossClass = response(); + await classStudentHandler({ getRepository: async () => classrooms })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, afterCrossClass); + assert.equal(afterCrossClass.body.assignment.id, WORKSPACE_B_ID); + assert.equal(afterCrossClass.body.participant.assignmentRevision, 2); + + const unassigned = response(); + await participantHandler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: null } + }, unassigned); + assert.equal(unassigned.statusCode, 200); + assert.equal(unassigned.body.changed, true); + assert.equal(unassigned.body.assignment, null); + assert.equal(unassigned.body.participant.assignmentRevision, 3); + + const oldTeamBLoad = response(); + await workspaceHandler({ getRepository: async () => workspaceRepo })({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'V'.repeat(43) } + }, oldTeamBLoad); + assert.equal(oldTeamBLoad.statusCode, 404); + + const waitingAgain = response(); + await classStudentAccessHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + tokenFactory: tokenFactory(['U']) + })({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, waitingAgain); + assert.equal(waitingAgain.statusCode, 409); + assert.equal(waitingAgain.body.status, 'waiting'); + assert.equal('workspaceToken' in waitingAgain.body, false); +}); + +test('individual live workspace rejects a second Student without disturbing either assignment', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + const individual = await workspaceRepo.create(testTokenHash('P'), {}, 30, 'Alex workspace'); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: individual.id, + kind: 'individual', + label: 'Individual 1', + claimHash: testTokenHash('X') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_B, + displayName: 'Blair', + sessionHash: testTokenHash('T') + }); + + const handler = classParticipantsHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo + }); + + const first = response(); + await handler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_A, workspaceId: WORKSPACE_A_ID } + }, first); + assert.equal(first.statusCode, 200); + assert.equal(first.body.participant.assignmentRevision, 1); + + const denied = response(); + await handler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { participantId: PARTICIPANT_B, workspaceId: WORKSPACE_A_ID } + }, denied); + assert.equal(denied.statusCode, 409); + + const alex = await classrooms.getParticipantBySession(testTokenHash('S')); + const blair = await classrooms.getParticipantBySession(testTokenHash('T')); + assert.equal(alex.assignment.id, WORKSPACE_A_ID); + assert.equal(alex.participant.assignmentRevision, 1); + assert.equal(blair.assignment, null); + assert.equal(blair.participant.assignmentRevision, 0); +}); + test('class admin rejects missing or malformed authorization before repository access', async () => { let accessed = false; const handler = classHandler({ From 24487a64572a7bb6a4a63d3c8bbc1959c19bb168 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:31:13 -0400 Subject: [PATCH 23/79] docs: record live assignment API semantics --- docs/classroom-api.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/classroom-api.md b/docs/classroom-api.md index 954ec3f..53a04dd 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -306,4 +306,16 @@ Key direction: - destination workspace state wins on reassignment; no automatic Intake merge occurs; - legacy two-code admission remains supported during the additive migration. -Planned new endpoints are `POST /api/classes/admit`, `GET/PATCH /api/classes/participants`, `GET /api/classes/student`, and `POST /api/classes/student/access`. Existing `POST /api/classes/join` remains a compatibility path until a later explicit migration. +Implemented live-class endpoints are `POST /api/classes/admit`, `GET/PATCH /api/classes/participants`, `GET /api/classes/student`, and `POST /api/classes/student/access`. Existing `POST /api/classes/join` remains a compatibility path until a later explicit migration. + +Assignment semantics: +- `PATCH /api/classes/participants` is Instructor-only and accepts `participantId` plus a same-class `workspaceId`, or `workspaceId: null` to return the Student to waiting; +- assign/reassign/unassign increments `assignmentRevision` exactly when the assignment changes; +- repeating the same assignment is idempotent and does not increment the revision; +- the previous assignment-specific edit capability is revoked before assignment metadata changes; +- stale presence is removed from the old workspace by internal workspace ID after revocation; +- cross-class destinations are rejected without changing the participant assignment; +- individual workspaces remain single-participant; +- no assignment operation reads, copies, or merges Intake snapshots; +- the Student must exchange the stable class-session capability for a fresh destination workspace token after an assignment change; +- a live Student coaching/protected-resource context is valid only while the stored workspace-access hash also resolves to an active, unrevoked `classroom-student` collaboration alias. From 8d846ca00befc8e5a191719939bd39cb8b685e74 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:31:27 -0400 Subject: [PATCH 24/79] docs: harden live assignment server rules --- api/AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/AGENTS.md b/api/AGENTS.md index 70f1bc9..408c7c9 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -115,3 +115,8 @@ Rules: - no assignment operation copies or merges Intake snapshots; - keep legacy `/api/classes/join` and `classroom_memberships` working during the additive migration; - Student status endpoints return only that Student's assignment; they never enumerate classmates/workspaces. +- `PATCH /api/classes/participants` must revoke any existing assignment-specific workspace capability before changing the participant's workspace assignment; destination access is issued only later through `POST /api/classes/student/access`. +- live Student coaching/protected-resource authorization must validate that the participant's stored workspace-access hash still corresponds to an active, unrevoked `classroom-student` collaboration capability; the stored hash alone is not sufficient authority. +- assignment changes use optimistic `assignment_revision` checks; a conflicting update may leave old access revoked, but must never leave old access valid or remap it to a new workspace. +- stale presence cleanup after reassignment uses internal workspace identity, not the revoked bearer token. +- individual live workspaces remain single-participant; cross-class destination assignment is rejected generically. From 87105fa367cbc258e39c3aaf0c76248b9c460142 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:32:04 -0400 Subject: [PATCH 25/79] docs: checkpoint live assignment authority --- docs/classroom-live-management.md | 51 ++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/classroom-live-management.md b/docs/classroom-live-management.md index e05dc4c..4a6e391 100644 --- a/docs/classroom-live-management.md +++ b/docs/classroom-live-management.md @@ -390,13 +390,50 @@ Validation on `04f95f2...`: - required Browser E2E: **22 passed / 6 intentional project-scoped skips / 0 failed**; - CodeQL, Dependency Review, and Template Manifest Guard: green. -**Exact next tranche:** implement Instructor assign/reassign/unassign repository + HTTP semantics before building the live management UI. The critical acceptance proof is: -1. assign waiting Student -> access token edits destination workspace; -2. reassign A -> B -> old A token is revoked before B access can be issued; -3. unassign -> old token is revoked and Student returns to waiting; -4. cross-class destination assignment is rejected; -5. no assignment operation copies or merges Intake snapshots; -6. stale Team A authority can never become Team B authority. +## Implementation checkpoint — assign / reassign / unassign authority + +Completed on #314 before any live team-management UI cutover. + +Implemented: +- Instructor-only `PATCH /api/classes/participants` assigns, reassigns, or unassigns one admitted Student; +- assignment is class-scoped: participant and destination workspace must both belong to the Instructor's represented class; +- `workspaceId: null` returns the Student to waiting/unassigned; +- assignment changes increment `assignmentRevision`; repeating the same assignment is idempotent and does not increment; +- any current assignment-specific edit capability is revoked **before** participant assignment metadata changes; +- stale collaboration presence is removed from the old workspace by internal workspace ID after revocation; +- the participant's stored workspace-access hash is cleared on assignment change; +- destination access is never issued by the Instructor assignment operation; the Student must later call `POST /api/classes/student/access`; +- cross-class destinations are rejected without changing assignment; +- individual live workspaces enforce one participant; +- leaving an individual workspace releases its individual claim for later reassignment; +- no assignment operation reads, copies, or merges Intake snapshots; +- live Student coaching/protected-resource context now additionally requires an active, unrevoked `classroom-student` collaboration alias, closing the stale stored-hash authorization window; +- optimistic assignment-revision protection means a concurrent conflict can fail closed with old authority revoked rather than risk stale authority surviving or being retargeted. + +Durable implementation commits: +- `742240e92156a9d43a96752a2cb6acc5908f4172` — server-only presence cleanup by workspace ID; +- `2c1d6fdf6872eb1390424503f08692a581da6322` — deterministic presence-cleanup parity; +- `b48e6bbb71c2d6ec818ca1f69b656b3bffbed473` — live assignment repository/HTTP semantics plus active-alias validation for Student context; +- `a382acad42de25f4fa1ae217acf34d69a047606b` — deterministic assignment transition model; +- `7bfd662e2dcbe5511a4065b7b19222129485d08a` — full Waiting -> Team A -> Team B -> Waiting authority-cutoff and individual-workspace tests. + +Validation on `7bfd662...`: +- repository quality: **246 tests / 245 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **22 passed / 6 intentional project-scoped skips / 0 failed**; +- CodeQL, Dependency Review, and Template Manifest Guard: green. + +**Exact next tranche:** build the Instructor live-management client on this proven server contract: +1. Start Class as the primary Instructor action; +2. persist the Instructor capability for same-device resume; +3. display/copy the human Student join code; +4. show Waiting/Unassigned plus team/individual workspace roster; +5. create team/individual workspaces; +6. assign/reassign/unassign through accessible button/select controls first; +7. keep drag-and-drop optional/progressive enhancement, never the only assignment path; +8. wire observer/coaching navigation from each workspace; +9. extend the deterministic browser fixture and real-browser E2E for Instructor start/roster/team assignment before cutting over the Student client. + +Do not implement Student one-code waiting/automatic reassignment UI until the Instructor management surface is browser-green. ## Student client lifecycle From 27e9df7af70c21a1f02553d8241077821409c5e0 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:32:37 -0400 Subject: [PATCH 26/79] docs: advance live classroom to Instructor UI --- docs/classroom-workstream.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 7d77ed0..9554fc8 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -346,7 +346,15 @@ Continue #295 on draft PR #307 from the current branch head. - `04f95f24dd63d2ce75260034d75c9c514d07e84e` adds the focused authorization matrix. - validation: repository quality **244 tests / 243 pass / 0 fail / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. - current Student UI remains on the legacy two-code path; no live team-management UI has been cut over yet. -- Exact next action: implement Instructor assign/reassign/unassign semantics with old-workspace capability revocation, cross-class rejection, assignment-revision increments, and no snapshot merge. Prove the server race/security contract before building Instructor drag/drop/team UI or Student automatic reassignment. +- Assign/reassign/unassign authority checkpoint: + - `742240e92156a9d43a96752a2cb6acc5908f4172` adds server-only stale-presence cleanup by internal workspace ID; + - `2c1d6fdf6872eb1390424503f08692a581da6322` mirrors that cleanup in the deterministic workspace harness; + - `b48e6bbb71c2d6ec818ca1f69b656b3bffbed473` adds Instructor-only assignment transitions, optimistic revision protection, individual-workspace occupancy enforcement, old-authority revocation before assignment mutation, and active-alias validation for live Student coaching/resource context; + - `a382acad42de25f4fa1ae217acf34d69a047606b` mirrors live assignment transitions in the deterministic Classroom repository; + - `7bfd662e2dcbe5511a4065b7b19222129485d08a` proves Waiting -> Team A -> Team B -> Waiting, idempotent same-team assignment, cross-class rejection, stale-token write rejection, old-presence cleanup, no snapshot merge, and individual-workspace single occupancy. + - validation: repository quality **246 tests / 245 pass / 0 fail / 1 intentional skip**; required Browser E2E **22 passed / 6 intentional skips / 0 failed**; CodeQL, Dependency Review, and Template Manifest Guard green. + - current Instructor and Student UIs are still on the pre-#312 experience; the new management surface has not been cut over. +- Exact next action: build the Instructor live-management client on the proven server contract: Start Class, same-device Instructor resume, human join-code display/copy, Waiting roster, team/individual workspace creation, accessible assign/reassign/unassign controls, and observer/coaching navigation. Extend the deterministic browser fixture and real-browser Instructor journey in the same tranche. Keep drag-and-drop optional and do not cut over the Student one-code/reassignment client until the Instructor surface is browser-green. ## Approved Classroom follow-on direction From 0d004552db82660dc723fd37a79ef4e46d197083 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:40:42 -0400 Subject: [PATCH 27/79] feat: add Instructor live-management shell --- index.html | 64 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 461f5e4..02fdfd7 100644 --- a/index.html +++ b/index.html @@ -328,20 +328,37 @@

Join your class workspace

Instructor -

Open your class

-

Use the Instructor access code created with the class. It authorizes the roster and read-only observation only; it never becomes a Student workspace credential.

-
+

Run a class

+

Start a new live class, or open an existing class from an Instructor access code. Instructor authority manages the class and observes work; it never becomes a Student workspace credential.

+ +
- - - Your access code is stored only on this device for class resume and never enters Intake case data. + + + Students will join with one short class code after you start.
-
- +
+ +
+ Open an existing class +
+
+ + + Your access code is stored only on this device for class resume and never enters Intake case data. +
+
+ +
+
+
+ + +