/** * Verification core — the single central authority for what counts as a * pass or a failure when re-verifying a proctored environment. * * Two callers feed it: * - the runtime {@link ../checkpoint checkpoint}, which gathers facts from * the live capture tracks it already holds, and * - the resume probe (packages/vue), which re-acquires media after a * mid-assessment refresh and gathers facts from the fresh streams. * * They differ only in HOW they gather the facts and HOW they render the * verdict (a drift `CheckpointChange` vs a candidate-facing * `ResumeCheckItem`). The RULE — precedence, loudness-independence, and the * graceful skips — lives here and nowhere else, so the two can never drift * apart. * * Purity is the contract: no DOM, no `getUserMedia`, no timers, no I/O. Every * function takes already-normalised facts and returns a neutral verdict. The * side-effecting gathering (enumeration, re-acquisition, frame capture, the * server ping) stays in the callers — this module only decides. */ // ── Device checks (camera · microphone · speaker) ────────────────────────── /** Tri-state permission, mirroring the browser's Permissions API. */ export type PermissionFact = "granted" | "denied" | "unknown"; /** * Whether the selected device id is still enumerated. * - `present` the id we saved is in the current device list * - `absent` the kind IS enumerable but our id is gone (unplugged) * - `cannot-tell` enumeration unavailable, or this kind can't be listed * (ids censored without a media grant; Safari never lists * audiooutput) — unknown, NOT missing */ export type PresenceFact = "present" | "absent" | "cannot-tell"; /** * The liveness of the capture track for this device. * - `live` the browser is delivering frames/samples * - `ended` track.readyState === "ended" (unplugged / stopped) * - `muted` track.muted === true — browser-owned, set when the source * stops delivering data (OS mute / grabbed by another app). * Loudness-independent: a quiet room is NOT muted. * - `none` no track to inspect (unavailable, not a failure) */ export type TrackFact = "live" | "ended" | "muted" | "none"; export interface DeviceFacts { /** * Whether the session depends on this device's PERMISSION. Only when true is * a `denied` permission treated as a regression — a policy that never needed * the mic can't false-flag a lost mic permission. Presence and track are * still checked regardless (a speaker has no permission of its own but its * presence still matters). */ required: boolean; permission: PermissionFact; presence: PresenceFact; track: TrackFact; } /** * The neutral device verdict — the FIRST tripped rule, in root-cause order, or * `ok`. Each caller maps these to its own vocabulary (e.g. `permission-denied` * → checkpoint `permission-lost` / resume `blocked`). */ export type DeviceVerdict = | "ok" | "permission-denied" | "device-absent" | "track-ended" | "track-muted"; // ── Granular per-rule verdicts ───────────────────────────────────────────── // Each rule as an independent atom. The checkpoint reports permission / // presence / track drift SEPARATELY (all three can fire for one device), so it // composes these itself; `deviceVerdict` composes the same atoms in precedence // order for callers (resume) that want a single first-tripped verdict per row. /** Permission regression, only when the session depends on the permission. */ export function permissionVerdict(facts: { required: boolean; permission: PermissionFact; }): "ok" | "denied" { return facts.required && facts.permission === "denied" ? "denied" : "ok"; } /** Selected device gone — only `absent` fails; `cannot-tell` is a skip. */ export function presenceVerdict(facts: { presence: PresenceFact; }): "ok" | "absent" { return facts.presence === "absent" ? "absent" : "ok"; } /** Capture track liveness. Loudness plays no part — `live`/`none` pass. */ export function trackVerdict(facts: { track: TrackFact; }): "ok" | "ended" | "muted" { if (facts.track === "ended") return "ended"; if (facts.track === "muted") return "muted"; return "ok"; } /** * Evaluate one device against the shared rule as a single verdict. * * Precedence is root-cause order: a lost permission explains a missing device * which explains a dead track, so we report the earliest, most actionable * cause. Composed from the granular atoms above so there is exactly one * definition of each rule. Any fact the browser couldn't answer (`unknown` / * `cannot-tell` / `none`) skips its rule — unknown is never broken. */ export function deviceVerdict(facts: DeviceFacts): DeviceVerdict { if (permissionVerdict(facts) === "denied") return "permission-denied"; if (presenceVerdict(facts) === "absent") return "device-absent"; const track = trackVerdict(facts); if (track === "ended") return "track-ended"; if (track === "muted") return "track-muted"; return "ok"; } // ── Display (external monitor) ───────────────────────────────────────────── export type DisplayFact = "single" | "extended" | "unknown"; export interface DisplayFacts { /** The display state that passed preflight (the comparison point). */ baseline: DisplayFact; /** The display state now. `unknown` when `screen.isExtended` is unsupported. */ current: DisplayFact; } export type DisplayVerdict = "ok" | "external-monitor"; /** * Flag only a NEW second display: single at baseline, extended now. A baseline * that was already extended can't regress, and an `unknown` current reading * (non-Chromium) passes rather than guessing. */ export function displayVerdict(facts: DisplayFacts): DisplayVerdict { return facts.baseline === "single" && facts.current === "extended" ? "external-monitor" : "ok"; } // ── Fullscreen ───────────────────────────────────────────────────────────── export interface FullscreenFacts { inFullscreen: boolean; } export type FullscreenVerdict = "ok" | "not-fullscreen"; export function fullscreenVerdict(facts: FullscreenFacts): FullscreenVerdict { return facts.inFullscreen ? "ok" : "not-fullscreen"; } // ── Screen share ─────────────────────────────────────────────────────────── export type ScreenShareVerdict = "needs-reshare"; /** * A refresh unconditionally drops the `getDisplayMedia` capture, and only a * fresh candidate gesture can re-acquire it — so there is no passing state to * probe. Kept as a function (not a bare constant) so the reason lives here * alongside the other verdict rules rather than being special-cased inline. */ export function screenShareVerdict(): ScreenShareVerdict { return "needs-reshare"; } // ── Connection ───────────────────────────────────────────────────────────── export interface ConnectionFacts { /** `true`/`false` from a reachability probe, or `not-probed` when skipped. */ reachable: boolean | "not-probed"; } export type ConnectionVerdict = "ok" | "offline"; /** * Reachability, not a speed test. A caller that doesn't probe connectivity * passes `not-probed` and the check is skipped (unavailable, not offline). */ export function connectionVerdict(facts: ConnectionFacts): ConnectionVerdict { if (facts.reachable === "not-probed") return "ok"; return facts.reachable ? "ok" : "offline"; }