diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index 535ec638..d62120d6 100644 --- a/.agent/knowledge/data-contracts.md +++ b/.agent/knowledge/data-contracts.md @@ -15,6 +15,15 @@ Document API and data-shape assumptions that must stay compatible over time. ## Contracts +- Date: 2026-04-29 +- Surface: internal module | activity interface +- Contract: Activity-owned session normalizers that return a typed `data` object must preserve the shared `controlAuthority` record whenever the activity opts into single-instructor control ownership. +- Compatibility constraints: Shared ownership helpers store authority in `session.data.controlAuthority`, but activity-local normalizers like SyncDeck's may rebuild `data` field-by-field. If they omit `controlAuthority`, ownership appears to claim successfully in one code path and then vanishes on the next normalized read or websocket update. +- Validation rules: Normalization should round-trip `{ mode, ownerInstanceId, ownerTakenAt, overrideInherited }`, defaulting through the shared control-authority normalizer rather than trusting arbitrary persisted values. +- Evidence (schema/tests/path): `server/controlAuthority.ts`; `activities/syncdeck/server/routes.ts`; `activities/syncdeck/server/routes.test.ts` +- Follow-up action: When another activity with its own `normalizeSessionData(...)` adopts control authority, audit that normalizer immediately instead of assuming shared helper writes are enough. +- Owner: Codex + - Date: 2026-04-17 - Surface: activity interface | websocket | internal module - Contract: SyncDeck student-side instructor sync suppression must derive incoming slide indices from all Reveal state shapes that can drive a `setState`, including `payload.indices`, `payload.navigation.current`, `payload.revealState`, and top-level state fields. Same-horizontal vertical instructor moves must remain suppressible even when the deck emits `revealState` without a separate `indices` object. diff --git a/.agent/knowledge/security-notes.md b/.agent/knowledge/security-notes.md index 8a4c500c..06e6c65d 100644 --- a/.agent/knowledge/security-notes.md +++ b/.agent/knowledge/security-notes.md @@ -15,6 +15,15 @@ Track security-relevant boundaries, risks, and mitigation decisions. ## Notes +- Date: 2026-05-08 +- Area: Playwright SyncDeck instructor bootstrap +- Threat or risk: Seeding E2E manager pages by writing a real `instructorPasscode` into browser `sessionStorage` creates the same clear-text credential persistence pattern the product avoids where possible, and triggers CodeQL clear-text storage findings. +- Control or mitigation: SyncDeck manager bootstrap no longer persists instructor passcodes in browser storage, and the control-authority Playwright test now fulfills the manager passcode API from the test runner instead of injecting the secret into browser storage. +- Residual risk: The passcode still exists in test process memory and is returned to app code through the manager bootstrap API path; the mitigation removes browser storage persistence from the product and harness. +- Validation (test/review/path): `playwright/control-authority.spec.ts` +- Follow-up action: Prefer route/cookie/bootstrap shims or same-tab memory helpers over Web Storage writes when E2E tests need manager credentials. +- Owner: Codex + - Date: 2026-03-22 - Area: Playwright production-mode test secret handling - Threat or risk: Committing a fixed `PERSISTENT_SESSION_SECRET` in the Playwright harness creates secret-scanner noise and normalizes checking pseudo-secrets into the repo, even when the value is test-only. diff --git a/.agent/knowledge/testing-patterns.md b/.agent/knowledge/testing-patterns.md index 20572f80..cb09d617 100644 --- a/.agent/knowledge/testing-patterns.md +++ b/.agent/knowledge/testing-patterns.md @@ -15,6 +15,15 @@ Capture reusable test setup patterns, common failure modes, and reliability guid ## Entries +- Date: 2026-04-29 +- Scope: integration +- Pattern: Server-side maintenance timers created during route handling should call `unref?.()` when they are only background cleanup helpers, especially in activity route modules that schedule delayed pruning or telemetry refresh work. +- Why it helps: Real `setTimeout` handles created during focused route tests can keep the Node process alive and make a healthy suite hang with `Promise resolution is still pending but the event loop has already resolved`, even when the assertions themselves pass. `unref()` keeps production cleanup behavior while letting tests and short-lived processes exit cleanly. +- Example (file/path): `activities/video-sync/server/routes.ts`; `activities/video-sync/server/routes.test.ts` +- Failure signal: A route test file appears to pass in narrowed subsets but the full file hangs until external timeout because background timers remain referenced after the test completes. +- Follow-up action: Prefer `unref?.()` for non-critical cleanup timers, and if a test needs to inspect timer scheduling directly, patch `setTimeout` locally while still returning an object shape that tolerates optional `unref`. +- Owner: Codex + - Date: 2026-03-04 - Scope: integration - Pattern: Session-store mocks for route tests should return deep clones from `get()` and store clones on `set()` when production storage serializes records between calls. diff --git a/.agent/plans/control-authority-plan.md b/.agent/plans/control-authority-plan.md new file mode 100644 index 00000000..499af1aa --- /dev/null +++ b/.agent/plans/control-authority-plan.md @@ -0,0 +1,305 @@ +# Control Authority Plan + +## Status: In Progress + +This document is the working implementation plan for issue `#245`: replace fragile multi-instructor command races with an explicit server-owned control authority model that activities can opt into. + +## Goal + +Provide a shared, reusable control-authority framework so activities with manager-driven runtime commands can designate one instructor as authoritative at a time, while still supporting embedded parent/child inheritance and intentional local override when needed. + +## Problem Summary + +Some activities currently allow multiple instructor surfaces to emit authoritative runtime commands at the same time. In practice this can create feedback loops such as repeated play/pause churn when two instructor views are both trying to drive the same session. + +This is already showing up across more than one activity surface: +- SyncDeck manager runtime control +- embedded Video Sync inside a SyncDeck session + +The current pattern of local flags, echo suppression, and cooldowns is not a strong enough foundation. The fix should move authority into explicit shared session state enforced by the server. + +## Resolved Product Decisions + +- [x] First instructor connected to an authority-enabled session becomes the default owner. +- [x] Non-owning instructors should see disabled controls with clear feedback rather than silent failures. +- [x] `scope: 'inherited'` means parent authority is the default for embedded sessions, but an instructor may explicitly take control of the child session and override the inherited owner. +- [x] Activities need a way to decide which manager commands are authority-gated without forcing shared code to understand every activity protocol. +- [x] Ownership uses only stable instructor instance identity; no instructor display name is required. +- [x] There is no explicit release-control action. Any instructor may take control at any time. +- [x] If the current owner disconnects, ownership remains on that instance id until another instructor takes control. There is no auto-release or auto-claim behavior. +- [x] Simultaneous `take-control` attempts resolve as last-write-wins on the server. +- [x] `scope: 'inherited'` without a controlling parent falls back to local session authority. +- [x] Multiple embedded child sessions may each override inherited authority independently on a per-child-session basis. + +## Proposed Shared Activity Config + +Add a shared optional activity config section: + +```ts +controlAuthority: { + mode: 'single-instructor', + scope: 'session' | 'inherited', + gating: 'all' | 'none' | 'activity', +} +``` + +Interpretation: +- `mode: 'single-instructor'`: the activity uses explicit single-owner control authority +- `scope: 'session'`: the session owns its own authority state +- `scope: 'inherited'`: embedded sessions default to parent authority when a parent authority session exists, but may be locally overridden; without a controlling parent, this falls back to local session authority +- `gating: 'all'`: all manager runtime commands are authority-gated +- `gating: 'none'`: authority state may still exist for inheritance, UI state, or client-side-only coordination, but no shared server-side manager runtime filtering happens +- `gating: 'activity'`: the activity exports a classifier callback used by shared enforcement + +Default behavior: +- no `controlAuthority` config means no shared authority enforcement +- `scope: 'inherited'` only inherits when the session is actually embedded under a controlling parent; otherwise the runtime falls back to local session authority + +## Activity-Owned Hook + +When `gating: 'activity'`, the activity should be able to export a callback from its server/runtime entry that shared enforcement can call: + +```ts +export function isAuthorityGatedManagerMessage(message: unknown): boolean +``` + +Why callback over shared message schema: +- keeps activity protocol ownership inside the activity boundary +- avoids forcing all manager command traffic into one shared wire format before we actually need that +- lets shared code own enforcement while activities own command semantics + +## Shared Runtime Model + +### Resolved Authority + +Shared code should compute runtime authority resolution separate from raw config: + +```ts +interface ResolvedControlAuthority { + configuredScope: 'session' | 'inherited' + effectiveScope: 'session' | 'inherited' + authoritySessionId: string + inheritedFromSessionId: string | null +} +``` + +This lets the runtime distinguish: +- standalone activity using local session authority +- embedded activity currently following parent authority +- embedded activity that has taken local override + +### Session Authority State + +Authority-enabled sessions should persist an owner record similar to: + +```ts +interface SessionControlAuthorityState { + mode: 'single-instructor' + ownerInstanceId: string | null + ownerTakenAt: number | null + overrideInherited: boolean +} +``` + +Notes: +- `ownerInstanceId` should represent a stable instructor browser/tab identity, not a websocket connection id +- first connected instructor auto-claims ownership when no owner exists +- `ownerTakenAt` is display/history metadata only and should not be load-bearing for lifecycle decisions +- inherited authority may be reflected in child status without immediately copying parent ownership into child state unless the child is explicitly overridden +- owner disconnect does not auto-release authority; another instructor may take control explicitly at any time +- on rollout or when normalizing older sessions, an empty authority state should remain valid until the first manager connection auto-claims ownership + +### Instructor Identity Persistence + +The control owner id should survive normal reloads so an instructor does not silently lose ownership on refresh. + +Recommended direction: +- use a durable browser-scoped id stored in `localStorage` +- optionally combine it with a tab-scoped discriminator if the implementation needs distinct instructor identities per tab +- do not rely on `sessionStorage` alone for the owner identity + +## UI Direction + +Use a shared manager-facing control-authority status component that activities can place in their own UI. + +Expected behavior: +- owner sees active status such as `You have control` +- non-owner sees status such as `Another instructor currently has control` +- authority-gated controls render disabled for non-owners +- disabled controls include clear feedback such as `Take control to use playback controls` +- `Take Control` button is available to instructors who do not currently own the effective authority surface +- inherited child sessions can show `Following parent instructor` until locally overridden +- sibling embedded child sessions may each override inherited authority independently + +Placement guidance: +- full-width status card or toolbar section for top-level manager views +- compact banner/chip for embedded manager panels + +## API / WebSocket Direction + +Prefer websocket-first authority updates because this is live session runtime state. + +Client identity: +- each instructor manager tab gets a stable `instructorInstanceId` +- persist it with a durable browser id plus a tab-scoped discriminator so normal reloads keep ownership while separate tabs remain distinct + +Authority actions: +- client sends `take-control` +- server updates authority state on the resolved authority session +- server broadcasts authority updates to connected manager clients + +Representative messages: + +```ts +{ type: 'take-control', instructorInstanceId: 'inst_abc123' } +``` + +```ts +{ + type: 'control-authority-updated', + payload: { + mode: 'single-instructor', + ownerInstanceId: 'inst_abc123', + ownerTakenAt: 1777459200000, + authoritySessionId: 'session-123', + overrideInherited: false, + } +} +``` + +Shared enforcement rule: +- if the activity is not authority-enabled, accept commands normally +- if authority is enabled and a manager command is authority-gated, only the effective owner may emit it +- when a non-owner attempts a gated command, server rejects or ignores it and can send explanatory feedback +- if two instructors issue `take-control` at effectively the same time, whichever request is processed last becomes the owner and the resulting authority update is broadcast to all connected instructors + +## Implementation Checklist + +### 1. Shared config and typing + +- [x] Add `controlAuthority` to shared activity config types. +- [x] Add schema validation for `mode`, `scope`, and `gating`. +- [x] Document fallback semantics for `scope: 'inherited'` without a controlling parent. +- [x] Add runtime helpers to resolve effective authority scope and authority session id. + +### 2. Shared session model + +- [x] Define shared authority session-state shape. +- [ ] Add normalizer support so authority-enabled sessions recover safely after restart. +- [x] Define how embedded child sessions discover parent authority context. +- [x] Define local override semantics for inherited child sessions. +- [x] Define the persisted-empty-state behavior for older in-flight sessions so first manager connect auto-claims ownership intentionally. + +### 3. Shared server enforcement + +- [x] Add shared instructor instance identity handling for manager websocket/API traffic. +- [ ] Auto-assign first connected instructor as owner when no owner exists. +- [x] Add `take-control` server action and broadcast path. +- [ ] Add generic authority checks before processing manager runtime commands. +- [ ] Wire `gating: 'all' | 'none' | 'activity'` into enforcement. +- [ ] Add activity callback lookup/invocation for `gating: 'activity'`. +- [x] Return explicit non-owner feedback for gated command attempts while keeping the server state unchanged. +- [x] Document that owner disconnect does not auto-release authority and that explicit takeover is the only reassignment path. +- [x] Document last-write-wins behavior for concurrent `take-control` requests. + +### 4. Shared client plumbing + +- [x] Generate and persist stable `instructorInstanceId` values for manager tabs. +- [x] Subscribe authority-enabled manager views to live authority status updates. +- [x] Expose authority state to activity manager UIs through a shared hook/helper. +- [x] Share the default instructor control id generator across manager and launch flows. +- [x] Provide shared disabled-state helpers and feedback text for gated controls. + +### 5. Shared UI primitives + +- [x] Build a reusable control-authority status component. +- [x] Support at least one compact variant for embedded manager surfaces. +- [x] Include accessible disabled-state explanations and button labeling. +- [x] Ensure live authority updates are announced appropriately for assistive tech where needed. + +### 6. Activity adoption + +- [x] Add `controlAuthority` config to SyncDeck. +- [x] Decide SyncDeck gating mode. Current decision: `gating: 'all'` because manager runtime controls are the command surface. +- [x] Add `controlAuthority` config to Video Sync. +- [x] Decide Video Sync gating mode. Current expectation: `gating: 'activity'` or `all`, depending on final command surface. +- [x] Implement activity-owned command classifiers where `gating: 'activity'` is used or explicitly document that the adopted activity uses `gating: 'all'`. +- [x] Ensure embedded Video Sync defaults to inherited authority when launched under SyncDeck. +- [x] Ensure embedded Video Sync can be locally overridden by explicit `Take Control`. +- [x] Enforce Video Sync non-owner command rejection for config and playback command routes. +- [x] Disable Video Sync gated controls locally with explicit takeover feedback. +- [x] Add first-pass SyncDeck manager authority plumbing: instructor instance identity, takeover route, websocket authority status, and local gating for the websocket relay controls. +- [x] Enforce SyncDeck non-owner rejection for configure and embedded activity start/end routes. +- [x] Disable SyncDeck configure, activity launch, and embedded end controls locally for non-owners. +- [x] Ensure SyncDeck standalone/persistent launch configure requests include `instructorInstanceId`. + +### 7. Validation + +- [x] Add unit tests for config validation and authority resolution helpers. +- [ ] Add server tests for first-instructor auto-ownership. +- [x] Add server tests for `take-control` handoff. +- [x] Add server tests for inherited authority resolution and local child override. +- [x] Add server tests for non-owner gated-command rejection. +- [ ] Add server tests covering older sessions with empty authority state that auto-initialize on first manager connect. +- [x] Add client tests for disabled controls and authority status messaging. +- [x] Add activity-specific tests for SyncDeck command classification or confirm `gating: 'all'` avoids an activity classifier. +- [x] Add activity-specific tests for Video Sync command classification helpers. +- [x] Add browser-level E2E coverage for two instructor views on the same authority-enabled session: first instructor default owner, second instructor disabled, live takeover, and disabled-state flip after handoff. +- [ ] Add browser-level E2E coverage for embedded inherited authority plus local child override behavior. +- [x] Run scope-appropriate repo validation, likely `npm test`, and include `npm run test:e2e` for the multi-instructor manager and embedded authority scenarios when the harness can support them. + +## Current Progress Snapshot + +Implemented on this branch so far: +- shared `controlAuthority` activity config shape and schema validation +- shared authority state normalization, ownership helpers, and inherited/session resolution helpers +- durable browser-plus-tab instructor identity generation for manager clients +- activity config opt-in for Video Sync (`inherited`) and SyncDeck (`session`) +- Video Sync takeover endpoint and embedded-child local override handling +- Video Sync manager authority status plumbing and `Take Control` UI wiring +- Video Sync protocol/session payload support for `controlAuthority` +- Video Sync server-side owner checks for playback/config commands plus standalone first-owner auto-claim +- Video Sync manager disabled-state enforcement and takeover feedback for non-owners +- Video Sync unsynced-student prune timers now `unref()` so maintenance timers do not keep tests or Node processes alive unnecessarily +- SyncDeck manager websocket identity + authority status plumbing +- SyncDeck takeover endpoint plus first-owner auto-claim on instructor websocket auth +- SyncDeck session normalization now preserves shared `controlAuthority` state instead of dropping it on activity-local normalization +- SyncDeck manager shows first-pass control status UI and blocks non-owner websocket relay controls locally +- SyncDeck websocket relay path rejects non-owner instructor updates and reflects current ownership back to the non-owner socket +- SyncDeck configure and embedded lifecycle REST routes require the owning instructor instance and auto-claim only when ownership is empty +- SyncDeck manager sends `instructorInstanceId` with configure, embedded start, and embedded end requests +- SyncDeck standalone and persistent solo launch helpers now resolve the same browser/tab instructor identity before configuring the session +- Playwright coverage now exercises two SyncDeck instructor contexts, non-owner disabled controls, live takeover, and disabled-state flip after handoff +- shared `ControlAuthorityStatus` component now renders the live authority label and accessible takeover action for SyncDeck and Video Sync managers + +Still pending before this feature is complete: +- shared/generic first-instructor auto-ownership support beyond the current Video Sync adoption +- decide whether lower-risk SyncDeck report/download routes should remain passcode-only or also require current control ownership +- shared/generic command filtering beyond the current Video Sync and SyncDeck activity-local enforcement +- browser-level E2E coverage for embedded inherited authority plus local child override behavior + +## Suggested Rollout Order + +1. Land shared config/types/schema support. +2. Land shared authority session model and runtime resolution. +3. Land shared server enforcement with `gating: 'all' | 'none'`. +4. Add activity callback support for `gating: 'activity'`. +5. Land shared client identity/status plumbing and reusable UI. +6. Adopt in Video Sync and SyncDeck. +7. Add embedded inheritance and child override coverage. +8. Validate and update docs/knowledge notes with implementation discoveries. + +## Risks To Watch + +- Shared code accidentally becoming SyncDeck-specific instead of remaining generic. +- Embedded override semantics becoming confusing if parent and child ownership are not clearly labeled in UI. +- Reconnect behavior causing accidental ownership churn if instance identity is not stable across normal refreshes. +- Activity callback interfaces drifting across activities without a well-defined contract. +- Server/client disagreement about which commands are gated if any activity duplicates logic on both sides. + +## Validation Notes + +Because this feature changes live runtime coordination and embedded activity behavior, expect both server-level and browser-visible risk. If sandbox limits block full browser verification, keep `npm test` as the minimum merge gate, use `npm run test:codex` if port-binding is the blocker, and record any environment limitation in the implementation notes. + +Current environment note: +- `npx playwright test playwright/control-authority.spec.ts --project=chromium` requires escalation for local port binding and then fails because the Playwright Chromium browser binary is not installed in this container (`npx playwright install` needed in a network-enabled setup). diff --git a/activities/syncdeck/activity.config.ts b/activities/syncdeck/activity.config.ts index e69a22b1..afd6c134 100644 --- a/activities/syncdeck/activity.config.ts +++ b/activities/syncdeck/activity.config.ts @@ -49,13 +49,14 @@ const syncdeckConfig: ActivityConfig = { }, ], createSessionBootstrap: { + historyState: ['instructorPasscode'], + transientOnly: true, selectedOptionsToSessionData: ['presentationUrl'], - sessionStorage: [ - { - keyPrefix: 'syncdeck_instructor_', - responseField: 'instructorPasscode', - }, - ], + }, + controlAuthority: { + mode: 'single-instructor', + scope: 'session', + gating: 'all', }, manageDashboard: { customPersistentLinkBuilder: true, diff --git a/activities/syncdeck/client/index.test.ts b/activities/syncdeck/client/index.test.ts index b98c9dd7..7220482f 100644 --- a/activities/syncdeck/client/index.test.ts +++ b/activities/syncdeck/client/index.test.ts @@ -63,14 +63,11 @@ void test('launchSyncDeckPersistentSoloEntry creates and configures a solo sessi assert.equal(requests.length, 2) assert.equal(requests[0]?.input, '/api/syncdeck/create') assert.equal(requests[1]?.input, '/api/syncdeck/syncdeck-solo-1/configure') - assert.deepEqual( - JSON.parse(String(requests[1]?.init?.body ?? '{}')), - { - presentationUrl: 'https://slides.example/deck', - instructorPasscode: 'pass-123', - standaloneMode: true, - }, - ) + const configureBody = JSON.parse(String(requests[1]?.init?.body ?? '{}')) as Record + assert.equal(configureBody.presentationUrl, 'https://slides.example/deck') + assert.equal(configureBody.instructorPasscode, 'pass-123') + assert.equal(configureBody.standaloneMode, true) + assert.equal(typeof configureBody.instructorInstanceId, 'string') } finally { globalThis.fetch = originalFetch } diff --git a/activities/syncdeck/client/manager/SyncDeckManager.test.tsx b/activities/syncdeck/client/manager/SyncDeckManager.test.tsx index 379028de..255164fc 100644 --- a/activities/syncdeck/client/manager/SyncDeckManager.test.tsx +++ b/activities/syncdeck/client/manager/SyncDeckManager.test.tsx @@ -43,6 +43,7 @@ import { resolveManagerActivityRequestBatchInputs } from './SyncDeckManager.js' import { resolveManagerPreloadRequestBatchInputs } from './SyncDeckManager.js' import { processManagerBundlePreloadRequests } from './SyncDeckManager.js' import { processManagerPreloadRequests } from './SyncDeckManager.js' +import { buildSyncDeckInstructorWsUrl } from './SyncDeckManager.js' import { runEmbeddedStartWithPendingRetry } from './SyncDeckManager.js' import { resolveCompletedEmbeddedBootstrapChildSessionIds } from './SyncDeckManager.js' import { resolveEmbeddedBootstrapBackfillRetryDelayMs } from './SyncDeckManager.js' @@ -113,6 +114,27 @@ void test('SyncDeckManager pre-fills presentation URL from query params', () => assert.match(html, /value="https:\/\/slides\.example\/deck"/i) }) +void test('buildSyncDeckInstructorWsUrl includes instructor instance identity for manager sockets', () => { + assert.equal( + buildSyncDeckInstructorWsUrl({ + sessionId: 'session-123', + instructorInstanceId: 'inst-123', + location: { protocol: 'https:', host: 'bits.example.test' }, + isConfigurePanelOpen: false, + }), + 'wss://bits.example.test/ws/syncdeck?instructorInstanceId=inst-123&sessionId=session-123&role=instructor', + ) + assert.equal( + buildSyncDeckInstructorWsUrl({ + sessionId: 'session-123', + instructorInstanceId: null, + location: { protocol: 'https:', host: 'bits.example.test' }, + isConfigurePanelOpen: false, + }), + null, + ) +}) + void test('activitySupportsEmbeddedReport reflects shared activity config metadata', () => { const entries = resolveSyncDeckActivityPickerEntries([ { id: 'gallery-walk', name: 'Gallery Walk', description: 'Peer feedback', reportEndpoint: '/api/gallery-walk/:sessionId/report' }, diff --git a/activities/syncdeck/client/manager/SyncDeckManager.tsx b/activities/syncdeck/client/manager/SyncDeckManager.tsx index 53cceff8..7f7c96eb 100644 --- a/activities/syncdeck/client/manager/SyncDeckManager.tsx +++ b/activities/syncdeck/client/manager/SyncDeckManager.tsx @@ -1,8 +1,12 @@ import { useResilientWebSocket } from '@src/hooks/useResilientWebSocket' +import { + createDefaultInstructorControlId, + resolveOrCreateInstructorControlInstanceId, +} from '@src/components/common/instructorControlIdentity' +import ControlAuthorityStatus from '@src/components/common/ControlAuthorityStatus' import { storeCreateSessionBootstrapPayload } from '@src/components/common/manageDashboardUtils' import { resolvePersistentSessionEntryPolicy, type PersistentSessionEntryPolicy } from '../../../../types/waitingRoom.js' import { runSyncDeckPresentationPreflight } from '../shared/presentationPreflight.js' -import { buildSyncDeckPasscodeKey } from '../shared/authStorage.js' import { getStudentPresentationCompatibilityError, } from '../shared/presentationUrlCompatibility.js' @@ -99,6 +103,20 @@ interface SyncDeckInstructorAuthMessage { instructorPasscode: string } +interface SyncDeckControlAuthority { + mode: 'single-instructor' + ownerInstanceId: string | null + ownerTakenAt: number | null + overrideInherited: boolean +} + +const EMPTY_SYNCDECK_CONTROL_AUTHORITY: SyncDeckControlAuthority = { + mode: 'single-instructor', + ownerInstanceId: null, + ownerTakenAt: null, + overrideInherited: false, +} + interface RevealCommandPayload { [key: string]: unknown } @@ -160,15 +178,17 @@ function isPlainObject(value: unknown): value is Record { export function buildSyncDeckInstructorWsUrl(params: { sessionId: string | null | undefined + instructorInstanceId: string | null | undefined location: Pick | null | undefined isConfigurePanelOpen: boolean }): string | null { - if (!params.sessionId || params.location == null || params.isConfigurePanelOpen) { + if (!params.sessionId || !params.instructorInstanceId || params.location == null || params.isConfigurePanelOpen) { return null } const protocol = params.location.protocol === 'https:' ? 'wss:' : 'ws:' const query = new URLSearchParams({ + instructorInstanceId: params.instructorInstanceId, sessionId: params.sessionId, role: 'instructor', }) @@ -189,6 +209,28 @@ export function createSyncDeckInstructorWsAuthMessage( return JSON.stringify(message) } +function isSyncDeckControlOwner( + controlAuthority: SyncDeckControlAuthority | null | undefined, + instructorInstanceId: string | null | undefined, +): boolean { + return ( + controlAuthority?.ownerInstanceId != null && + instructorInstanceId != null && + controlAuthority.ownerInstanceId === instructorInstanceId + ) +} + +function canUseSyncDeckInstructorControls( + controlAuthority: SyncDeckControlAuthority | null | undefined, + instructorInstanceId: string | null | undefined, +): boolean { + if (controlAuthority?.ownerInstanceId == null) { + return true + } + + return isSyncDeckControlOwner(controlAuthority, instructorInstanceId) +} + function stripOverviewFromStateEnvelope(data: unknown): unknown { if ( data == null || @@ -534,6 +576,20 @@ export function normalizeStoredInstructorPasscode(value: string | null): string return trimmed.length > 0 ? trimmed : null } +function readBootstrapInstructorPasscode(state: unknown): string | null { + if (state == null || typeof state !== 'object' || Array.isArray(state)) { + return null + } + + const payload = (state as { createSessionPayload?: unknown }).createSessionPayload + if (payload == null || typeof payload !== 'object' || Array.isArray(payload)) { + return null + } + + const value = (payload as { instructorPasscode?: unknown }).instructorPasscode + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null +} + export function validatePresentationUrl(value: string, hostProtocol?: string | null, userAgent?: string | null): boolean { return value.trim().length > 0 && getStudentPresentationCompatibilityError({ value, @@ -1817,6 +1873,16 @@ const SyncDeckManager: FC = () => { const { sessionId } = useParams<{ sessionId?: string }>() const location = useLocation() const navigate = useNavigate() + const instructorInstanceId = useMemo(() => { + if (sessionId == null || typeof window === 'undefined') { + return null + } + + return resolveOrCreateInstructorControlInstanceId({ + localStorage: window.localStorage, + sessionStorage: window.sessionStorage, + }, createDefaultInstructorControlId) + }, [sessionId]) const [copiedValue, setCopiedValue] = useState(null) const [presentationUrl, setPresentationUrl] = useState(() => { const params = new URLSearchParams(location.search) @@ -1828,12 +1894,17 @@ const SyncDeckManager: FC = () => { const [, setStartSuccess] = useState(null) const [isConfigurePanelOpen, setIsConfigurePanelOpen] = useState(true) const [instructorPasscode, setInstructorPasscode] = useState(null) + const bootstrapInstructorPasscode = useMemo( + () => readBootstrapInstructorPasscode(location.state), + [location.state], + ) const [persistentUrlHashFallback, setPersistentUrlHashFallback] = useState(null) const [persistentEntryPolicyFallback, setPersistentEntryPolicyFallback] = useState(null) const [isPasscodeReady, setIsPasscodeReady] = useState(false) const [hasAutoStarted, setHasAutoStarted] = useState(false) const [instructorConnectionState, setInstructorConnectionState] = useState<'connected' | 'disconnected'>('disconnected') const [instructorConnectionTooltip, setInstructorConnectionTooltip] = useState('Not connected to sync server') + const [controlAuthority, setControlAuthority] = useState(EMPTY_SYNCDECK_CONTROL_AUTHORITY) const [isInstructorSyncEnabled, setIsInstructorSyncEnabled] = useState(true) const [connectedStudentCount, setConnectedStudentCount] = useState(0) const [students, setStudents] = useState([]) @@ -2127,17 +2198,26 @@ const SyncDeckManager: FC = () => { } }, [presentationUrlError, isConfigurePanelOpen]) + const hasControl = isSyncDeckControlOwner(controlAuthority, instructorInstanceId) + const canUseInstructorControls = canUseSyncDeckInstructorControls(controlAuthority, instructorInstanceId) + const controlStatusLabel = hasControl + ? 'You have control' + : controlAuthority.ownerInstanceId + ? 'Another instructor has control' + : 'Take control to present from this manager' + const buildInstructorWsUrl = useCallback((): string | null => { if (typeof window === 'undefined') { return null } return buildSyncDeckInstructorWsUrl({ + instructorInstanceId, sessionId, location: window.location, isConfigurePanelOpen, }) - }, [sessionId, isConfigurePanelOpen]) + }, [instructorInstanceId, sessionId, isConfigurePanelOpen]) const { connect: connectInstructorWs, disconnect: disconnectInstructorWs, socketRef: instructorSocketRef } = useResilientWebSocket({ @@ -2168,7 +2248,22 @@ const SyncDeckManager: FC = () => { }, onMessage: (event) => { try { - const message = JSON.parse(event.data) as SyncDeckStudentPresenceMessage + const message = JSON.parse(event.data) as SyncDeckStudentPresenceMessage & { + payload?: SyncDeckControlAuthority | SyncDeckStudentPresenceMessage['payload'] + } + if (message.type === 'syncdeck-control-authority') { + const payload = message.payload + if (payload && typeof payload === 'object') { + const controlPayload = payload as Record + setControlAuthority({ + mode: 'single-instructor', + ownerInstanceId: typeof controlPayload.ownerInstanceId === 'string' ? controlPayload.ownerInstanceId : null, + ownerTakenAt: typeof controlPayload.ownerTakenAt === 'number' && Number.isFinite(controlPayload.ownerTakenAt) ? controlPayload.ownerTakenAt : null, + overrideInherited: controlPayload.overrideInherited === true, + }) + } + return + } const statePayload = extractSyncDeckStatePayload(message) if (statePayload != null) { const embeddedLifecyclePayload = parseEmbeddedLifecyclePayload(statePayload) @@ -2424,12 +2519,8 @@ const SyncDeckManager: FC = () => { let isCancelled = false const loadInstructorPasscode = async (): Promise => { - const cachedPasscode = normalizeStoredInstructorPasscode( - window.sessionStorage.getItem(buildSyncDeckPasscodeKey(sessionId)), - ) - if (!isCancelled) { - setInstructorPasscode(cachedPasscode) + setInstructorPasscode(bootstrapInstructorPasscode) setPersistentUrlHashFallback(null) setPersistentEntryPolicyFallback(null) } @@ -2440,7 +2531,7 @@ const SyncDeckManager: FC = () => { }) if (!response.ok) { if (!isCancelled) { - if (!cachedPasscode) { + if (!bootstrapInstructorPasscode) { setInstructorPasscode(null) } setPersistentUrlHashFallback(null) @@ -2451,11 +2542,10 @@ const SyncDeckManager: FC = () => { const payload = (await response.json()) as InstructorPasscodeResponsePayload if (typeof payload.instructorPasscode === 'string' && payload.instructorPasscode.length > 0) { - window.sessionStorage.setItem(buildSyncDeckPasscodeKey(sessionId), payload.instructorPasscode) if (!isCancelled) { setInstructorPasscode(payload.instructorPasscode) } - } else if (!isCancelled && !cachedPasscode) { + } else if (!isCancelled && !bootstrapInstructorPasscode) { setInstructorPasscode(null) } @@ -2481,7 +2571,7 @@ const SyncDeckManager: FC = () => { } } catch { if (!isCancelled) { - if (!cachedPasscode) { + if (!bootstrapInstructorPasscode) { setInstructorPasscode(null) } setPersistentUrlHashFallback(null) @@ -2500,10 +2590,10 @@ const SyncDeckManager: FC = () => { return () => { isCancelled = true } - }, [hostProtocol, sessionId, userAgent]) + }, [bootstrapInstructorPasscode, hostProtocol, sessionId, userAgent]) useEffect(() => { - if (!sessionId || !instructorPasscode) { + if (!sessionId || !instructorPasscode || !instructorInstanceId) { return } @@ -2533,6 +2623,7 @@ const SyncDeckManager: FC = () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instructorPasscode, + instructorInstanceId, activityId: request.activityId, instanceKey: request.instanceKey, ...(request.location ? { location: request.location } : {}), @@ -2613,7 +2704,7 @@ const SyncDeckManager: FC = () => { pendingEmbeddedBootstrapChildSessionIdsRef.current.delete(request.childSessionId) } } - }, [clearEmbeddedBootstrapBackfillRetryTimeout, embeddedActivities, embeddedBootstrapBackfillRetryNonce, instructorPasscode, sessionId]) + }, [clearEmbeddedBootstrapBackfillRetryTimeout, embeddedActivities, embeddedBootstrapBackfillRetryNonce, instructorInstanceId, instructorPasscode, sessionId]) const copyValue = async (value: string): Promise => { if (!value || typeof navigator === 'undefined' || navigator.clipboard === undefined) { @@ -2625,6 +2716,35 @@ const SyncDeckManager: FC = () => { setTimeout(() => setCopiedValue((current) => (current === value ? null : current)), 1500) } + const takeControl = async (): Promise => { + if (!sessionId || !instructorPasscode || !instructorInstanceId) { + return + } + + try { + const response = await fetch(`/api/syncdeck/${encodeURIComponent(sessionId)}/control-authority/take`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + instructorPasscode, + instructorInstanceId, + }), + }) + const payload = await response.json() as { controlAuthority?: SyncDeckControlAuthority } + if (!response.ok) { + throw new Error('Unable to take control.') + } + if (payload.controlAuthority) { + setControlAuthority(payload.controlAuthority) + } + setStartError(null) + setStartSuccess(null) + } catch { + setStartError('Unable to take control right now.') + setStartSuccess(null) + } + } + const handleEndSession = async (): Promise => { if (!sessionId) return @@ -2654,6 +2774,12 @@ const SyncDeckManager: FC = () => { return } + if (!canUseInstructorControls) { + setStartError('Take control to use instructor presentation controls.') + setStartSuccess(null) + return + } + const socket = instructorSocketRef.current if (!socket || socket.readyState !== WS_OPEN_READY_STATE) { return @@ -2832,7 +2958,7 @@ const SyncDeckManager: FC = () => { return false } - if (!instructorPasscode) { + if (!instructorPasscode || !instructorInstanceId) { if (!background) { setStartError('Instructor passcode missing. Refresh SyncDeck manager and try again.') setStartSuccess(null) @@ -2840,6 +2966,14 @@ const SyncDeckManager: FC = () => { return false } + if (!canUseInstructorControls) { + if (!background) { + setStartError('Take control to launch embedded activities.') + setStartSuccess(null) + } + return false + } + return await runEmbeddedStartWithPendingRetry({ instanceKey: request.instanceKey, background, @@ -2855,6 +2989,7 @@ const SyncDeckManager: FC = () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instructorPasscode, + instructorInstanceId, activityId: request.activityId, instanceKey: request.instanceKey, ...(request.location ? { location: request.location } : {}), @@ -2954,7 +3089,7 @@ const SyncDeckManager: FC = () => { }, }) }, - [sessionId, instructorPasscode, embeddedActivities, instructorIndicesState], + [canUseInstructorControls, sessionId, instructorInstanceId, instructorPasscode, embeddedActivities, instructorIndicesState], ) const loadDeckActivityRequests = useCallback(async (): Promise => { @@ -3238,6 +3373,7 @@ const SyncDeckManager: FC = () => { presentationUrl: normalizedUrl, entryPolicy, instructorPasscode, + instructorInstanceId, ...(urlHash ? { urlHash } : {}), }), }) @@ -3852,7 +3988,13 @@ const SyncDeckManager: FC = () => { } const endEmbeddedActivity = async (instanceKey: string): Promise => { - if (!sessionId || !instructorPasscode) { + if (!sessionId || !instructorPasscode || !instructorInstanceId) { + return + } + + if (!canUseInstructorControls) { + setStartError('Take control to end embedded activities.') + setStartSuccess(null) return } @@ -3863,6 +4005,7 @@ const SyncDeckManager: FC = () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instructorPasscode, + instructorInstanceId, instanceKey, }), }) @@ -4006,7 +4149,7 @@ const SyncDeckManager: FC = () => { }`} title={isInstructorSyncEnabled ? 'Disable instructor sync' : 'Enable instructor sync'} aria-label={isInstructorSyncEnabled ? 'Disable instructor sync' : 'Enable instructor sync'} - disabled={isConfigurePanelOpen} + disabled={isConfigurePanelOpen || !canUseInstructorControls} > 🔗 @@ -4016,7 +4159,7 @@ const SyncDeckManager: FC = () => { className="ml-2 px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" title="Force sync students to current position" aria-label="Force sync students to current position" - disabled={isConfigurePanelOpen || instructorConnectionState !== 'connected' || !isInstructorSyncEnabled} + disabled={isConfigurePanelOpen || instructorConnectionState !== 'connected' || !isInstructorSyncEnabled || !canUseInstructorControls} > 📍 @@ -4030,7 +4173,7 @@ const SyncDeckManager: FC = () => { }`} title={isPresentationPaused ? 'Resume presentation' : 'Pause presentation'} aria-label={isPresentationPaused ? 'Resume presentation' : 'Pause presentation'} - disabled={isConfigurePanelOpen} + disabled={isConfigurePanelOpen || !canUseInstructorControls} > ⬛ @@ -4044,7 +4187,7 @@ const SyncDeckManager: FC = () => { }`} title="Toggle chalkboard screen" aria-label="Toggle chalkboard screen" - disabled={isConfigurePanelOpen} + disabled={isConfigurePanelOpen || !canUseInstructorControls} > 🖍️ @@ -4058,7 +4201,7 @@ const SyncDeckManager: FC = () => { }`} title="Toggle pen overlay" aria-label="Toggle pen overlay" - disabled={isConfigurePanelOpen} + disabled={isConfigurePanelOpen || !canUseInstructorControls} > ✏️ @@ -4081,7 +4224,7 @@ const SyncDeckManager: FC = () => { className="px-2 py-1 rounded border border-gray-300 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" aria-expanded={isActivityPickerOpen} aria-controls="syncdeck-activity-picker-panel" - disabled={isConfigurePanelOpen} + disabled={isConfigurePanelOpen || !canUseInstructorControls} > Activities @@ -4092,6 +4235,15 @@ const SyncDeckManager: FC = () => { > Students: {connectedStudentCount} + { + void takeControl() + }} + buttonClassName="rounded border border-gray-300 bg-white px-2 py-1 text-xs font-semibold text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50" + /> Join Code: { onClick={() => { handleEmbeddedEndControlClick(instanceKey) }} - disabled={endingEmbeddedInstanceKey === instanceKey} + disabled={endingEmbeddedInstanceKey === instanceKey || !canUseInstructorControls} className="px-2 py-1 rounded border border-red-600 text-xs font-semibold text-red-600 hover:bg-red-50 disabled:opacity-60" > {endingEmbeddedInstanceKey === instanceKey @@ -4265,7 +4417,7 @@ const SyncDeckManager: FC = () => { + void takeControl()} + /> )} @@ -1079,6 +1230,7 @@ export default function VideoSyncManager() { Session: {sessionId ?? '—'}
+ {!hasControl ? : null}
@@ -1095,6 +1247,14 @@ export default function VideoSyncManager() { {state.videoId ? (
+ {!canUseInstructorControls ? ( +
+
+

Take control to use playback controls for this session.

+ +
+
+ ) : null}
) : (
@@ -1105,6 +1265,7 @@ export default function VideoSyncManager() {
Video: {state.videoId || 'Not configured'} + {controlStatusLabel} Playing: {state.isPlaying ? 'Yes' : 'No'} Position: {displayPosition.toFixed(2)}s Connections: {telemetry.connections.activeCount} diff --git a/activities/video-sync/client/protocol.test.ts b/activities/video-sync/client/protocol.test.ts index d1024a49..5f1cc100 100644 --- a/activities/video-sync/client/protocol.test.ts +++ b/activities/video-sync/client/protocol.test.ts @@ -142,7 +142,6 @@ void test('parseVideoSyncStateMessagePayload normalizes legacy manager updates t updatedBy: 'instructor', serverTimestampMs: 1234, }, - telemetry: undefined, }, ) }) diff --git a/activities/video-sync/client/protocol.ts b/activities/video-sync/client/protocol.ts index 692a3477..4717353e 100644 --- a/activities/video-sync/client/protocol.ts +++ b/activities/video-sync/client/protocol.ts @@ -5,6 +5,13 @@ export type VideoSyncMessageType = | 'telemetry-update' | 'error' +export interface VideoSyncControlAuthority { + mode: 'single-instructor' + ownerInstanceId: string | null + ownerTakenAt: number | null + overrideInherited: boolean +} + export interface VideoSyncState { provider: 'youtube' videoId: string @@ -47,10 +54,12 @@ export interface VideoSyncWsEnvelope { export interface VideoSyncStateMessagePayload { state?: VideoSyncState telemetry?: VideoSyncTelemetry + controlAuthority?: VideoSyncControlAuthority } export interface VideoSyncTelemetryMessagePayload { telemetry?: VideoSyncTelemetry + controlAuthority?: VideoSyncControlAuthority } export interface VideoSyncErrorMessagePayload { @@ -69,6 +78,19 @@ function isNullableFiniteNumber(value: unknown): value is number | null { return value === null || isFiniteNumber(value) } +export function isVideoSyncControlAuthority(value: unknown): value is VideoSyncControlAuthority { + if (!isRecord(value)) { + return false + } + + return ( + value.mode === 'single-instructor' && + (value.ownerInstanceId === null || typeof value.ownerInstanceId === 'string') && + isNullableFiniteNumber(value.ownerTakenAt) && + typeof value.overrideInherited === 'boolean' + ) +} + function normalizeUpdatedBy(value: unknown): VideoSyncState['updatedBy'] | null { if (value === 'instructor' || value === 'manager') { return 'instructor' @@ -147,15 +169,23 @@ export function parseVideoSyncStateMessagePayload(payload: unknown): VideoSyncSt if ('telemetry' in payload && payload.telemetry !== undefined && !isVideoSyncTelemetry(payload.telemetry)) { return null } + if ('controlAuthority' in payload && payload.controlAuthority !== undefined && !isVideoSyncControlAuthority(payload.controlAuthority)) { + return null + } return { - state: isVideoSyncState(payload.state) + ...(isVideoSyncState(payload.state) ? { - ...payload.state, - updatedBy: normalizeUpdatedBy(payload.state.updatedBy) ?? 'system', + state: { + ...payload.state, + updatedBy: normalizeUpdatedBy(payload.state.updatedBy) ?? 'system', + }, } - : undefined, - telemetry: isVideoSyncTelemetry(payload.telemetry) ? payload.telemetry : undefined, + : {}), + ...(isVideoSyncTelemetry(payload.telemetry) ? { telemetry: payload.telemetry } : {}), + ...(isVideoSyncControlAuthority(payload.controlAuthority) + ? { controlAuthority: payload.controlAuthority } + : {}), } } @@ -167,9 +197,15 @@ export function parseVideoSyncTelemetryMessagePayload(payload: unknown): VideoSy if ('telemetry' in payload && payload.telemetry !== undefined && !isVideoSyncTelemetry(payload.telemetry)) { return null } + if ('controlAuthority' in payload && payload.controlAuthority !== undefined && !isVideoSyncControlAuthority(payload.controlAuthority)) { + return null + } return { - telemetry: isVideoSyncTelemetry(payload.telemetry) ? payload.telemetry : undefined, + ...(isVideoSyncTelemetry(payload.telemetry) ? { telemetry: payload.telemetry } : {}), + ...(isVideoSyncControlAuthority(payload.controlAuthority) + ? { controlAuthority: payload.controlAuthority } + : {}), } } diff --git a/activities/video-sync/server/routes.test.ts b/activities/video-sync/server/routes.test.ts index 9ca2c6d8..ffa82e17 100644 --- a/activities/video-sync/server/routes.test.ts +++ b/activities/video-sync/server/routes.test.ts @@ -14,6 +14,8 @@ import setupVideoSyncRoutes, { waitForInstructorAuthMessage } from './routes.js' const TEST_INSTRUCTOR_PASSCODE = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' const ALT_TEST_INSTRUCTOR_PASSCODE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' +const TEST_INSTRUCTOR_INSTANCE_ID = 'inst-123' +const ALT_TEST_INSTRUCTOR_INSTANCE_ID = 'inst-456' type RouteHandler = ( req: { params: Record; body?: unknown; cookies?: Record }, @@ -414,6 +416,12 @@ void test('session get route redacts instructor-only fields from public payload' assert.equal(payload.data?.standaloneMode, false) assert.equal(typeof payload.data?.state, 'object') assert.equal(typeof payload.data?.telemetry, 'object') + assert.deepEqual(payload.data?.controlAuthority, { + mode: 'single-instructor', + ownerInstanceId: null, + ownerTakenAt: null, + overrideInherited: false, + }) assert.equal('instructorPasscode' in (payload.data ?? {}), false) }) @@ -776,6 +784,106 @@ void test('session get route returns projected playback without persisting ordin } }) +void test('take control claims video-sync session control authority for the requesting instructor instance', async () => { + const app = createMockApp() + const ws = createMockWs() as unknown as WsRouter + const storeState = createSessionStore({ s1: createVideoSyncSession('s1') }) + + setupVideoSyncRoutes(app, storeState.sessions, ws) + + const handler = app.handlers.post['/api/video-sync/:sessionId/control-authority/take'] + assert.equal(typeof handler, 'function') + + const res = createResponse() + await handler?.( + { + params: { sessionId: 's1' }, + body: { + instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + instructorInstanceId: 'inst-1', + }, + }, + res, + ) + + assert.equal(res.statusCode, 200) + assert.deepEqual(res.body, { + success: true, + controlAuthority: { + mode: 'single-instructor', + ownerInstanceId: 'inst-1', + ownerTakenAt: (res.body as { controlAuthority: { ownerTakenAt: number } }).controlAuthority.ownerTakenAt, + overrideInherited: false, + }, + }) + + const persisted = storeState.store.s1?.data as { + controlAuthority?: Record + } + assert.equal(persisted.controlAuthority?.ownerInstanceId, 'inst-1') + assert.equal(persisted.controlAuthority?.overrideInherited, false) +}) + +void test('take control marks embedded video-sync sessions as locally overriding inherited authority', async () => { + const app = createMockApp() + const ws = createMockWs() as unknown as WsRouter + const session = createVideoSyncSession('child-1') + ;(session.data as Record).embeddedParentSessionId = 'parent-syncdeck' + const storeState = createSessionStore({ 'child-1': session }) + + setupVideoSyncRoutes(app, storeState.sessions, ws) + + const handler = app.handlers.post['/api/video-sync/:sessionId/control-authority/take'] + assert.equal(typeof handler, 'function') + + const res = createResponse() + await handler?.( + { + params: { sessionId: 'child-1' }, + body: { + instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + instructorInstanceId: 'inst-2', + }, + }, + res, + ) + + assert.equal(res.statusCode, 200) + const persisted = storeState.store['child-1']?.data as { + controlAuthority?: Record + } + assert.equal(persisted.controlAuthority?.ownerInstanceId, 'inst-2') + assert.equal(persisted.controlAuthority?.overrideInherited, true) +}) + +void test('take control rejects missing instructor instance ids', async () => { + const app = createMockApp() + const ws = createMockWs() as unknown as WsRouter + const storeState = createSessionStore({ s1: createVideoSyncSession('s1') }) + + setupVideoSyncRoutes(app, storeState.sessions, ws) + + const handler = app.handlers.post['/api/video-sync/:sessionId/control-authority/take'] + assert.equal(typeof handler, 'function') + + const res = createResponse() + await handler?.( + { + params: { sessionId: 's1' }, + body: { + instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + }, + }, + res, + ) + + assert.equal(res.statusCode, 400) + assert.deepEqual(res.body, { + error: 'INVALID_INSTRUCTOR_INSTANCE_ID', + message: 'Valid instructorInstanceId is required', + }) +}) + void test('session get route persists the session when projected playback reaches stopSec', async () => { const originalDateNow = Date.now const nowMs = 12_000 @@ -875,7 +983,7 @@ void test('session patch returns invalid source url for unsupported non-YouTube await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://vimeo.com/1234', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://vimeo.com/1234', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -901,7 +1009,7 @@ void test('session patch returns invalid source url for malformed url input', as await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'not a url', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'not a url', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -927,7 +1035,7 @@ void test('session patch returns invalid video id for YouTube url without a usab await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://www.youtube.com/watch?list=abc123', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://www.youtube.com/watch?list=abc123', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -953,7 +1061,7 @@ void test('session patch accepts youtu.be urls with extra path segments by using await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ/extra-segment?t=45', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ/extra-segment?t=45', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -979,7 +1087,7 @@ void test('session patch returns invalid video id for malformed youtu.be ids', a await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgX$Q', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgX$Q', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1005,7 +1113,7 @@ void test('session patch returns invalid time range when stop time is before par await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 20, instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 20, instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1031,7 +1139,7 @@ void test('session patch returns invalid stopSec when stopSec is not numeric', a await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: '120', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: '120', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1057,7 +1165,7 @@ void test('session patch normalizes youtube source and publishes extensible enve await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1103,6 +1211,7 @@ void test('session patch can mark a configured session as standalone', async () body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, standaloneMode: true, }, }, @@ -1130,6 +1239,14 @@ void test('session patch can mark a configured session as standalone', async () : undefined, }, telemetry: updated.telemetry, + controlAuthority: { + mode: 'single-instructor', + ownerInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, + ownerTakenAt: (updated.controlAuthority != null && typeof updated.controlAuthority === 'object') + ? (updated.controlAuthority as { ownerTakenAt?: unknown }).ownerTakenAt + : undefined, + overrideInherited: false, + }, }, }) }) @@ -1153,6 +1270,7 @@ void test('session patch preserves existing standaloneMode when request omits th body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, }, }, res, @@ -1178,7 +1296,7 @@ void test('session patch ignores partially numeric timestamp query values', asyn await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=83abc', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=83abc', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1205,7 +1323,7 @@ void test('session patch falls back to valid t param when start param is malform await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?start=oops&t=1m23s', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?start=oops&t=1m23s', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1236,7 +1354,7 @@ void test('session patch rejects reconfiguration after a video is already set', await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1264,7 +1382,7 @@ void test('session patch publishes through broadcast channel without direct loca await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1313,7 +1431,7 @@ void test('session patch falls back to direct local websocket send when pubsub p await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1373,7 +1491,7 @@ void test('session patch falls back to direct local websocket send when publishB await handler?.( { params: { sessionId: 's1' }, - body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', stopSec: 120, instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1407,7 +1525,7 @@ void test('command route updates playback and emits extensible envelope', async await handler?.( { params: { sessionId: 's1' }, - body: { type: 'play', instructorPasscode: TEST_INSTRUCTOR_PASSCODE }, + body: { type: 'play', instructorPasscode: TEST_INSTRUCTOR_PASSCODE, instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID }, }, res, ) @@ -1429,6 +1547,75 @@ void test('command route updates playback and emits extensible envelope', async assert.equal(message.type, 'state-update') }) +void test('command route rejects non-owner instructor instances after control is claimed', async () => { + const app = createMockApp() + const ws = createMockWs() as unknown as WsRouter + const session = createVideoSyncSession('s1') + ;(session.data as Record).controlAuthority = { + mode: 'single-instructor', + ownerInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, + ownerTakenAt: 123, + overrideInherited: false, + } + const storeState = createSessionStore({ s1: session }) + + setupVideoSyncRoutes(app, storeState.sessions, ws) + + const handler = app.handlers.post['/api/video-sync/:sessionId/command'] + assert.equal(typeof handler, 'function') + + const res = createResponse() + await handler?.( + { + params: { sessionId: 's1' }, + body: { + type: 'play', + instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + instructorInstanceId: ALT_TEST_INSTRUCTOR_INSTANCE_ID, + }, + }, + res, + ) + + assert.equal(res.statusCode, 403) + assert.deepEqual(res.body, { + error: 'CONTROL_AUTHORITY_REQUIRED', + message: 'Take control to use instructor controls for this session.', + }) +}) + +void test('session patch rejects embedded child updates until the instructor explicitly takes control', async () => { + const app = createMockApp() + const ws = createMockWs() as unknown as WsRouter + const session = createVideoSyncSession('child-1') + ;(session.data as Record).embeddedParentSessionId = 'parent-syncdeck' + const storeState = createSessionStore({ 'child-1': session }) + + setupVideoSyncRoutes(app, storeState.sessions, ws) + + const handler = app.handlers.patch['/api/video-sync/:sessionId/session'] + assert.equal(typeof handler, 'function') + + const res = createResponse() + await handler?.( + { + params: { sessionId: 'child-1' }, + body: { + sourceUrl: 'https://youtu.be/dQw4w9WgXcQ?t=43', + instructorPasscode: TEST_INSTRUCTOR_PASSCODE, + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, + }, + }, + res, + ) + + assert.equal(res.statusCode, 403) + assert.deepEqual(res.body, { + error: 'CONTROL_AUTHORITY_REQUIRED', + message: 'Take control to use instructor controls for this session.', + }) +}) + void test('session patch rejects requests without a valid instructor passcode', async () => { const app = createMockApp() const ws = createMockWs() as unknown as WsRouter @@ -1836,6 +2023,7 @@ void test('instructor websocket rejects connections without a valid instructor p const recorder = createMockSocket() handler?.(recorder.socket, new URLSearchParams({ + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, sessionId: 's1', role: 'instructor', })) @@ -1868,6 +2056,7 @@ void test('instructor websocket rejects oversized instructor passcodes before ve const recorder = createMockSocket() handler?.(recorder.socket, new URLSearchParams({ + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, sessionId: 's1', role: 'instructor', })) @@ -1906,6 +2095,7 @@ void test('instructor websocket accepts connections with a valid instructor pass const recorder = createMockSocket() handler?.(recorder.socket, new URLSearchParams({ + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, sessionId: 's1', role: 'instructor', })) @@ -1924,6 +2114,10 @@ void test('instructor websocket accepts connections with a valid instructor pass const telemetryEnvelope = JSON.parse(recorder.sent[1] ?? '{}') as { type?: string; payload?: { reason?: string } } assert.equal(telemetryEnvelope.type, 'telemetry-update') assert.equal(telemetryEnvelope.payload?.reason, 'connection-change') + const persistedControlAuthority = storeState.store.s1?.data as { + controlAuthority?: { ownerInstanceId?: string | null } + } + assert.equal(persistedControlAuthority.controlAuthority?.ownerInstanceId, TEST_INSTRUCTOR_INSTANCE_ID) recorder.emit('close') await new Promise((resolve) => setTimeout(resolve, 0)) }) @@ -1940,6 +2134,7 @@ void test('instructor websocket accepts uppercase instructor passcodes by canoni const recorder = createMockSocket() handler?.(recorder.socket, new URLSearchParams({ + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, sessionId: 's1', role: 'instructor', })) @@ -1968,6 +2163,7 @@ void test('legacy manager websocket role is normalized to instructor', async () const recorder = createMockSocket() handler?.(recorder.socket, new URLSearchParams({ + instructorInstanceId: TEST_INSTRUCTOR_INSTANCE_ID, sessionId: 's1', role: 'manager', })) diff --git a/activities/video-sync/server/routes.ts b/activities/video-sync/server/routes.ts index 4224d6f7..5834f5ab 100644 --- a/activities/video-sync/server/routes.ts +++ b/activities/video-sync/server/routes.ts @@ -1,6 +1,11 @@ import { createSession, type SessionRecord, type SessionStore } from 'activebits-server/core/sessions.js' import { registerSessionNormalizer } from 'activebits-server/core/sessionNormalization.js' import { createBroadcastSubscriptionHelper } from 'activebits-server/core/broadcastUtils.js' +import { + claimSessionControlAuthority, + getSessionControlAuthorityState, + normalizeInstructorInstanceId, +} from '../../../server/controlAuthority.js' import { findHashBySessionId, resolvePersistentSessionEntryPolicy, @@ -60,6 +65,7 @@ interface PublicVideoSyncSessionData { standaloneMode: boolean state: VideoSyncState telemetry: VideoSyncTelemetry + controlAuthority: ReturnType } interface VideoSyncSession extends SessionRecord { @@ -106,6 +112,7 @@ interface VideoSyncWsMessageEnvelope { interface VideoSyncSocket extends ActiveBitsWebSocket { sessionId?: string | null videoSyncRole?: VideoSyncRole + instructorInstanceId?: string | null } interface VideoSyncInstructorAuthMessage { @@ -205,6 +212,69 @@ function normalizeInstructorPasscode(value: unknown): string | null { return normalized.toLowerCase() } +function readInstructorInstanceId(body: unknown): string | null { + if (!isPlainObject(body)) { + return null + } + + return normalizeInstructorInstanceId(body.instructorInstanceId) +} + +function buildControlAuthorityRequiredError(): { error: 'CONTROL_AUTHORITY_REQUIRED'; message: string } { + return { + error: 'CONTROL_AUTHORITY_REQUIRED', + message: 'Take control to use instructor controls for this session.', + } +} + +function ensureInstructorControlAuthority(params: { + session: VideoSyncSession + instructorInstanceId: string | null + allowInlineAutoClaim: boolean +}): { ok: true } | { ok: false; status: number; body: { error: string; message: string } } { + const normalizedInstructorInstanceId = normalizeInstructorInstanceId(params.instructorInstanceId) + if (!normalizedInstructorInstanceId) { + return { + ok: false, + status: 400, + body: { + error: 'INVALID_INSTRUCTOR_INSTANCE_ID', + message: 'Valid instructorInstanceId is required', + }, + } + } + + const embeddedParentContext = readEmbeddedParentSessionContext(params.session.data) + const controlAuthority = getSessionControlAuthorityState(params.session) + + if (controlAuthority.ownerInstanceId == null) { + if (!params.allowInlineAutoClaim || embeddedParentContext != null) { + return { + ok: false, + status: 403, + body: buildControlAuthorityRequiredError(), + } + } + + claimSessionControlAuthority({ + session: params.session, + instructorInstanceId: normalizedInstructorInstanceId, + overrideInherited: false, + }) + return { ok: true } + } + + if (controlAuthority.ownerInstanceId !== normalizedInstructorInstanceId) { + return { + ok: false, + status: 403, + body: buildControlAuthorityRequiredError(), + } + } + + return { ok: true } +} + function verifyInstructorPasscode(expected: string, candidate: string): boolean { if ( expected.length !== INSTRUCTOR_PASSCODE_LENGTH || @@ -911,6 +981,7 @@ function scheduleUnsyncedStudentsPrune( console.error(`Failed to prune stale video-sync unsynced students for session ${sessionId}:`, error) }) }, delayMs) + timer.unref?.() unsyncedStudentPruneTimersBySession.set(sessionId, timer) } @@ -949,6 +1020,7 @@ function toPublicSessionData(data: VideoSyncSessionData): PublicVideoSyncSession standaloneMode: data.standaloneMode, state: data.state, telemetry: data.telemetry, + controlAuthority: getSessionControlAuthorityState({ data }), } } @@ -1403,6 +1475,15 @@ export default function setupVideoSyncRoutes( res.status(403).json({ error: 'FORBIDDEN', message: 'Valid instructorPasscode is required' }) return } + const instructorAuthority = ensureInstructorControlAuthority({ + session, + instructorInstanceId: readInstructorInstanceId(req.body), + allowInlineAutoClaim: true, + }) + if (!instructorAuthority.ok) { + res.status(instructorAuthority.status).json(instructorAuthority.body) + return + } const data = ensureVideoSyncSessionData(session) if (data.state.videoId.length > 0) { @@ -1500,6 +1581,7 @@ export default function setupVideoSyncRoutes( const envelope = createEnvelope(sessionId, 'state-update', { state: data.state, telemetry: data.telemetry, + controlAuthority: getSessionControlAuthorityState(session), reason: 'config-updated', }) await broadcastEnvelope(sessions, ws, sessionId, envelope) @@ -1525,6 +1607,15 @@ export default function setupVideoSyncRoutes( res.status(403).json({ error: 'FORBIDDEN', message: 'Valid instructorPasscode is required' }) return } + const instructorAuthority = ensureInstructorControlAuthority({ + session, + instructorInstanceId: readInstructorInstanceId(req.body), + allowInlineAutoClaim: true, + }) + if (!instructorAuthority.ok) { + res.status(instructorAuthority.status).json(instructorAuthority.body) + return + } const body = isPlainObject(req.body) ? (req.body as CommandBody) : {} if (!isCommandType(body.type)) { @@ -1581,6 +1672,7 @@ export default function setupVideoSyncRoutes( const envelope = createEnvelope(sessionId, 'state-update', { state: data.state, telemetry: data.telemetry, + controlAuthority: getSessionControlAuthorityState(session), reason: body.type, }) await broadcastEnvelope(sessions, ws, sessionId, envelope) @@ -1659,6 +1751,7 @@ export default function setupVideoSyncRoutes( const envelope = createEnvelope(sessionId, 'telemetry-update', { telemetry: data.telemetry, + controlAuthority: getSessionControlAuthorityState(session), reason: body.type, }) await broadcastEnvelope(sessions, ws, sessionId, envelope) @@ -1666,6 +1759,45 @@ export default function setupVideoSyncRoutes( res.json({ success: true, telemetry: data.telemetry }) }) + app.post('/api/video-sync/:sessionId/control-authority/take', async (req, res) => { + const sessionId = resolveSessionId(req) + if (!sessionId) { + res.status(400).json({ error: 'INVALID_SESSION_ID', message: 'sessionId is required' }) + return + } + + const session = await getVideoSyncSession(sessions, sessionId) + if (!session) { + res.status(404).json({ error: 'NOT_FOUND', message: 'Session not found' }) + return + } + + const instructorPasscode = readInstructorPasscode(req.body) + if (!instructorPasscode || !verifyInstructorPasscode(session.data.instructorPasscode, instructorPasscode)) { + res.status(403).json({ error: 'FORBIDDEN', message: 'Valid instructorPasscode is required' }) + return + } + + const instructorInstanceId = readInstructorInstanceId(req.body) + if (!instructorInstanceId) { + res.status(400).json({ error: 'INVALID_INSTRUCTOR_INSTANCE_ID', message: 'Valid instructorInstanceId is required' }) + return + } + + const embeddedParentContext = readEmbeddedParentSessionContext(session.data) + const controlAuthority = claimSessionControlAuthority({ + session, + instructorInstanceId, + overrideInherited: embeddedParentContext != null, + }) + await sessions.set(session.id, session) + + res.json({ + success: true, + controlAuthority, + }) + }) + ws.register('/ws/video-sync', (socket, query) => { const sessionId = query.get('sessionId') const roleParam = query.get('role') @@ -1706,6 +1838,7 @@ export default function setupVideoSyncRoutes( const disconnectTelemetryUpdate = createEnvelope(sessionId, 'telemetry-update', { telemetry: currentData.telemetry, + controlAuthority: getSessionControlAuthorityState(currentSession), reason: 'connection-change', }) await broadcastEnvelope(sessions, ws, sessionId, disconnectTelemetryUpdate) @@ -1755,12 +1888,27 @@ export default function setupVideoSyncRoutes( const role: VideoSyncRole = isInstructorRoleParam(roleParam) ? 'instructor' : 'student' typedSocket.sessionId = sessionId typedSocket.videoSyncRole = role + typedSocket.instructorInstanceId = role === 'instructor' + ? normalizeInstructorInstanceId(query.get('instructorInstanceId')) + : null if (cleanedUp || typedSocket.readyState !== WS_OPEN_READY_STATE) { handleSocketClosed() return } + if (role === 'instructor') { + const embeddedParentContext = readEmbeddedParentSessionContext(session.data) + const controlAuthority = getSessionControlAuthorityState(session) + if (controlAuthority.ownerInstanceId == null && embeddedParentContext == null && typedSocket.instructorInstanceId) { + claimSessionControlAuthority({ + session, + instructorInstanceId: typedSocket.instructorInstanceId, + overrideInherited: false, + }) + } + } + ensureBroadcastSubscription(sessionId) upsertSubscriber(sessionId, typedSocket) isSubscribed = true @@ -1774,6 +1922,7 @@ export default function setupVideoSyncRoutes( const snapshot = createEnvelope(sessionId, 'state-snapshot', { state: data.state, telemetry: data.telemetry, + controlAuthority: getSessionControlAuthorityState(session), role, }) @@ -1783,6 +1932,7 @@ export default function setupVideoSyncRoutes( const telemetryUpdate = createEnvelope(sessionId, 'telemetry-update', { telemetry: data.telemetry, + controlAuthority: getSessionControlAuthorityState(session), reason: 'connection-change', }) await broadcastEnvelope(sessions, ws, sessionId, telemetryUpdate) diff --git a/client/src/components/common/ActivityLauncher.tsx b/client/src/components/common/ActivityLauncher.tsx index 53611864..fca21bdb 100644 --- a/client/src/components/common/ActivityLauncher.tsx +++ b/client/src/components/common/ActivityLauncher.tsx @@ -12,6 +12,7 @@ import { } from './activityLauncherUtils' import { persistCreateSessionBootstrapToSessionStorage, + shouldPersistCreateSessionBootstrapPayload, storeCreateSessionBootstrapPayload, } from './manageDashboardUtils' @@ -64,7 +65,7 @@ function ActivityLauncherBody({ const navigationState = buildStandaloneActivityLauncherState(activity, payload) persistCreateSessionBootstrapToSessionStorage(activity.createSessionBootstrap, payload.id, payload) - if (navigationState != null) { + if (navigationState != null && shouldPersistCreateSessionBootstrapPayload(activity.createSessionBootstrap)) { storeCreateSessionBootstrapPayload(activity.id, payload.id, navigationState.createSessionPayload) } diff --git a/client/src/components/common/ControlAuthorityStatus.test.tsx b/client/src/components/common/ControlAuthorityStatus.test.tsx new file mode 100644 index 00000000..4f2e04f8 --- /dev/null +++ b/client/src/components/common/ControlAuthorityStatus.test.tsx @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { renderToStaticMarkup } from 'react-dom/server' +import ControlAuthorityStatus from './ControlAuthorityStatus' + +void test('ControlAuthorityStatus renders current control state and disables owner button', () => { + const html = renderToStaticMarkup( + {}} + />, + ) + + assert.match(html, /aria-live="polite"/) + assert.match(html, /You have control/) + assert.match(html, /Instructor control is active in this view/) + assert.match(html, /disabled/) + assert.match(html, /In Control/) +}) + +void test('ControlAuthorityStatus can hide the action when the current instructor owns control', () => { + const html = renderToStaticMarkup( + {}} + />, + ) + + assert.match(html, /You have control/) + assert.doesNotMatch(html, /In Control/) +}) diff --git a/client/src/components/common/ControlAuthorityStatus.tsx b/client/src/components/common/ControlAuthorityStatus.tsx new file mode 100644 index 00000000..1b96b53f --- /dev/null +++ b/client/src/components/common/ControlAuthorityStatus.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' + +void React + +export interface ControlAuthorityStatusProps { + statusLabel: string + hasControl: boolean + canTakeControl: boolean + onTakeControl: () => void + hideButtonWhenOwner?: boolean + className?: string + buttonClassName?: string + takeControlLabel?: string + inControlLabel?: string +} + +export default function ControlAuthorityStatus({ + statusLabel, + hasControl, + canTakeControl, + onTakeControl, + hideButtonWhenOwner = false, + className = 'flex items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1', + buttonClassName = 'rounded border border-gray-300 bg-white px-3 py-1 text-sm font-semibold text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50', + takeControlLabel = 'Take Control', + inControlLabel = 'In Control', +}: ControlAuthorityStatusProps) { + const shouldShowButton = !hideButtonWhenOwner || !hasControl + + return ( +
+ {statusLabel} + {shouldShowButton ? ( + + ) : null} +
+ ) +} diff --git a/client/src/components/common/ManageDashboard.tsx b/client/src/components/common/ManageDashboard.tsx index e6b45b27..21c46112 100644 --- a/client/src/components/common/ManageDashboard.tsx +++ b/client/src/components/common/ManageDashboard.tsx @@ -26,6 +26,7 @@ import { parseDeepLinkGenerator, parseDeepLinkOptions, resolvePersistentLinkPreflightValue, + shouldPersistCreateSessionBootstrapPayload, validateDeepLinkSelection, type DeepLinkSelection, } from './manageDashboardUtils' @@ -271,7 +272,7 @@ export default function ManageDashboard({ const navigationState = activity ? buildStandaloneActivityLauncherState(activity, payload) : null persistCreateSessionBootstrapToSessionStorage(activity?.createSessionBootstrap, payload.id, payload) - if (navigationState != null) { + if (navigationState != null && shouldPersistCreateSessionBootstrapPayload(activity?.createSessionBootstrap)) { storeCreateSessionBootstrapPayload(activityId, payload.id, navigationState.createSessionPayload) } diff --git a/client/src/components/common/SessionRouter.tsx b/client/src/components/common/SessionRouter.tsx index 15395ade..dec216d1 100644 --- a/client/src/components/common/SessionRouter.tsx +++ b/client/src/components/common/SessionRouter.tsx @@ -7,6 +7,7 @@ import WaitingRoom from './WaitingRoom' import LoadingFallback from './LoadingFallback' import { persistCreateSessionBootstrapToSessionStorage, + shouldPersistCreateSessionBootstrapPayload, storeCreateSessionBootstrapPayload, } from './manageDashboardUtils' import { @@ -467,7 +468,9 @@ const SessionRouter = () => { if (createSessionPayload) { persistCreateSessionBootstrapToSessionStorage(activity?.createSessionBootstrap, nextSessionId, createSessionPayload) - storeCreateSessionBootstrapPayload(payload.activityName, nextSessionId, createSessionPayload) + if (shouldPersistCreateSessionBootstrapPayload(activity?.createSessionBootstrap)) { + storeCreateSessionBootstrapPayload(payload.activityName, nextSessionId, createSessionPayload) + } } const path = await resolveTeacherManagePath(payload.activityName, nextSessionId, '') diff --git a/client/src/components/common/instructorControlIdentity.test.ts b/client/src/components/common/instructorControlIdentity.test.ts new file mode 100644 index 00000000..1dfa63a0 --- /dev/null +++ b/client/src/components/common/instructorControlIdentity.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + buildInstructorControlInstanceId, + createDefaultInstructorControlId, + resolveBrowserInstructorControlId, + resolveOrCreateInstructorControlInstanceId, + resolveTabInstructorControlId, + type InstructorControlIdentityStorageLike, +} from './instructorControlIdentity' + +function createStorage(): InstructorControlIdentityStorageLike { + const values = new Map() + + return { + getItem(key) { + return values.get(key) ?? null + }, + setItem(key, value) { + values.set(key, value) + }, + } +} + +void test('buildInstructorControlInstanceId joins browser and tab ids', () => { + assert.equal(buildInstructorControlInstanceId('browser-1', 'tab-1'), 'browser-1:tab-1') +}) + +void test('createDefaultInstructorControlId returns a non-empty id', () => { + assert.equal(createDefaultInstructorControlId().trim().length > 0, true) +}) + +void test('resolveOrCreateInstructorControlInstanceId creates and persists browser and tab ids', () => { + const localStorage = createStorage() + const sessionStorage = createStorage() + const createdIds = ['browser-1', 'tab-1'] + + const instanceId = resolveOrCreateInstructorControlInstanceId( + { localStorage, sessionStorage }, + () => createdIds.shift() ?? 'unexpected-id', + ) + + assert.equal(instanceId, 'browser-1:tab-1') + assert.equal(resolveBrowserInstructorControlId(localStorage), 'browser-1') + assert.equal(resolveTabInstructorControlId(sessionStorage), 'tab-1') +}) + +void test('resolveOrCreateInstructorControlInstanceId reuses existing ids on reload', () => { + const localStorage = createStorage() + const sessionStorage = createStorage() + + localStorage.setItem('activebits:instructor-control:browser-id', 'browser-1') + sessionStorage.setItem('activebits:instructor-control:tab-id', 'tab-1') + + let createCalls = 0 + const instanceId = resolveOrCreateInstructorControlInstanceId( + { localStorage, sessionStorage }, + () => { + createCalls += 1 + return `generated-${createCalls}` + }, + ) + + assert.equal(instanceId, 'browser-1:tab-1') + assert.equal(createCalls, 0) +}) + +void test('resolveOrCreateInstructorControlInstanceId preserves browser identity while creating a new tab id', () => { + const localStorage = createStorage() + const sessionStorage = createStorage() + + localStorage.setItem('activebits:instructor-control:browser-id', 'browser-1') + + const instanceId = resolveOrCreateInstructorControlInstanceId( + { localStorage, sessionStorage }, + () => 'tab-2', + ) + + assert.equal(instanceId, 'browser-1:tab-2') + assert.equal(resolveBrowserInstructorControlId(localStorage), 'browser-1') + assert.equal(resolveTabInstructorControlId(sessionStorage), 'tab-2') +}) diff --git a/client/src/components/common/instructorControlIdentity.ts b/client/src/components/common/instructorControlIdentity.ts new file mode 100644 index 00000000..3a019724 --- /dev/null +++ b/client/src/components/common/instructorControlIdentity.ts @@ -0,0 +1,64 @@ +export interface InstructorControlIdentityStorageLike { + getItem(key: string): string | null + setItem(key: string, value: string): void +} + +export interface InstructorControlIdentityStorageSet { + localStorage: InstructorControlIdentityStorageLike + sessionStorage: InstructorControlIdentityStorageLike +} + +const BROWSER_ID_STORAGE_KEY = 'activebits:instructor-control:browser-id' +const TAB_ID_STORAGE_KEY = 'activebits:instructor-control:tab-id' + +function normalizeStoredId(value: string | null): string | null { + if (typeof value !== 'string') { + return null + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +export function buildInstructorControlInstanceId(browserId: string, tabId: string): string { + return `${browserId}:${tabId}` +} + +export function createDefaultInstructorControlId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +export function resolveOrCreateInstructorControlInstanceId( + storage: InstructorControlIdentityStorageSet, + createId: () => string, +): string { + let browserId = normalizeStoredId(storage.localStorage.getItem(BROWSER_ID_STORAGE_KEY)) + if (!browserId) { + browserId = createId().trim() + storage.localStorage.setItem(BROWSER_ID_STORAGE_KEY, browserId) + } + + let tabId = normalizeStoredId(storage.sessionStorage.getItem(TAB_ID_STORAGE_KEY)) + if (!tabId) { + tabId = createId().trim() + storage.sessionStorage.setItem(TAB_ID_STORAGE_KEY, tabId) + } + + return buildInstructorControlInstanceId(browserId, tabId) +} + +export function resolveBrowserInstructorControlId( + localStorage: InstructorControlIdentityStorageLike, +): string | null { + return normalizeStoredId(localStorage.getItem(BROWSER_ID_STORAGE_KEY)) +} + +export function resolveTabInstructorControlId( + sessionStorage: InstructorControlIdentityStorageLike, +): string | null { + return normalizeStoredId(sessionStorage.getItem(TAB_ID_STORAGE_KEY)) +} diff --git a/client/src/components/common/manageDashboardUtils.test.ts b/client/src/components/common/manageDashboardUtils.test.ts index a4c91b6c..9a049a30 100644 --- a/client/src/components/common/manageDashboardUtils.test.ts +++ b/client/src/components/common/manageDashboardUtils.test.ts @@ -18,6 +18,7 @@ import { parseDeepLinkGenerator, persistCreateSessionBootstrapToSessionStorage, parseDeepLinkOptions, + shouldPersistCreateSessionBootstrapPayload, storeCreateSessionBootstrapPayload, validateDeepLinkSelection, } from './manageDashboardUtils' @@ -319,16 +320,35 @@ void test('parseCreateSessionBootstrap validates sessionStorage bootstrap metada { keyPrefix: 'x_', responseField: '' }, ], historyState: [' instructorPasscode ', '', 42], + transientOnly: true, }), { sessionStorage: [ { keyPrefix: 'syncdeck_instructor_', responseField: 'instructorPasscode' }, ], historyState: ['instructorPasscode'], + transientOnly: true, }, ) }) +void test('shouldPersistCreateSessionBootstrapPayload respects transient-only bootstrap payloads', () => { + assert.equal(shouldPersistCreateSessionBootstrapPayload(null), true) + assert.equal( + shouldPersistCreateSessionBootstrapPayload({ + historyState: ['instructorPasscode'], + transientOnly: true, + }), + false, + ) + assert.equal( + shouldPersistCreateSessionBootstrapPayload({ + historyState: ['instructorPasscode'], + }), + true, + ) +}) + void test('persistCreateSessionBootstrapToSessionStorage stores declared create response fields', () => { const originalWindow = globalThis.window const { backing: writes, storage: fakeSessionStorage } = createFakeSessionStorage() diff --git a/client/src/components/common/manageDashboardUtils.ts b/client/src/components/common/manageDashboardUtils.ts index 52c35527..372445c2 100644 --- a/client/src/components/common/manageDashboardUtils.ts +++ b/client/src/components/common/manageDashboardUtils.ts @@ -38,6 +38,7 @@ export interface CreateSessionBootstrapSessionStorageEntry { export interface CreateSessionBootstrapConfig { sessionStorage: CreateSessionBootstrapSessionStorageEntry[] historyState?: string[] + transientOnly?: boolean } export type DeepLinkOptions = Record @@ -344,6 +345,7 @@ export function parseCreateSessionBootstrap(rawCreateSessionBootstrap: unknown): .map((entry) => entry.trim()) .filter((entry) => entry.length > 0) : [] + const transientOnly = rawCreateSessionBootstrap.transientOnly === true if (sessionStorage.length === 0 && historyState.length === 0) { return null @@ -352,9 +354,15 @@ export function parseCreateSessionBootstrap(rawCreateSessionBootstrap: unknown): return { sessionStorage, ...(historyState.length > 0 ? { historyState } : {}), + ...(transientOnly ? { transientOnly } : {}), } } +export function shouldPersistCreateSessionBootstrapPayload(rawCreateSessionBootstrap: unknown): boolean { + const createSessionBootstrap = parseCreateSessionBootstrap(rawCreateSessionBootstrap) + return createSessionBootstrap?.transientOnly !== true +} + export function persistCreateSessionBootstrapToSessionStorage( rawCreateSessionBootstrap: unknown, sessionId: string, diff --git a/playwright/control-authority.spec.ts b/playwright/control-authority.spec.ts new file mode 100644 index 00000000..9d4b9b75 --- /dev/null +++ b/playwright/control-authority.spec.ts @@ -0,0 +1,115 @@ +import { expect, test, type APIRequestContext, type Browser, type Page } from '@playwright/test' + +async function createConfiguredSyncDeckSession(request: APIRequestContext): Promise<{ + sessionId: string + instructorPasscode: string +}> { + const createResponse = await request.post('/api/syncdeck/create') + expect(createResponse.ok()).toBe(true) + const createPayload = await createResponse.json() as { + id?: unknown + instructorPasscode?: unknown + } + expect(typeof createPayload.id).toBe('string') + expect(typeof createPayload.instructorPasscode).toBe('string') + + const sessionId = createPayload.id as string + const instructorPasscode = createPayload.instructorPasscode as string + const configureResponse = await request.post(`/api/syncdeck/${encodeURIComponent(sessionId)}/configure`, { + data: { + presentationUrl: 'https://example.com/syncdeck-control-authority-e2e', + instructorPasscode, + instructorInstanceId: 'browser-owner:tab-owner', + standaloneMode: false, + }, + }) + expect(configureResponse.ok()).toBe(true) + + return { sessionId, instructorPasscode } +} + +async function openSyncDeckInstructorPage(params: { + browser: Browser + baseURL: string | undefined + sessionId: string + instructorPasscode: string + browserId: string + tabId: string +}): Promise { + const context = await params.browser.newContext() + const page = await context.newPage() + await page.route( + `**/api/syncdeck/${encodeURIComponent(params.sessionId)}/instructor-passcode`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ instructorPasscode: params.instructorPasscode }), + }) + }, + ) + await page.addInitScript( + ({ browserId, tabId }) => { + window.localStorage.setItem('activebits:instructor-control:browser-id', browserId) + window.sessionStorage.setItem('activebits:instructor-control:tab-id', tabId) + }, + { + browserId: params.browserId, + tabId: params.tabId, + }, + ) + + await page.goto(`${params.baseURL ?? ''}/manage/syncdeck/${encodeURIComponent(params.sessionId)}`, { + waitUntil: 'networkidle', + }) + return page +} + +test('SyncDeck manager control authority disables non-owner controls and flips after takeover', async ({ + baseURL, + browser, + request, +}) => { + const { sessionId, instructorPasscode } = await createConfiguredSyncDeckSession(request) + const pages: Page[] = [] + + try { + const ownerPage = await openSyncDeckInstructorPage({ + browser, + baseURL, + sessionId, + instructorPasscode, + browserId: 'browser-owner', + tabId: 'tab-owner', + }) + pages.push(ownerPage) + + const peerPage = await openSyncDeckInstructorPage({ + browser, + baseURL, + sessionId, + instructorPasscode, + browserId: 'browser-peer', + tabId: 'tab-peer', + }) + pages.push(peerPage) + + await expect(ownerPage.getByText('You have control')).toBeVisible() + await expect(peerPage.getByText('Another instructor has control')).toBeVisible() + await expect(peerPage.getByRole('button', { name: 'Force sync students to current position' })).toBeDisabled() + + await peerPage.getByRole('button', { name: 'Take Control' }).click() + + await expect(peerPage.getByText('You have control')).toBeVisible() + await expect(ownerPage.getByText('Another instructor has control')).toBeVisible() + await expect(ownerPage.getByRole('button', { name: 'Force sync students to current position' })).toBeDisabled() + await expect(peerPage.getByRole('button', { name: 'Force sync students to current position' })).toBeEnabled() + } finally { + await Promise.all(pages.map((page) => page.context().close())) + await request.delete(`/api/syncdeck/${encodeURIComponent(sessionId)}`, { + data: { + instructorPasscode, + }, + }) + } +}) diff --git a/server/activityConfigSchema.test.ts b/server/activityConfigSchema.test.ts index 24e51ac1..1ad0987f 100644 --- a/server/activityConfigSchema.test.ts +++ b/server/activityConfigSchema.test.ts @@ -61,6 +61,7 @@ void test('parseActivityConfig accepts valid shared contracts', () => { }, createSessionBootstrap: { historyState: ['instructorPasscode'], + transientOnly: true, selectedOptionsToSessionData: ['presentationUrl'], sessionStorage: [ { @@ -75,6 +76,11 @@ void test('parseActivityConfig accepts valid shared contracts', () => { embeddedRuntime: { instructorGated: 'runtime', }, + controlAuthority: { + mode: 'single-instructor', + scope: 'inherited', + gating: 'activity', + }, reportEndpoint: '/api/syncdeck/s1/report', utilMode: true, waitingRoom: { @@ -116,9 +122,15 @@ void test('parseActivityConfig accepts valid shared contracts', () => { responseField: 'instructorPasscode', }) assert.deepEqual(parsed.createSessionBootstrap?.historyState, ['instructorPasscode']) + assert.equal(parsed.createSessionBootstrap?.transientOnly, true) assert.deepEqual(parsed.createSessionBootstrap?.selectedOptionsToSessionData, ['presentationUrl']) assert.equal(parsed.manageDashboard?.customPersistentLinkBuilder, true) assert.equal(parsed.embeddedRuntime?.instructorGated, 'runtime') + assert.deepEqual(parsed.controlAuthority, { + mode: 'single-instructor', + scope: 'inherited', + gating: 'activity', + }) assert.equal(parsed.reportEndpoint, '/api/syncdeck/s1/report') assert.deepEqual(parsed.utilities, [ { @@ -414,6 +426,77 @@ void test('parseActivityConfig rejects invalid shared contract enums and shapes' /embeddedRuntime.*instructorGated.*runtime.*waiting-room/, ) + assert.throws( + () => + parseActivityConfig( + { + id: 'bad-control-mode', + name: 'BadControlMode', + description: 'desc', + color: 'gray', + standaloneEntry: { + enabled: true, + supportsDirectPath: true, + supportsPermalink: true, + showOnHome: true, + }, + controlAuthority: { + mode: 'multi-instructor', + }, + }, + 'bad-control-mode', + ), + /controlAuthority.*mode.*single-instructor/, + ) + + assert.throws( + () => + parseActivityConfig( + { + id: 'bad-control-scope', + name: 'BadControlScope', + description: 'desc', + color: 'gray', + standaloneEntry: { + enabled: true, + supportsDirectPath: true, + supportsPermalink: true, + showOnHome: true, + }, + controlAuthority: { + mode: 'single-instructor', + scope: 'parent', + }, + }, + 'bad-control-scope', + ), + /controlAuthority.*scope.*session.*inherited/, + ) + + assert.throws( + () => + parseActivityConfig( + { + id: 'bad-control-gating', + name: 'BadControlGating', + description: 'desc', + color: 'gray', + standaloneEntry: { + enabled: true, + supportsDirectPath: true, + supportsPermalink: true, + showOnHome: true, + }, + controlAuthority: { + mode: 'single-instructor', + gating: 'server-only', + }, + }, + 'bad-control-gating', + ), + /controlAuthority.*gating.*all.*none.*activity/, + ) + assert.throws( () => parseActivityConfig( @@ -451,6 +534,7 @@ void test('parseActivityConfig removes optional keys when input provides null', }, title: null, deepLinkOptions: null, + controlAuthority: null, reportEndpoint: null, }, 'null-config', @@ -458,8 +542,10 @@ void test('parseActivityConfig removes optional keys when input provides null', assert.equal(parsed.title, undefined) assert.equal(parsed.deepLinkOptions, undefined) + assert.equal(parsed.controlAuthority, undefined) assert.equal(parsed.reportEndpoint, undefined) assert.equal('title' in parsed, false) assert.equal('deepLinkOptions' in parsed, false) + assert.equal('controlAuthority' in parsed, false) assert.equal('reportEndpoint' in parsed, false) }) diff --git a/server/controlAuthority.test.ts b/server/controlAuthority.test.ts new file mode 100644 index 00000000..0aad1382 --- /dev/null +++ b/server/controlAuthority.test.ts @@ -0,0 +1,371 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import type { ActivityConfig } from '../types/activity.js' +import { + activityUsesControlAuthority, + claimSessionControlAuthority, + getEmbeddedParentSessionId, + getResolvedControlAuthorityOwnerInstanceId, + getSessionControlAuthorityState, + isInstructorControlOwner, + normalizeInstructorInstanceId, + normalizeSessionControlAuthorityState, + resolveControlAuthority, + setSessionControlAuthorityState, + shouldAutoClaimControlAuthority, +} from './controlAuthority.js' + +function buildActivityConfig(overrides: Partial = {}): ActivityConfig { + return { + id: 'activity', + name: 'Activity', + description: 'desc', + color: 'blue', + standaloneEntry: { + enabled: false, + supportsDirectPath: false, + supportsPermalink: false, + showOnHome: false, + }, + ...overrides, + } +} + +void test('normalizeSessionControlAuthorityState normalizes sparse and invalid input', () => { + assert.deepEqual(normalizeSessionControlAuthorityState(null), { + mode: 'single-instructor', + ownerInstanceId: null, + ownerTakenAt: null, + overrideInherited: false, + }) + + assert.deepEqual( + normalizeSessionControlAuthorityState({ + ownerInstanceId: ' inst-1 ', + ownerTakenAt: 123, + overrideInherited: true, + }), + { + mode: 'single-instructor', + ownerInstanceId: 'inst-1', + ownerTakenAt: 123, + overrideInherited: true, + }, + ) +}) + +void test('normalizeInstructorInstanceId trims usable instance ids only', () => { + assert.equal(normalizeInstructorInstanceId(' inst-1 '), 'inst-1') + assert.equal(normalizeInstructorInstanceId(' '), null) + assert.equal(normalizeInstructorInstanceId(null), null) +}) + +void test('getSessionControlAuthorityState reads normalized state from session data', () => { + assert.deepEqual( + getSessionControlAuthorityState({ + data: { + controlAuthority: { + ownerInstanceId: 'owner-1', + ownerTakenAt: 456, + overrideInherited: false, + }, + }, + }), + { + mode: 'single-instructor', + ownerInstanceId: 'owner-1', + ownerTakenAt: 456, + overrideInherited: false, + }, + ) +}) + +void test('setSessionControlAuthorityState persists normalized authority state into session data', () => { + const session = { data: {} } + const storedState = setSessionControlAuthorityState(session, { + mode: 'single-instructor', + ownerInstanceId: 'owner-1', + ownerTakenAt: 789, + overrideInherited: true, + }) + + assert.deepEqual(storedState, { + mode: 'single-instructor', + ownerInstanceId: 'owner-1', + ownerTakenAt: 789, + overrideInherited: true, + }) + assert.deepEqual((session.data as Record).controlAuthority, storedState) +}) + +void test('claimSessionControlAuthority stores the active owner and timestamp', () => { + const session = { data: {} } + + const claimedState = claimSessionControlAuthority({ + session, + instructorInstanceId: ' owner-2 ', + takenAt: 999, + }) + + assert.deepEqual(claimedState, { + mode: 'single-instructor', + ownerInstanceId: 'owner-2', + ownerTakenAt: 999, + overrideInherited: false, + }) +}) + +void test('getEmbeddedParentSessionId returns trimmed embedded parent ids only', () => { + assert.equal(getEmbeddedParentSessionId(null), null) + assert.equal(getEmbeddedParentSessionId({ id: 'child', data: {} }), null) + assert.equal( + getEmbeddedParentSessionId({ + id: 'child', + data: { embeddedParentSessionId: ' parent-1 ' }, + }), + 'parent-1', + ) +}) + +void test('activityUsesControlAuthority only enables configured single-instructor activities', () => { + assert.equal(activityUsesControlAuthority(buildActivityConfig()), false) + assert.equal( + activityUsesControlAuthority( + buildActivityConfig({ + controlAuthority: { + mode: 'single-instructor', + }, + }), + ), + true, + ) +}) + +void test('resolveControlAuthority uses local session authority by default', () => { + const activityConfig = buildActivityConfig({ + controlAuthority: { + mode: 'single-instructor', + scope: 'session', + }, + }) + + assert.deepEqual( + resolveControlAuthority({ + session: { id: 'session-1', data: {} }, + activityConfig, + }), + { + mode: 'single-instructor', + configuredScope: 'session', + effectiveScope: 'session', + authoritySessionId: 'session-1', + inheritedFromSessionId: null, + }, + ) +}) + +void test('resolveControlAuthority inherits parent authority for embedded sessions when configured', () => { + const childConfig = buildActivityConfig({ + controlAuthority: { + mode: 'single-instructor', + scope: 'inherited', + }, + }) + const parentConfig = buildActivityConfig({ + id: 'syncdeck', + name: 'SyncDeck', + controlAuthority: { + mode: 'single-instructor', + scope: 'session', + }, + }) + + assert.deepEqual( + resolveControlAuthority({ + session: { + id: 'child-1', + data: { embeddedParentSessionId: 'parent-1' }, + }, + activityConfig: childConfig, + parentSession: { + id: 'parent-1', + data: {}, + }, + parentActivityConfig: parentConfig, + }), + { + mode: 'single-instructor', + configuredScope: 'inherited', + effectiveScope: 'inherited', + authoritySessionId: 'parent-1', + inheritedFromSessionId: 'parent-1', + }, + ) +}) + +void test('resolveControlAuthority falls back to local session authority without a controlling parent', () => { + const childConfig = buildActivityConfig({ + controlAuthority: { + mode: 'single-instructor', + scope: 'inherited', + }, + }) + + assert.deepEqual( + resolveControlAuthority({ + session: { + id: 'child-1', + data: { embeddedParentSessionId: 'missing-parent' }, + }, + activityConfig: childConfig, + parentSession: null, + parentActivityConfig: null, + }), + { + mode: 'single-instructor', + configuredScope: 'inherited', + effectiveScope: 'session', + authoritySessionId: 'child-1', + inheritedFromSessionId: null, + }, + ) +}) + +void test('resolveControlAuthority falls back to local session authority when inherited control is locally overridden', () => { + const childConfig = buildActivityConfig({ + controlAuthority: { + mode: 'single-instructor', + scope: 'inherited', + }, + }) + const parentConfig = buildActivityConfig({ + id: 'syncdeck', + name: 'SyncDeck', + controlAuthority: { + mode: 'single-instructor', + }, + }) + + assert.deepEqual( + resolveControlAuthority({ + session: { + id: 'child-1', + data: { + embeddedParentSessionId: 'parent-1', + controlAuthority: { + overrideInherited: true, + }, + }, + }, + activityConfig: childConfig, + parentSession: { + id: 'parent-1', + data: {}, + }, + parentActivityConfig: parentConfig, + }), + { + mode: 'single-instructor', + configuredScope: 'inherited', + effectiveScope: 'session', + authoritySessionId: 'child-1', + inheritedFromSessionId: null, + }, + ) +}) + +void test('resolveControlAuthority returns null for activities without control authority configured', () => { + assert.equal( + resolveControlAuthority({ + session: { id: 'session-1', data: {} }, + activityConfig: buildActivityConfig(), + }), + null, + ) +}) + +void test('getResolvedControlAuthorityOwnerInstanceId reads from the effective authority session', () => { + const inheritedResolution = { + mode: 'single-instructor', + configuredScope: 'inherited', + effectiveScope: 'inherited', + authoritySessionId: 'parent-1', + inheritedFromSessionId: 'parent-1', + } as const + + assert.equal( + getResolvedControlAuthorityOwnerInstanceId({ + resolvedAuthority: inheritedResolution, + session: { data: { controlAuthority: { ownerInstanceId: 'child-owner' } } }, + parentSession: { data: { controlAuthority: { ownerInstanceId: 'parent-owner' } } }, + }), + 'parent-owner', + ) + + assert.equal( + getResolvedControlAuthorityOwnerInstanceId({ + resolvedAuthority: { + mode: 'single-instructor', + configuredScope: 'session', + effectiveScope: 'session', + authoritySessionId: 'child-1', + inheritedFromSessionId: null, + }, + session: { data: { controlAuthority: { ownerInstanceId: 'child-owner' } } }, + parentSession: { data: { controlAuthority: { ownerInstanceId: 'parent-owner' } } }, + }), + 'child-owner', + ) +}) + +void test('isInstructorControlOwner compares the instructor instance id against the effective owner', () => { + const resolvedAuthority = { + mode: 'single-instructor', + configuredScope: 'session', + effectiveScope: 'session', + authoritySessionId: 'session-1', + inheritedFromSessionId: null, + } as const + + assert.equal( + isInstructorControlOwner({ + resolvedAuthority, + session: { data: { controlAuthority: { ownerInstanceId: 'owner-1' } } }, + instructorInstanceId: 'owner-1', + }), + true, + ) + assert.equal( + isInstructorControlOwner({ + resolvedAuthority, + session: { data: { controlAuthority: { ownerInstanceId: 'owner-1' } } }, + instructorInstanceId: 'owner-2', + }), + false, + ) +}) + +void test('shouldAutoClaimControlAuthority is true only when the effective owner is empty', () => { + const resolvedAuthority = { + mode: 'single-instructor', + configuredScope: 'session', + effectiveScope: 'session', + authoritySessionId: 'session-1', + inheritedFromSessionId: null, + } as const + + assert.equal( + shouldAutoClaimControlAuthority({ + resolvedAuthority, + session: { data: {} }, + }), + true, + ) + assert.equal( + shouldAutoClaimControlAuthority({ + resolvedAuthority, + session: { data: { controlAuthority: { ownerInstanceId: 'owner-1' } } }, + }), + false, + ) +}) diff --git a/server/controlAuthority.ts b/server/controlAuthority.ts new file mode 100644 index 00000000..d05f3deb --- /dev/null +++ b/server/controlAuthority.ts @@ -0,0 +1,199 @@ +import type { + ActivityConfig, + ResolvedControlAuthority, + SessionControlAuthorityState, +} from '../types/activity.js' +import type { SessionRecord } from './core/sessions.js' + +interface ControlAuthoritySessionLike { + id: string + data?: unknown +} + +function isPlainObject(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value) +} + +function normalizeNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +function normalizeFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function ensureSessionDataRecord(session: Pick): Record { + if (isPlainObject(session.data)) { + return session.data + } + + const nextData: Record = {} + ;(session as SessionRecord).data = nextData + return nextData +} + +export function normalizeInstructorInstanceId(value: unknown): string | null { + return normalizeNonEmptyString(value) +} + +export function normalizeSessionControlAuthorityState(value: unknown): SessionControlAuthorityState { + const source = isPlainObject(value) ? value : {} + + return { + mode: 'single-instructor', + ownerInstanceId: normalizeNonEmptyString(source.ownerInstanceId), + ownerTakenAt: normalizeFiniteNumber(source.ownerTakenAt), + overrideInherited: source.overrideInherited === true, + } +} + +export function getSessionControlAuthorityState(session: Pick): SessionControlAuthorityState { + const data = isPlainObject(session.data) ? session.data : {} + return normalizeSessionControlAuthorityState(data.controlAuthority) +} + +export function setSessionControlAuthorityState( + session: Pick, + state: SessionControlAuthorityState, +): SessionControlAuthorityState { + const data = ensureSessionDataRecord(session) + data.controlAuthority = { + mode: 'single-instructor', + ownerInstanceId: state.ownerInstanceId, + ownerTakenAt: state.ownerTakenAt, + overrideInherited: state.overrideInherited, + } satisfies SessionControlAuthorityState + + return getSessionControlAuthorityState(session) +} + +export function claimSessionControlAuthority(params: { + session: Pick + instructorInstanceId: string + takenAt?: number + overrideInherited?: boolean +}): SessionControlAuthorityState { + const normalizedInstructorInstanceId = normalizeInstructorInstanceId(params.instructorInstanceId) + if (!normalizedInstructorInstanceId) { + throw new Error('claimSessionControlAuthority requires a non-empty instructorInstanceId') + } + + return setSessionControlAuthorityState(params.session, { + mode: 'single-instructor', + ownerInstanceId: normalizedInstructorInstanceId, + ownerTakenAt: normalizeFiniteNumber(params.takenAt) ?? Date.now(), + overrideInherited: params.overrideInherited === true, + }) +} + +export function getEmbeddedParentSessionId(session: ControlAuthoritySessionLike | null | undefined): string | null { + const data = isPlainObject(session?.data) ? session.data : {} + return normalizeNonEmptyString(data.embeddedParentSessionId) +} + +function getConfiguredControlAuthorityScope(activityConfig: ActivityConfig | null | undefined): 'session' | 'inherited' { + return activityConfig?.controlAuthority?.scope === 'inherited' ? 'inherited' : 'session' +} + +export function activityUsesControlAuthority(activityConfig: ActivityConfig | null | undefined): boolean { + return activityConfig?.controlAuthority?.mode === 'single-instructor' +} + +export function resolveControlAuthority(params: { + session: ControlAuthoritySessionLike + activityConfig: ActivityConfig | null | undefined + parentSession?: ControlAuthoritySessionLike | null + parentActivityConfig?: ActivityConfig | null | undefined +}): ResolvedControlAuthority | null { + const { session, activityConfig, parentSession = null, parentActivityConfig = null } = params + if (!activityUsesControlAuthority(activityConfig)) { + return null + } + + const configuredScope = getConfiguredControlAuthorityScope(activityConfig) + const localState = getSessionControlAuthorityState(session as Pick) + const embeddedParentSessionId = getEmbeddedParentSessionId(session) + const canInherit = + configuredScope === 'inherited' + && localState.overrideInherited !== true + && embeddedParentSessionId != null + && parentSession?.id === embeddedParentSessionId + && activityUsesControlAuthority(parentActivityConfig) + + if (canInherit) { + return { + mode: 'single-instructor', + configuredScope, + effectiveScope: 'inherited', + authoritySessionId: parentSession.id, + inheritedFromSessionId: parentSession.id, + } + } + + return { + mode: 'single-instructor', + configuredScope, + effectiveScope: 'session', + authoritySessionId: session.id, + inheritedFromSessionId: null, + } +} + +export function getResolvedControlAuthorityOwnerInstanceId(params: { + resolvedAuthority: ResolvedControlAuthority | null + session: Pick + parentSession?: Pick | null +}): string | null { + const { resolvedAuthority, session, parentSession = null } = params + if (!resolvedAuthority) { + return null + } + + if (resolvedAuthority.effectiveScope === 'inherited') { + return parentSession ? getSessionControlAuthorityState(parentSession).ownerInstanceId : null + } + + return getSessionControlAuthorityState(session).ownerInstanceId +} + +export function isInstructorControlOwner(params: { + resolvedAuthority: ResolvedControlAuthority | null + session: Pick + instructorInstanceId: string | null | undefined + parentSession?: Pick | null +}): boolean { + const normalizedInstructorInstanceId = normalizeInstructorInstanceId(params.instructorInstanceId) + if (!normalizedInstructorInstanceId) { + return false + } + + const ownerInstanceId = getResolvedControlAuthorityOwnerInstanceId(params) + return ownerInstanceId === normalizedInstructorInstanceId +} + +export function shouldAutoClaimControlAuthority(params: { + resolvedAuthority: ResolvedControlAuthority | null + session: Pick + parentSession?: Pick | null +}): boolean { + return getResolvedControlAuthorityOwnerInstanceId(params) == null +} + +export default { + activityUsesControlAuthority, + claimSessionControlAuthority, + getEmbeddedParentSessionId, + getResolvedControlAuthorityOwnerInstanceId, + getSessionControlAuthorityState, + isInstructorControlOwner, + normalizeInstructorInstanceId, + normalizeSessionControlAuthorityState, + resolveControlAuthority, + setSessionControlAuthorityState, + shouldAutoClaimControlAuthority, +} diff --git a/types/activity.ts b/types/activity.ts index 288bf523..c8276016 100644 --- a/types/activity.ts +++ b/types/activity.ts @@ -67,6 +67,7 @@ export interface ActivityCreateSessionBootstrapSessionStorageEntry { export interface ActivityCreateSessionBootstrapConfig { sessionStorage?: ActivityCreateSessionBootstrapSessionStorageEntry[] historyState?: string[] + transientOnly?: boolean selectedOptionsToSessionData?: string[] } @@ -94,6 +95,27 @@ export interface ActivityEmbeddedRuntimeConfig { instructorGated?: 'runtime' | 'waiting-room' } +export interface ActivityControlAuthorityConfig { + mode: 'single-instructor' + scope?: 'session' | 'inherited' + gating?: 'all' | 'none' | 'activity' +} + +export interface SessionControlAuthorityState { + mode: 'single-instructor' + ownerInstanceId: string | null + ownerTakenAt: number | null + overrideInherited: boolean +} + +export interface ResolvedControlAuthority { + mode: 'single-instructor' + configuredScope: 'session' | 'inherited' + effectiveScope: 'session' | 'inherited' + authoritySessionId: string + inheritedFromSessionId: string | null +} + export type ActivityReportScope = 'activity-session' | 'student-cross-activity' | 'session-summary' export interface ActivityReportStudentRef { @@ -200,6 +222,7 @@ export interface ActivityConfig { expandShell?: boolean } embeddedRuntime?: ActivityEmbeddedRuntimeConfig + controlAuthority?: ActivityControlAuthorityConfig reportEndpoint?: string waitingRoom?: ActivityWaitingRoomConfig isDev?: boolean diff --git a/types/activityConfigSchema.ts b/types/activityConfigSchema.ts index f19dff29..7643205b 100644 --- a/types/activityConfigSchema.ts +++ b/types/activityConfigSchema.ts @@ -1,5 +1,6 @@ import type { ActivityConfig, + ActivityControlAuthorityConfig, ActivityCreateSessionBootstrapConfig, ActivityCreateSessionBootstrapSessionStorageEntry, ActivityDeepLinkOption, @@ -255,6 +256,7 @@ function parseCreateSessionBootstrap(raw: unknown, context: string): ActivityCre const sessionStorage = parseCreateSessionBootstrapSessionStorage(raw.sessionStorage, `${context}.createSessionBootstrap`) const historyState = parseCreateSessionBootstrapHistoryState(raw.historyState, `${context}.createSessionBootstrap`) + const transientOnly = raw.transientOnly === true const selectedOptionsToSessionData = parseCreateSessionBootstrapSelectedOptionsToSessionData( raw.selectedOptionsToSessionData, `${context}.createSessionBootstrap`, @@ -262,6 +264,7 @@ function parseCreateSessionBootstrap(raw: unknown, context: string): ActivityCre return { ...(sessionStorage !== undefined ? { sessionStorage } : {}), ...(historyState !== undefined ? { historyState } : {}), + ...(transientOnly ? { transientOnly } : {}), ...(selectedOptionsToSessionData !== undefined ? { selectedOptionsToSessionData } : {}), } } @@ -305,6 +308,36 @@ function parseEmbeddedRuntime(raw: unknown, context: string): ActivityConfig['em } } +function parseControlAuthority(raw: unknown, context: string): ActivityControlAuthorityConfig | undefined { + if (raw == null) { + return undefined + } + if (!isRecord(raw)) { + throw new Error(`${context}: "controlAuthority" must be an object when provided`) + } + + const mode = raw.mode + if (mode !== 'single-instructor') { + throw new Error(`${context}.controlAuthority: "mode" must be "single-instructor"`) + } + + const scopeRaw = raw.scope + if (scopeRaw !== undefined && scopeRaw !== null && scopeRaw !== 'session' && scopeRaw !== 'inherited') { + throw new Error(`${context}.controlAuthority: "scope" must be "session" or "inherited" when provided`) + } + + const gatingRaw = raw.gating + if (gatingRaw !== undefined && gatingRaw !== null && gatingRaw !== 'all' && gatingRaw !== 'none' && gatingRaw !== 'activity') { + throw new Error(`${context}.controlAuthority: "gating" must be "all", "none", or "activity" when provided`) + } + + return { + mode: 'single-instructor', + ...(scopeRaw === 'session' || scopeRaw === 'inherited' ? { scope: scopeRaw } : {}), + ...(gatingRaw === 'all' || gatingRaw === 'none' || gatingRaw === 'activity' ? { gating: gatingRaw } : {}), + } +} + function parseUtilities(raw: unknown, context: string): ActivityUtility[] | undefined { if (raw == null) { return undefined @@ -538,6 +571,7 @@ export function parseActivityConfig(rawConfig: unknown, sourceLabel = 'activity. const manageDashboard = parseManageDashboard(rawConfig.manageDashboard, context) const manageLayout = parseManageLayout(rawConfig.manageLayout, context) const embeddedRuntime = parseEmbeddedRuntime(rawConfig.embeddedRuntime, context) + const controlAuthority = parseControlAuthority(rawConfig.controlAuthority, context) const reportEndpoint = readOptionalString(rawConfig, 'reportEndpoint', context) const waitingRoom = parseWaitingRoom(rawConfig.waitingRoom, context) @@ -557,6 +591,7 @@ export function parseActivityConfig(rawConfig: unknown, sourceLabel = 'activity. assignOptionalField(parsed, 'manageDashboard', manageDashboard) assignOptionalField(parsed, 'manageLayout', manageLayout) assignOptionalField(parsed, 'embeddedRuntime', embeddedRuntime) + assignOptionalField(parsed, 'controlAuthority', controlAuthority) assignOptionalField(parsed, 'reportEndpoint', reportEndpoint) assignOptionalField(parsed, 'waitingRoom', waitingRoom)