import type { EventKind } from "@a4anthony/proctorkit-types"; import { ScreenshotObserver, type ObserverEmitter as ScreenshotEmitter, type ScreenshotObserverConfig, } from "./screenshot-observer.js"; import type { ScreenShareObserverConfig } from "./screen-share-observer.js"; import type { WebcamObserverConfig } from "./webcam-observer.js"; export type { ScreenshotObserverConfig } from "./screenshot-observer.js"; export type { ScreenShareObserverConfig } from "./screen-share-observer.js"; export type { WebcamObserverConfig } from "./webcam-observer.js"; /** * Minimal contract the observers need from the host's ProctoringClient. * Decoupled from the concrete client so we can unit-test in isolation * without spinning up a worker. */ export interface ObserverEmitter { emit(kind: EventKind, payload?: Record): void; } export interface ClipboardBlockConfig { /** Block Cmd/Ctrl+C from updating the clipboard. The attempt is still logged. */ copy?: boolean; /** Block Cmd/Ctrl+X. The attempt is still logged. */ cut?: boolean; /** * Block paste *except* on elements that opt back in via the * `data-proctoring-allow-clipboard` attribute (set on the element or * any ancestor). Useful for blocking paste into instructions while * still letting candidates paste into their own answer field. */ paste?: boolean; /** Block the right-click / context menu globally. */ contextmenu?: boolean; } export interface ClipboardObserverConfig { /** * Capture the actual text content of copy/paste/cut events. * * SECURITY: candidates' clipboards routinely contain unrelated * personal data (passwords, OTPs, addresses). Capturing it has * material legal and ethical consequences — only turn this on after * obtaining explicit consent from candidates, typically via a * disclosure screen before the test starts. Default: false. */ captureContent?: boolean; /** * When captureContent is on, the maximum number of UTF-8 bytes of * clipboard text included per event. Content is truncated past this. * Default: 2000. */ maxBytes?: number; /** * Block clipboard actions during the proctored test. The attempted * action is always still emitted to the timeline (with * `blocked: true`) so proctors see the cheating signal regardless. * * Pass `true` to block everything. Pass an object to be granular. * Default: nothing blocked. */ block?: boolean | ClipboardBlockConfig; } const ALLOW_ATTR = "data-proctoring-allow-clipboard"; export interface KeyboardObserverConfig { /** * Shortcut strings to block. Each is "Mod+P", "Mod+Shift+I", "F12" * etc. `Mod` resolves to Cmd on Mac, Ctrl elsewhere. Other modifier * tokens: `Ctrl`, `Cmd`, `Alt`, `Shift`. Letters are case-insensitive. * * When a blocked shortcut fires, event.preventDefault() runs and a * `keyboard.blocked` event lands in the timeline with the shortcut * name in the payload. * * Default: a sensible proctoring list (print, save, find, reload, * F12, view source, DevTools). Pass `[]` to disable entirely. */ block?: string[]; } export interface IdleObserverConfig { /** * Seconds of no keystrokes / mouse movement / pointer / wheel / * touch input before `idle.started` fires. The timer pauses while * the tab is hidden — a candidate who switched tabs is "away", not * "idle on this page", and we already emit `tab.hidden` for that * case. * * Default: 60s. Tuned for proctoring: under 30s gives false positives * for thinking pauses; over 2min misses real "candidate walked off" * cases. */ thresholdSeconds?: number; } export interface DomObserversConfig { focus?: boolean; visibility?: boolean; fullscreen?: boolean; network?: boolean; /** * Detect when the cursor leaves the viewport (candidate moves the * mouse off the page to interact with another window / monitor / OS * notification). Emits `pointer.left-window` on `mouseleave` of the * document element and `pointer.returned` on the matching * `mouseenter`. Pass `false` to disable. Default: on. * * False-positives to be aware of: the browser also fires mouseleave * when the candidate switches to another tab via keyboard. We do not * emit if the page is already hidden (visibilityState !== "visible") * to avoid double-flagging the same disengagement that `tab.hidden` * already captures. */ pointer?: boolean; /** * Detect "candidate appears to have walked away" — no keyboard or * pointer activity for N seconds while the tab is still visible. * Pass `false` to disable, `true` for defaults, or an object to tune * the threshold. Default: on, 60s threshold. */ idle?: boolean | IdleObserverConfig; /** * Clipboard signals (copy / paste / cut) and right-click. Pass `true` * (default) to record fact-only payloads. Pass `false` to disable * entirely. Pass a {@link ClipboardObserverConfig} to opt into * capturing content, which has privacy implications. */ clipboard?: boolean | ClipboardObserverConfig; /** * Block in-page keyboard shortcuts. Pass `false` to disable, `true` * to use the default list, or an object for custom shortcuts. * * CAVEAT: browser-owned shortcuts (Cmd+T new tab, Cmd+L address bar, * Cmd+Tab app switch, OS screenshot shortcuts) cannot be intercepted * by a web page — those are filtered by the browser before keydown * reaches your listener. This option blocks the in-page subset only. */ keyboard?: boolean | KeyboardObserverConfig; /** * Detect screenshot attempts (keyboard shortcuts + visibility change * + window blur), emitting `screenshot.attempted` for each. Pass `true` * to enable detection-only, or an object to opt into the on-page blur * overlay deterrent. Defaults to off because the blur overlay changes * the candidate's UX — opt in deliberately. */ screenshot?: boolean | ScreenshotObserverConfig; /** * Request the candidate's screen via `getDisplayMedia` when the * client starts, and record the stream into chunks. Pass `true` to * use defaults (entire screen enforced, 10s VP9 WebM chunks at * 500kbps), or an object to customise. Default: off. * * Prefer a stream returned by `requestScreenShare()` from the candidate's * button click. When `stream` is omitted, the client opens the picker * synchronously during construction for compatibility, so construction * must itself happen in that user-activation handler. */ screenShare?: boolean | ScreenShareObserverConfig; /** * Acquire the candidate's webcam via `getUserMedia` when the client * starts. Pass `true` to grab the camera with no photo loop, or an * object with `photos: true` to capture random snapshots at intervals. * * IMPORTANT: when enabled, construct the `ProctoringClient` inside a * user-activation handler (your "Start session" click). Browsers * reject `getUserMedia` outside that window. Failures are routed via * the `onWebcamError(kind)` callback on the client. */ webcam?: boolean | WebcamObserverConfig; } const DEFAULT_KEYBOARD_BLOCKLIST = [ "Mod+P", // print "Mod+S", // save "Mod+F", // find in page "Mod+R", // reload "F12", // DevTools "Mod+Shift+I", // DevTools (Chrome/Firefox) "Mod+Shift+J", // DevTools (Chrome console) "Mod+Shift+C", // DevTools (Chrome element picker) "Mod+U", // view source ]; /** * DOM-level capture for proctoring signals. Each observer listens on * window or document, debounces nothing (proctors want every event), * and dispatches through the injected emitter. * * The host calls {@link start} once after {@link ProctoringClient.start} * resolves and {@link stop} during {@link ProctoringClient.stop} to * release the listeners cleanly. */ interface ResolvedClipboardConfig { enabled: boolean; captureContent: boolean; maxBytes: number; block: { copy: boolean; cut: boolean; paste: boolean; contextmenu: boolean }; } interface ParsedShortcut { raw: string; key: string; ctrl: boolean; meta: boolean; alt: boolean; shift: boolean; } interface ResolvedKeyboardConfig { enabled: boolean; shortcuts: ParsedShortcut[]; } interface ResolvedIdleConfig { enabled: boolean; thresholdMs: number; } interface ResolvedConfig { focus: boolean; visibility: boolean; fullscreen: boolean; network: boolean; pointer: boolean; clipboard: ResolvedClipboardConfig; keyboard: ResolvedKeyboardConfig; idle: ResolvedIdleConfig; } export class DomObservers { private listening = false; private readonly config: ResolvedConfig; private readonly listeners: Array<{ target: EventTarget; type: string; listener: EventListener; }> = []; private readonly screenshotConfig: ScreenshotObserverConfig | boolean | undefined; private screenshotObserver: ScreenshotObserver | null = null; // Idle state. Out here on the class so tests can inspect via the // public events, but the timer plumbing is intentionally private. private idleTimer: ReturnType | null = null; private idleSince: number | null = null; private isIdle = false; constructor( private readonly emitter: ObserverEmitter, config: DomObserversConfig = {}, ) { this.config = { focus: config.focus ?? true, visibility: config.visibility ?? true, fullscreen: config.fullscreen ?? true, network: config.network ?? true, pointer: config.pointer ?? true, clipboard: resolveClipboardConfig(config.clipboard), keyboard: resolveKeyboardConfig(config.keyboard), idle: resolveIdleConfig(config.idle), }; this.screenshotConfig = config.screenshot; } start(): void { if (this.listening) return; if (typeof window === "undefined" || typeof document === "undefined") { return; } this.listening = true; if (this.config.focus) { this.bind(window, "focus", () => this.emitter.emit("focus.gained")); this.bind(window, "blur", () => this.emitter.emit("focus.lost")); } if (this.config.visibility) { this.bind(document, "visibilitychange", () => { if (document.visibilityState === "hidden") { this.emitter.emit("tab.hidden"); } else if (document.visibilityState === "visible") { this.emitter.emit("tab.visible"); } }); } if (this.config.fullscreen) { this.bind(document, "fullscreenchange", () => { const inFullscreen = document.fullscreenElement !== null; this.emitter.emit(inFullscreen ? "fullscreen.entered" : "fullscreen.exited"); }); } if (this.config.network) { this.bind(window, "online", () => this.emitter.emit("network.online")); this.bind(window, "offline", () => this.emitter.emit("network.offline")); } if (this.config.pointer) { // We bind on documentElement (the ) rather than window so // we don't conflate "mouse moved over the devtools dock" with // "mouse left the viewport entirely". documentElement covers the // full visible page area in every browser we care about. // // We skip emit when the page is already hidden because // tab.hidden already captures that disengagement — a hidden tab // fires its own mouseleave when focus shifts, which would // otherwise double-flag. const root = document.documentElement; this.bind(root, "mouseleave", () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } this.emitter.emit("pointer.left-window"); }); this.bind(root, "mouseenter", () => { if (typeof document !== "undefined" && document.visibilityState === "hidden") { return; } this.emitter.emit("pointer.returned"); }); } if (this.config.idle.enabled) { // The activity events we count toward "alive on this page". // Touch / pointer / wheel covers tablets and trackpads; keydown // is the obvious one. mousemove is here, but throttled by the // idle timer reset itself — we don't add a separate debounce. const activityEvents: Array = [ "keydown", "mousemove", "pointerdown", "wheel", "touchstart", ]; const onActivity = (): void => this.recordActivity(); for (const type of activityEvents) { this.bind(document, type, onActivity); } // Pause the idle timer while the tab is hidden. We do this in // addition to the user's visibilityChange handler so the idle // observer keeps working even if the customer turned visibility // off. this.bind(document, "visibilitychange", () => { if (document.visibilityState === "hidden") { this.clearIdleTimer(); } else { this.recordActivity(); } }); // Kick off the first timer immediately. If the tab is hidden at // start, recordActivity is a no-op and we'll re-arm on the // visibilitychange. this.recordActivity(); } if (this.screenshotConfig !== undefined && this.screenshotConfig !== false) { this.screenshotObserver = new ScreenshotObserver( this.emitter as ScreenshotEmitter, this.screenshotConfig, ); this.screenshotObserver.start(); } if (this.config.keyboard.enabled && this.config.keyboard.shortcuts.length > 0) { const shortcuts = this.config.keyboard.shortcuts; this.bind(document, "keydown", (event) => { const ke = event as KeyboardEvent; const match = matchShortcut(ke, shortcuts); if (!match) return; event.preventDefault(); event.stopPropagation(); this.emitter.emit("keyboard.blocked", { shortcut: match.raw, key: ke.key, code: ke.code, modifiers: describeModifiers(ke), blocked: true, }); }); } if (this.config.clipboard.enabled) { const cfg = this.config.clipboard; this.bind(document, "copy", (event) => { const blocked = cfg.block.copy; if (blocked) event.preventDefault(); this.emitter.emit("clipboard.copy", clipboardPayload(event, cfg, blocked)); }); this.bind(document, "paste", (event) => { const blocked = cfg.block.paste && !pasteAllowed(event.target); if (blocked) event.preventDefault(); this.emitter.emit("clipboard.paste", clipboardPayload(event, cfg, blocked)); }); this.bind(document, "cut", (event) => { const blocked = cfg.block.cut; if (blocked) event.preventDefault(); this.emitter.emit("clipboard.cut", clipboardPayload(event, cfg, blocked)); }); this.bind(document, "contextmenu", (event) => { const blocked = cfg.block.contextmenu; if (blocked) event.preventDefault(); this.emitter.emit("contextmenu.opened", blocked ? { blocked: true } : undefined); }); } } stop(): void { if (!this.listening) return; for (const { target, type, listener } of this.listeners) { target.removeEventListener(type, listener); } this.listeners.length = 0; this.listening = false; this.screenshotObserver?.stop(); this.screenshotObserver = null; this.clearIdleTimer(); this.isIdle = false; this.idleSince = null; } private bind(target: EventTarget, type: string, listener: EventListener): void { target.addEventListener(type, listener); this.listeners.push({ target, type, listener }); } /** * Mark "the candidate is actively using the page right now." If the * idle observer previously fired `idle.started`, also fire * `idle.ended` with the duration. Either way, re-arm the timer that * will fire `idle.started` after `thresholdMs` of further silence. */ private recordActivity(): void { if (!this.config.idle.enabled) return; if (typeof document !== "undefined" && document.visibilityState === "hidden") { // No-op while hidden — visibilitychange will re-arm. return; } if (this.isIdle) { this.isIdle = false; const startedAt = this.idleSince; const durationMs = startedAt !== null ? Date.now() - startedAt : 0; this.idleSince = null; this.emitter.emit("idle.ended", { durationMs }); } this.clearIdleTimer(); this.idleTimer = setTimeout(() => { this.isIdle = true; this.idleSince = Date.now(); this.emitter.emit("idle.started", { thresholdMs: this.config.idle.thresholdMs, }); }, this.config.idle.thresholdMs); } private clearIdleTimer(): void { if (this.idleTimer !== null) { clearTimeout(this.idleTimer); this.idleTimer = null; } } } /** * Build a clipboard-event payload. Always records the *signal*: the * active element's tag (so a proctor can tell "paste into the answer * field" from "paste in the URL bar") and a byte-length when reachable. * * When the host has opted into `captureContent`, also records up to * `maxBytes` of the clipboard text (truncated, with a `truncated` flag * so proctors know they're seeing only a prefix). Default behaviour * does NOT capture content. */ function clipboardPayload( event: Event, cfg: ResolvedClipboardConfig, blocked: boolean, ): Record { const payload: Record = {}; if (blocked) payload["blocked"] = true; const target = (event.target as Element | null) ?? document.activeElement; if (target?.tagName) { payload["targetTag"] = target.tagName.toLowerCase(); } const text = readClipboardText(event); if (text !== null) { const bytes = new TextEncoder().encode(text); payload["byteLength"] = bytes.byteLength; if (cfg.captureContent && bytes.byteLength > 0) { if (bytes.byteLength > cfg.maxBytes) { const prefix = new TextDecoder("utf-8", { fatal: false }).decode( bytes.slice(0, cfg.maxBytes), ); payload["content"] = prefix; payload["truncated"] = true; } else { payload["content"] = text; } } } return payload; } function pasteAllowed(target: EventTarget | null): boolean { let node: Element | null = target instanceof Element ? target : null; while (node) { if (node.hasAttribute(ALLOW_ATTR)) return true; node = node.parentElement; } return false; } /** * Read clipboard text for an event. For paste events the answer lives in * `event.clipboardData.getData("text/plain")` — that's the data being * pasted in. For copy/cut, the same call returns the text only when the * source was an editable element; for everything else (headings, * paragraphs, spans) the browser deliberately leaves clipboardData empty * for security reasons. Fall back to `window.getSelection()` so we * record what the candidate had selected at the moment they hit Cmd+C. */ function readClipboardText(event: Event): string | null { const clipboardEvent = event as ClipboardEvent; if (clipboardEvent.clipboardData) { try { const direct = clipboardEvent.clipboardData.getData("text/plain"); if (typeof direct === "string" && direct.length > 0) return direct; } catch { // Cross-origin reads can throw; fall through to selection. } } if (event.type === "copy" || event.type === "cut") { try { const selection = window.getSelection?.()?.toString(); if (typeof selection === "string" && selection.length > 0) return selection; } catch { // Some browsers throw if the selection is across shadow roots; ignore. } } // Returning empty string vs null distinguishes "we observed empty content" // from "we could not observe content at all". Empty content still reports // byteLength: 0 in the payload so the timeline shows the event happened. return clipboardEvent.clipboardData ? "" : null; } function resolveClipboardConfig( raw: boolean | ClipboardObserverConfig | undefined, ): ResolvedClipboardConfig { const noBlock = { copy: false, cut: false, paste: false, contextmenu: false }; if (raw === false) { return { enabled: false, captureContent: false, maxBytes: 2_000, block: noBlock }; } if (raw === undefined || raw === true) { return { enabled: true, captureContent: false, maxBytes: 2_000, block: noBlock }; } return { enabled: true, captureContent: raw.captureContent ?? false, maxBytes: raw.maxBytes ?? 2_000, block: resolveBlockConfig(raw.block), }; } function resolveIdleConfig(raw: boolean | IdleObserverConfig | undefined): ResolvedIdleConfig { const DEFAULT_THRESHOLD_MS = 60_000; if (raw === false) return { enabled: false, thresholdMs: DEFAULT_THRESHOLD_MS }; if (raw === undefined || raw === true) { return { enabled: true, thresholdMs: DEFAULT_THRESHOLD_MS }; } const seconds = raw.thresholdSeconds; const thresholdMs = typeof seconds === "number" && seconds > 0 ? Math.floor(seconds * 1000) : DEFAULT_THRESHOLD_MS; return { enabled: true, thresholdMs }; } function resolveKeyboardConfig( raw: boolean | KeyboardObserverConfig | undefined, ): ResolvedKeyboardConfig { if (raw === false) { return { enabled: false, shortcuts: [] }; } if (raw === undefined) { return { enabled: false, shortcuts: [] }; } const list = raw === true ? DEFAULT_KEYBOARD_BLOCKLIST : (raw.block ?? DEFAULT_KEYBOARD_BLOCKLIST); return { enabled: list.length > 0, shortcuts: list.map(parseShortcut).filter((s): s is ParsedShortcut => s !== null), }; } function parseShortcut(input: string): ParsedShortcut | null { const parts = input .split("+") .map((p) => p.trim()) .filter(Boolean); if (parts.length === 0) return null; const key = parts[parts.length - 1]?.toLowerCase(); if (!key) return null; const mods = new Set(parts.slice(0, -1).map((m) => m.toLowerCase())); const isMac = typeof navigator !== "undefined" && /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent); return { raw: input, key, ctrl: mods.has("ctrl") || (mods.has("mod") && !isMac), meta: mods.has("cmd") || mods.has("meta") || (mods.has("mod") && isMac), alt: mods.has("alt") || mods.has("option"), shift: mods.has("shift"), }; } function matchShortcut(event: KeyboardEvent, shortcuts: ParsedShortcut[]): ParsedShortcut | null { const eventKey = event.key.toLowerCase(); for (const s of shortcuts) { if (event.ctrlKey !== s.ctrl) continue; if (event.metaKey !== s.meta) continue; if (event.altKey !== s.alt) continue; if (event.shiftKey !== s.shift) continue; if (eventKey === s.key) return s; // Function keys are case-insensitive but also case-uniform; normalise. if (eventKey.toUpperCase() === s.key.toUpperCase()) return s; } return null; } function describeModifiers(event: KeyboardEvent): string[] { const mods: string[] = []; if (event.ctrlKey) mods.push("Ctrl"); if (event.metaKey) mods.push("Meta"); if (event.altKey) mods.push("Alt"); if (event.shiftKey) mods.push("Shift"); return mods; } function resolveBlockConfig(raw: boolean | ClipboardBlockConfig | undefined): { copy: boolean; cut: boolean; paste: boolean; contextmenu: boolean; } { if (raw === true) { return { copy: true, cut: true, paste: true, contextmenu: true }; } if (!raw) { return { copy: false, cut: false, paste: false, contextmenu: false }; } return { copy: raw.copy ?? false, cut: raw.cut ?? false, paste: raw.paste ?? false, contextmenu: raw.contextmenu ?? false, }; }