ADR-089 — Kiosk stations for the online workspace: the station stays on Quiz and frames the portal¶
Status¶
Proposed (2026-10-09). Points 1, 7 and 9 below (scope, suspension,
sequencing) are the product owner's decisions of 2026-10-09. The other
points are assumed defaults the owner may amend at review; points 7 and 8
flag two weaker guarantees for the owner's attention. Nothing is
implemented, and implementation waits for proof B (§9).
Since 2026-10-11 its "GET start route" is POST /app/api/projects/:id/workspace/start (ADR-047, audit L7).
Amended 2026-10-11 (audit L24): the "Same-site exposure" consequence points
to ADR-047's amendment.
Scope: how a student works in the online workspace of an online_seb
project from an attested kiosk station, across the two hosts of Quiz and
its portal; what each side checks, and how the sitting ends. Not the
workspace question of evaluations (ADR-075).
Relations: amends ADR-051 §1 (a
kiosk session may be confined to a project), §4 (supersession for a
project), §7 (pairing for a project, the end and the navigation of such a
session), and its "evaluations only" reading of D21; amends
ADR-047's M6-07 amendment (a
kiosk launch claim beside seb). Related to
ADR-075 §11, which keeps governing
the kiosk for the workspace question of evaluations, and to
question 52 (c), which stays open for
that question. Uses the HS256 messages of ADR-047 §6 and ADR-078 §2, and
the confined sessions of
ADR-027's M6-07
addendum.
Context¶
An online_seb project is worked in the portal (code.chevallier.io)
from a seb session confined to the project (ADR-047, M6-07). When the
student's laptop cannot run SEB, the exam fallback is a kiosk station
(ADR-051), but nothing of that path reaches the portal:
- The start route does not serve a station.
GET /app/codespace/start/:id(apps/api/src/modules/codespace/routes.ts) declaresPROJECT_SEB,sessions: ["portal", "seb"](apps/api/src/auth/session.ts). The session hook (auth/plugin.ts) checksservesbefore any trust check, so akiosksession is anonymous there and the station is sent to the sign-in. Addingkioskto the route alone would not do:quiz_kioskis host-only with path/app/api(modules/kiosk/routes.ts, thesetCookieofverify), so the request would lack it andtrustRefusal(auth/trust.ts) would answerkiosk_station. The portal must never see that cookie; widening it toDomain=chevallier.iowould hand it to a host that serves student-controlled content. - Quiz and the portal are the same site (
chevallier.io; on stagingquiz.devandcode-dev). A same-site frame carriesStrictandLaxcookies and is neither blocked nor partitioned as third-party. - The station's machinery lives on a Quiz page. Re-attestation every
10 minutes (
apps/web/src/kiosk/useStationAttestation.ts), the 30 s retry while suspended, the return to/kiosk, and the423 kiosk_suspendedof writes all act on Quiz. A page that leaves Quiz for the portal stops them; the portal checks trust once, at/launch, then trusts its IP-boundexam_sessioncookie for 4 h (EXAM_COOKIE_MAX_AGE_MS), against a kiosk session's fixed 6 h. - A kiosk session is bound to an evaluation end to end:
kiosk_pairings.evaluation_id, the pairing'sopenSessionwithprojectId: null,watch.ts'ssittingOn,endKioskSessionskeyed by evaluation (auth/session.ts), andworkspaceStartRefusal'sfromSeb(@quiz/domain,workMode.ts).sessions.project_idand itssessions_one_activitycheck already exist (M6-07), andsupersede(auth/session.ts) already keys a project's confined sessions by (user, project) over both trusted kinds. - A station is shared. The portal's cookies (
exam_session, and the/s/<id>session cookie ofapps/codespace/src/web/routes.ts) stay in the station's browser profile after the student leaves, behind the same IP address, and nothing closes a portal session at the end of a sitting. - Scope.
00§0.6 and ADR-051 admit stations for exams only; ADR-075 §11 makes the kiosk wait for proof B, which is a property of SEB, not of a station.
Decision¶
- Scope: a fallback beside SEB (owner). A project accepts a kiosk
station only as an alternative to SEB, in
online_seb: "SEB or kiosk", as for an exam whose two settings are on. Kiosk-only projects are deferred. No new work mode. - The setting.
kiosk: boolean, a project setting meaningful only withonline_seb, set by the mode route (ProjectWorkModeBodygains it, the same owner and grant checks) and audited bycodespace.work_mode, whose payload carries it. It is read through one reader besidetrustedClientsOf(the project's trusted clients:["seb"]or["seb", "kiosk"]) and frozen at the first student launch with the mode (codespace_projects.first_launch_at,409 work_mode_frozen). Offered only whileKIOSK_ATTESTATIONis notoff. - The station stays on Quiz and frames the portal. A kiosk session of
a project is confined to one Quiz page of that project (the station
page): a thin bar (the station's label, the server's deadline, Leave
this station) above an
iframeof the portal. The station enters that page by a full navigation (location.replace, asapps/web/src/kiosk/useKioskStation.tsopens an exam today), so the page's own CSP (point 10) applies. Everything of ADR-051 runs unchanged on that page: re-attestation, the 30 s retry, the event stream, the return to/kiosk. The iframe hasallow="clipboard-read; clipboard-write"and asandboxwithoutallow-top-navigationor popups, so nothing the portal serves can take the station off the page. - The launch. The station page obtains the launch URL from
POST /app/api/projects/:id/workspace/launch, declared{ sessions: ["kiosk"], activities: ["project"], freshAttestation: true }: under/app/api, soquiz_kioskkeeps its host-only cookie and its path; a write, so a suspended station is refused (423) and CSRF applies; and an attestation less than two minutes old, as for the submit (ADR-051 §6). Access is loaded first (invariant 6), as the GET start route does: the session must be a non-delegatedkiosksession confined to THIS:id(the kiosk counterpart ofsebProjectSession,modules/guards.ts), and the project is loaded through the classroom's student branch with the caller's claimed seat (findStudentProjectView). Any mismatch, another project's kiosk session included, answers the 404 of a missing project. Only then doesworkspaceStartRefusal, which learnsfromKioskbesidefromSeb, decide the refusals a student can read. The answer carries the portal's/launchURL, whose token has a new claimkiosk: { deviceId }inLaunchTokenClaims(@quiz/contracts); it is sent withCache-Control: no-store, and the token is never logged, stored or audited (itsjtiis audited, as on the GET route). The station sets the iframe'ssrcto it. The GET start route keeps serving portals andsebsessions. The portal's/launchin exam mode refuses a token with neithersebnorkiosk; withkioskit skips the SEB header check and sets itsexam_sessionas today. The station's cookie never crosses hosts, and the portal never calls Google. - Pairing and supersession.
kiosk_pairingsgains a nullableproject_idwith a one-activity check, assessionshas.GET /app/api/pair/:codealso lists the open projects the student can start on a station (a claimed seat,online_seb,kioskon, before the effective deadline of ADR-078 §2); approval opens akiosksession withprojectId. One confined session per (user, project), the newer superseding the older whichever its kind: a station's session supersedes the student'ssebsession of that project and the reverse, assupersedealready does. Supervision in v1 is the phone only: projects have no live dashboard, hence no Assign a station. Alerts go to the audit and to the project's workspace list. - The end, and the shared station. A project kiosk session ends at the
earliest of: Leave this station; supersession; the student's
effective deadline, by the ticker-sweeper on the server's clock
(ADR-006, invariant 5); the 6 h fixed lifetime. Each ends the Quiz
session and its event streams, through a project variant of
endKioskSessions(new work: today's is keyed by evaluation), and enqueues a portal close: a pg-boss job, retried, idempotent, that sends a new HS256 service message (its own audience) naming the user and the project. The portal then invalidates both the user'sexam_sessioncookies issued before the close and the/s/<id>session of that workspace, and drops their sockets; the container and its volume are untouched. While a close for a station is pending, that station cannot be paired (/kioskshows "call the supervisor"); a close whose retries are exhausted is audited and keeps the station unpairable until an admin retires and reactivates it. So a workspace is never left open to the next student. Leaving early hands nothing in: the project is graded from its repository at the deadline, as ever. The portal never decides the end. - Suspension v1 (owner). A suspended station covers the iframe with
ADR-051's suspension screen, and
kiosk.suspendednames the project. The work is not frozen on the portal: like SEB, the portal checks trust at launch only. For the owner: N-SEC-10's suspension is weaker on this path — a screen over the iframe, while writes to the portal continue, where an exam's suspension is the server's423. A portal "suspend" message is a possible later step, not promised here. - The deadline. For the owner: the station's session ends at the
effective deadline, not at deadline plus the grace that ADR-078 §7
grants the git relay; a push in flight at the deadline still lands
within the grace, but the student cannot keep working on the station.
When the portal answers 403 because its 4 h
exam_sessionexpired before that, the station page relaunches (point 4); resuming a workspace consumes no quota. - Sequencing (owner). This ADR is written now. Implementation waits
until SEB projects are open to students (proof B recorded, M6-07).
Offering the setting in production further requires proof K on
staging, on a real station in a web kiosk with URL blocklist
*and allowlist {Quiz, portal}: the iframe loads; code-server works (its websocket, its service worker, its webviews, the clipboard, shortcuts not swallowed); the extension still answers the top page; the portal's cookies survive the 303 of/launchinside the iframe. Proof B is a property of SEB and gates nothing of the kiosk beyond the sequencing above. - Framing. Each portal instance sends
Content-Security-Policy: frame-ancestors 'self' <its Quiz origin>in every mode —'self'because code-server frames its own webviews on the portal's origin; the production portal names production Quiz only, the staging portal staging Quiz only — replacingX-Frame-Options: SAMEORIGIN(set today byapps/codespace/deploy/Caddyfile). Quiz addsframe-src <CODESPACE_URL>to its CSP (apps/api/src/csp.ts) on the station page only. - Same site, required. Quiz and its portal stay on the same registrable domain in each environment. A host move that breaks this reopens the decision: a cross-site frame loses its cookies.
- Capacity. A kiosk project is not opened to a class larger than the quota that carries it (ADR-047 amendment "M6-06" (C)); the portal enforces it (its 429, shown on the station). Room-sized sittings wait for M6-05.
- Audit.
codespace.launch_issuedgainskiosk: truefor a station's launch;kiosk.pairedandkiosk.suspendedmay name a project; the union gainscodespace.session_closed(the cause:left,superseded,deadline,expired) andcodespace.session_close_failedfor the portal close of point 6; the setting is audited as point 2 says. No token or cookie is written.
Consequences¶
- ADR-051's machinery is reused whole; the cost is on the edges: a
migration (
kiosk_pairings.project_id), the launch route andfromKiosk, the pairing list, the end sweep and the project variant ofendKioskSessions, the close job and the pairing hold,watchlearning projects, one CSP source on one page, and on the portal thekioskclaim,frame-ancestorsand the close message. - The extension and its
externally_connectablestay limited to Quiz's origins. The Admin console's URL allowlist gains the portal's origin;docs/development/kiosk.mdchanges when this ships, not before. - Same-site exposure: the portal's host is same-site with Quiz; what a session may serve there is ADR-047's amendment of 2026-10-11. Quiz's double-submit CSRF and the confinement of the session to the project bound it, and the iframe's sandbox keeps that content from navigating the station.
00§0.6 ("fallback stations for an exam") becomes "for an exam or a supervised project" when this ADR is accepted; the spec edit lands with acceptance. N-SEC-10's blocking exception applies at launch.- A future split of Quiz and its portal onto two sites breaks this design (point 11).
Alternatives considered¶
- The extension or the portal relays the attestation
(
externally_connectablegains the portal's origin). Rejected: that origin serves student-controlled content, so the station would become an attestation oracle for a laptop; and calling Google from the portal would put a Verified Access key on the VM that runs student containers, against the spirit of ADR-010. - A top-level 303 to the portal, as SEB does. Rejected: it loses the
re-attestation and the forced return to
/kioskat the deadline (the portal would have to decide the end, against invariant 5), and it leaves a live workspace to the next student on the station. - Widening
quiz_kiosktoDomain=chevallier.io. Rejected: the station's credential would reach the portal's host. - Widening
quiz_kiosk's path and addingkioskto the GET start route. Viable, but a GET passes a suspended station and escapes CSRF; the POST of point 4 gets both from the existing guards.