# @a4anthony/proctorkit-sdk

Framework-agnostic browser SDK for the proctoring platform. Drop it into any web-based testing surface (React, Vue, plain HTML — doesn't matter) and it captures the behavioural signals a proctor needs, queues them durably in the browser, and uploads them in the background.

This document is the source of truth for **what the SDK does, why it does it that way, and how to integrate it**. It assumes you've already read the repo root README for the architectural context (multi-tenant SaaS, candidate session never pays for proctoring, $40/mo MVP cost ceiling).

---

## Table of contents

1. [Mental model](#mental-model)
2. [Vue one-component setup](#vue-one-component-setup)
3. [Public API](#public-api)
4. [Lifecycle](#lifecycle)
5. [Architecture: main thread vs. worker](#architecture-main-thread-vs-worker)
6. [Event queue and uploader](#event-queue-and-uploader)
7. [Observers](#observers)
   - [DOM observers](#dom-observers)
   - [Clipboard](#clipboard)
   - [Keyboard shortcuts](#keyboard-shortcuts)
   - [Screenshot](#screenshot)
   - [Screen share](#screen-share)
8. [Event kinds reference](#event-kinds-reference)
9. [Integration recipes](#integration-recipes)
10. [Privacy and legal](#privacy-and-legal)
11. [Testing](#testing)
12. [Limitations and known gaps](#limitations-and-known-gaps)

---

## Mental model

The SDK is a **library, not a host application**. It does five things:

1. **Captures** browser-side signals (focus, visibility, clipboard, keyboard, screenshot intent, screen sharing).
2. **Queues** them durably in IndexedDB so a tab refresh or network blip doesn't lose anything.
3. **Uploads** them in batches to a server endpoint you point it at, with retry and back-pressure.
4. **Identifies** the candidate at construction time so every batch carries the right identity.
5. **Authenticates** to the server with a public app key (`pk_live_…`) so multi-tenant routing works without a per-customer build.

What the SDK **does not do**:

- Render UI for the customer's exam. That's the customer's job.
- Paint modals or banners explaining what's about to happen. The native browser prompts (screen-share picker, fullscreen confirmation) are surfaced by the browser itself; pre-prompts and error banners are the customer's responsibility.
- Decide what to do with the captured signals. That's the dashboard / server.

The driving principle: the candidate's test session should never be slowed by the SDK. All storage and network happens off the main thread.

### The "fire and forget" contract

The SDK is **declarative**. You describe what you want in the constructor options, and the SDK runs it. There are no lifecycle methods to call in sequence — no `start()` to await, no `stop()` to remember, no `identify()` to chain.

```ts
new ProctoringClient({
  sessionId,
  ingestUrl,
  appId,
  workerUrl,
  candidate: { id, name, email },
  observers: {
    /* ... */
  },
  endSignal: endController.signal,
  onScreenShareError: (kind) => setError(kind),
  onError: (err) => showFatalError(err),
});
```

That's the whole integration when screen sharing is off. When it is on, prefer
`requestScreenShare()` as the first action in the candidate's button handler,
then pass the granted stream through `observers.screenShare.stream`. The
constructor's legacy auto-acquisition remains supported and now opens the
picker synchronously before worker startup, but the explicit helper provides
clearer permission errors and lifecycle ownership.

---

## Vue one-component setup

Vue customers should use `ProctoredAssessment` from `@a4anthony/proctorkit-vue`.
It hides attempt resolution, preflight skip logic, face-photo wiring, SDK startup,
and permission-error handling behind one component.

```bash
pnpm add @a4anthony/proctorkit-vue @a4anthony/proctorkit-sdk
```

```vue
<script setup lang="ts">
import { ProctoredAssessment } from "@a4anthony/proctorkit-vue";
import "@a4anthony/proctorkit-vue/style.css";

const candidate = {
  id: "cand_123",
  name: "Jane Candidate",
  email: "jane@example.com",
};
</script>

<template>
  <ProctoredAssessment
    app-id="pk_live_xxx"
    correlation-id="client-attempt-123"
    :candidate="candidate"
    preset="standard"
    api-base-url="https://proctoring.example.com"
  >
    <template #default="{ client, attempt, endSession }">
      <AssessmentQuestions
        :proctoring-client="client"
        :session-id="attempt.sessionId"
        @complete="endSession"
      />
    </template>
  </ProctoredAssessment>
</template>
```

`ProctoredAssessment` accepts policy overrides for both media permissions and
system-class preflight rows:

```vue
<ProctoredAssessment
  preset="standard"
  :policy-overrides="{
    preflight: {
      browser: true,
      camera: true,
      microphone: true,
      speaker: true,
      system: {
        browser: true,
        device: true,
        layout: true,
        externalMonitor: false,
        connection: true,
      },
      thresholds: {
        minBandwidthMbps: 5,
        allowExternalMonitor: true,
        allowMobile: false,
      },
    },
  }"
/>
```

`preflight.browser` remains the master system-check switch for backward
compatibility. If it is `false`, the nested `system` rows are skipped.

Use the slot's `client` for assessment media helpers such as
`recordAudioClip()`, `recordVideoClip()`, `uploadVideoClip()`, and
`playAudioFile()`. Vue apps can also import `AudioPlayer`, `AudioRecorder`, and
`VideoRecorder` from `@a4anthony/proctorkit-vue` for ready-made UI around
those helpers. Advanced/custom framework integrations can still use the raw
`ProctoringClient` API below.

The local candidate demo host on `http://127.0.0.1:5733` is a Vue app built on
this wrapper. It loads the published demo assessment by `testId` from the
backend and renders those dynamic questions; the core SDK remains
framework-neutral and does not depend on Vue.

---

## Public API

```ts
import {
  ProctoringClient,
  type ProctoringClientOptions,
  type DomObserversConfig,
  type ScreenShareObserverConfig,
  type ScreenshotObserverConfig,
  type CandidateIdentity,
  type WorkerToMainMessage,
} from "@a4anthony/proctorkit-sdk";
```

### `class ProctoringClient`

The single entry point. Construct one per session.

```ts
new ProctoringClient({
  sessionId: "exam-2026-05-20-alice", // required
  ingestUrl: "https://api.yourapp.com/ingest", // required
  appId: "pk_live_abc123", // recommended
  workerUrl: new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url), // required
  candidate: {
    // recommended
    id: "user_42",
    name: "Alice",
    email: "alice@example.com",
  },
  observers: {
    clipboard: { block: true },
    keyboard: true,
    screenshot: { blurOnSuspicion: true },
    screenShare: true,
  },
  endSignal: endController.signal, // optional but recommended
  onScreenShareError: (kind) => {
    // optional
    showBanner(kind);
  },
  onError: (err) => showFatalError(err), // optional
  onEvent: (msg) => trackUploadHealth(msg), // optional
});
```

**The constructor auto-starts initialization.** No `.start()` call. The SDK does not emit `sdk.ready` until its mandatory delivery checks and observer startup succeed; it then continues until `endSignal.abort()` is called. Page unloads only flush queued events; the backend infers abandonment from missing heartbeat/activity.

#### Constructor options

| Field                | Required    | Default             | Purpose                                                                                                                                                                                                                                                                       |
| -------------------- | ----------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sessionId`          | yes         | —                   | Internal sitting identifier returned by attempt resolution. Used for queue/storage scope and server reconciliation.                                                                                                                                                           |
| `ingestUrl`          | yes         | —                   | Full URL of your server's ingest endpoint. Receives one POST per batch.                                                                                                                                                                                                       |
| `workerUrl`          | yes         | —                   | URL of the bundled worker entry. In Vite/webpack: `new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url)`.                                                                                                                                                             |
| `appId`              | recommended | —                   | Public app key from your dashboard (`pk_live_…`). Routes events to the right organisation. Without it the server falls back to a demo org.                                                                                                                                    |
| `candidate`          | recommended | —                   | The candidate identity attached to every batch. Equivalent to LogRocket's `identify()` but passed at construction.                                                                                                                                                            |
| `policySnapshot`     | recommended | —                   | Complete resolved assessment policy. Emitted once as `session.policy` so post-session analysis honours the same signal controls even when preflight is skipped.                                                                                                               |
| `observers`          | no          | all on              | See [Observers](#observers). Pass `false` to disable all capture; pass an object to opt observers in or out.                                                                                                                                                                  |
| `endSignal`          | no          | —                   | Optional external `AbortSignal` that invokes the same clean-end path. Hosts can and should keep `await client.end()` for explicit submission teardown.                                                                                                                        |
| `onScreenShareError` | no          | —                   | Called with `activation-required`, `permission-denied`, `unsupported`, `wrong-surface`, or `track-ended` when screen sharing cannot start. Without it the session simply doesn't start (a warning is logged).                                                                 |
| `onError`            | no          | —                   | Called for any other init failure (worker init, IndexedDB denied, ingest rejection, or recording-storage verification failure). Delivery failures are typed as `DeliveryReadinessError`. Without it, the rejection surfaces as an unhandled promise rejection in the console. |
| `onEvent`            | no          | —                   | Worker→main message stream. Useful for upload health: `queued`, `uploaded`, `upload-failed`, `dropped`, `init-failed`.                                                                                                                                                        |
| `telemetry`          | no          | `{ enabled: true }` | Privacy-safe SDK health events sent through the existing ingest pipeline. Pass `false` to disable.                                                                                                                                                                            |
| `batchSize`          | no          | 50                  | Max events per upload batch.                                                                                                                                                                                                                                                  |
| `batchIntervalMs`    | no          | 2000                | Max delay before flushing a non-full batch.                                                                                                                                                                                                                                   |
| `maxQueueBytes`      | no          | 50 MB               | When the IndexedDB queue exceeds this, oldest events are dropped to make room.                                                                                                                                                                                                |
| `maxRetries`         | no          | 5                   | Maximum delivery attempts per batch, with exponential backoff.                                                                                                                                                                                                                |

#### Instance method (singular)

The SDK exposes a small set of instance methods for host-driven actions:

- **`emit(kind, payload?)`** — fire a custom event from your app code, eg. `client.emit("exam.submitted", { score: 87 })`. Fire-and-forget. The event flows through the same queue and uploader as observer-captured events. Throws if the client isn't ready yet (rare — only fires if `emit()` is called synchronously in the same tick as construction).
- **`startScreenShareRecording()`** — when `observers.screenShare.deferRecording` is true, attach MediaRecorder to the already-active screen stream and begin chunk upload. Returns false if the stream is unavailable or cannot be recorded.

Everything else (lifecycle, identity, observers) is fixed at construction.

---

## Lifecycle

```
new ProctoringClient(opts)
        │
        │  ◀── must run inside a user-activation handler if screenShare is on
        │
        │  Synchronously:
        │   1. request getDisplayMedia when screenShare has no supplied stream
        │   2. return the client instance
        │
        │  Asynchronously:
        │   3. spawn Worker
        │   4. postMessage({ type: "init", config, candidate })
        │   5. require the ingest endpoint to accept a delivery canary
        │   6. for recording, require the server-selected storage path
        │      to persist and acknowledge a disposable 1 KiB canary
        │   7. await the already-open screen picker
        │   8. start recording and DOM observers; emit sdk.ready
        │
        ▼
   running ──┬── observer-captured events flow to the worker
             │
             ├── client.emit(kind, payload?)  (rare — custom signals)
             │
             ├── customer reads onEvent for upload health
             │
             ▼
clean end:
  • endSignal.abort()        ◀── customer-controlled teardown

        │ 1. stop observers
        │ 2. release screen-share stream
        │ 3. postMessage({ type: "flush" })
        │ 4. worker drains queue with fetch keepalive
        │ 5. worker.terminate()
        ▼
     stopped

page unload / refresh:
  • window.pagehide          ◀── flush only; not terminal
```

### Screen-share user activation

`navigator.mediaDevices.getDisplayMedia()` requires the call to be inside a **user-activation event**. The preferred integration makes that boundary explicit:

```ts
import { ProctoringClient, requestScreenShare } from "@a4anthony/proctorkit-sdk";

async function handleStartSession() {
  const stream = await requestScreenShare({ enforceEntireScreen: true });
  const client = new ProctoringClient({
    sessionId,
    ingestUrl,
    appId,
    observers: {
      screenShare: {
        stream,
        enforceEntireScreen: true,
        stopExternalStreamOnStop: true,
      },
    },
  });
  return client;
}
```

Call `handleStartSession` directly from the candidate's button click. Do not
call it from an effect, timer, worker message, or after unrelated asynchronous
work. If you use the compatibility shortcut `screenShare: true`, construct the
client directly in that click handler; the SDK invokes the picker before its
first worker await.

If `observers.screenShare` is off, this requirement doesn't apply — you can construct anywhere.

### Why `endSignal` instead of `client.stop()`

`AbortSignal` is the standard JavaScript primitive for "this operation is cancellable." It composes well: the customer can wire the same signal to fetch calls, intervals, and event listeners. Calling `controller.abort()` ends the session along with everything else.

If the customer doesn't pass an `endSignal`, the SDK has no clean programmatic end. `pagehide` does not mark the session abandoned because refresh and tab close look the same to the browser. For exams that complete with a "Submit" button, the customer should pass an `endSignal` and abort it after their own submit flow completes.

### Why callbacks for errors instead of try/catch

The constructor returns immediately. The async work (worker init, screen-share picker) happens after the customer has already moved on. There's no thrown error to catch in the synchronous code path.

For screen-share specifically:

- **`onScreenShareError(kind)`** — called with `"activation-required"`, `"permission-denied"`, `"unsupported"`, `"wrong-surface"`, or `"track-ended"`. The session does not start; the observer also emits `screen-share.declined` or `screen-share.wrong-surface` where applicable.
- **`onError(err)`** — called for anything else that can fail at init: worker bundle fails to load, IndexedDB is denied (private browsing in some browsers), the ingest endpoint does not explicitly accept the canary, or the selected recording-storage path cannot persist its canary.

If the customer doesn't pass these callbacks, the rejection surfaces as an unhandled promise rejection in the browser console. Not silent — just not customisable.

### Mandatory delivery readiness

`sdk.ready` means more than local worker startup. Before it is emitted, the SDK
must send a `sdk.delivery.ready` event through the normal ingest path and receive
an acknowledgement that explicitly accepts that event ID. This exercises the
configured URL, CORS, app-origin authorization, request format, and response
contract. When continuous screen or webcam recording is configured, the SDK
also resolves the server-selected upload mode and writes, verifies, and removes
a disposable 1 KiB object through that exact relay or direct-storage path.

This gate is always enabled. It has no constructor option, policy override, or
development bypass. Snapshot-only webcam capture does not run the recording
storage check because it uses a different per-photo uploader; the ingest check
still always runs.

The Vue assessment wrapper also performs a workload-aware storage check in the
preflight connection row for continuous-recording policies. It excludes a 1
KiB warm-up and scores three payloads sized from the enabled recorders' bitrate
and chunk interval. Their median must finish within half a chunk interval. Raw
Mbps estimates remain telemetry and do not block a candidate because small
HTTP probes are dominated by latency and may not represent sustained media
delivery to the configured storage route.

For host diagnostics, delivery failures are instances of the exported
`DeliveryReadinessError`. Its `code` identifies the failed boundary:

- `ingest-timeout`, `ingest-unreachable`, `ingest-not-authorized`,
  `ingest-rejected`, or `ingest-invalid-response`;
- `upload-config-timeout`, `upload-config-unreachable`, or
  `upload-config-invalid`;
- `media-storage-timeout`, `media-storage-too-slow`, `media-storage-unreachable`,
  `media-storage-rejected`, or `media-storage-invalid-response`.

Use the code for support diagnostics, but show candidates a neutral connection
message and let them retry the complete start flow. Do not reveal storage URLs,
authorization details, or raw server errors in candidate UI. Workload-aware
readiness failures also expose structured, operator-only timing data on
`DeliveryReadinessError.details` when available.

### Pagehide safety

The SDK installs a `pagehide` listener that asks the worker to flush queued events. It does not emit `session.ended` or mark the session abandoned on pagehide. The server should rely on heartbeat/activity expiry for true abandonment, which keeps ordinary refreshes from creating a new attempt and forcing preflight again.

### Clean end safety

Calling `client.end()` sends `session.end_requested` immediately, then drains media uploaders and emits the final `session.ended`. If the final event never reaches the server after the drain deadline, the backend auto-finalizes the session and analyzes whatever media arrived.

### SDK health telemetry

The SDK emits its own operational health events through the same ingest endpoint as proctoring events. This gives the dashboard a session-level trail for SDK boot, media, worker, and upload failures without adding Sentry, OpenReplay, or another third-party script to the candidate's browser by default.

Telemetry is enabled by default and can be tuned at construction:

```ts
new ProctoringClient({
  sessionId,
  ingestUrl,
  workerUrl,
  telemetry: {
    enabled: true,
    debug: false,
  },
});
```

Set `telemetry: false` or `telemetry: { enabled: false }` to disable SDK health telemetry. Keep `debug` off in production unless a customer has explicitly agreed to a short diagnostic window.

The SDK emits these health event kinds:

- `sdk.initialized`
- `sdk.ready`
- `sdk.error`
- `sdk.upload.failed`
- `sdk.media.failed`
- `sdk.permission.denied`
- `sdk.worker.failed`
- `sdk.stop.drain-timeout`

Every telemetry payload uses stable diagnostic fields where possible:

- `code`: stable error code, for example `runtime.webcam.permission_denied` or `upload.media.chunk_dropped`
- `phase`: boot, media, worker, upload, or stop phase where the issue happened
- `recoverable`: whether the session can reasonably continue
- `sdkVersion`: package version injected into the published SDK bundle
- `buildSha`: immutable Git commit injected into the published SDK bundle
- `fingerprintId`: short hash for grouping similar failures
- `browser`, `visibilityState`, `networkOnline`, `pageOrigin`: environment diagnostics

Privacy defaults are intentionally narrow. SDK telemetry does not collect DOM content, page text, cookies, localStorage/sessionStorage, candidate media, raw candidate answers, full page URLs, paths, query strings, fragments, referrers, or document titles. Page-load evidence contains only the customer application origin and whether the page was backgrounded. The legacy `includePageUrl` option is deprecated and ignored. Raw stack traces are excluded unless `debug` is enabled; stack hashes are still included so repeated failures can be grouped.

---

## Architecture: main thread vs. worker

```
┌───────────────────────────── Main thread ──────────────────────────────┐
│                                                                         │
│  ProctoringClient   ◀───── customer constructs in click handler         │
│   │                                                                     │
│   │  DOM observers  ◀───── focus, blur, visibility, fullscreen,         │
│   │                        clipboard, keyboard, screenshot, screen-share│
│   │                                                                     │
│   │  postMessage({ type: "emit", event })                               │
│   ▼                                                                     │
│  ┌──── Worker boundary ──────────────────────────────────────────────┐  │
│  │                                                                   │  │
│  │  WorkerCore                                                       │  │
│  │   │                                                               │  │
│  │   ├── EventQueue (IndexedDB-backed FIFO)                          │  │
│  │   │     - durable across reloads                                  │  │
│  │   │     - byte-counted to enforce maxQueueBytes                   │  │
│  │   │     - oldest-first drop on overflow                           │  │
│  │   │                                                               │  │
│  │   └── Uploader (batched POST + retry)                             │  │
│  │         - batch every batchIntervalMs OR when batchSize reached   │  │
│  │         - exponential backoff on 5xx / network error              │  │
│  │         - keepalive flush on pagehide                             │  │
│  │                                                                   │  │
│  └──── network ─────────────────────── POST {ingestUrl} ───────────▶ │  │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘
```

The main thread never blocks on storage or network. Even on a slow phone, the cost the candidate pays is one `postMessage` per emitted event — microseconds.

The Worker is a **dedicated** Worker (not a SharedWorker). One per session per tab. Two tabs of the same exam will run two independent workers — intentional, because each session has its own IndexedDB store keyed by `sessionId`.

---

## Event queue and uploader

### Queue invariants

- **Order**: events are uploaded in the order they were emitted. Concurrent emits write into IndexedDB transactions that the browser serialises; a JS-side lock prevents torn byte counts.
- **Durability**: events persist across page reloads. If the candidate closes and reopens the tab on the same `sessionId`, the queue resumes from disk.
- **Overflow**: when `maxQueueBytes` is exceeded, the oldest events are dropped (FIFO eviction) until we're under budget. A `dropped` message fires with the count. FIFO over LIFO because the most recent events tend to be the most diagnostically valuable.

### Upload protocol

The Uploader POSTs `IngestBatchRequest` to `ingestUrl`:

```ts
interface IngestBatchRequest {
  batchId: string; // UUID per attempt; server uses for idempotency
  sessionId: string;
  candidate?: CandidateIdentity; // included once the constructor has the candidate
  events: SessionEvent[]; // up to batchSize
}
```

Headers:

- `Content-Type: application/json`
- `X-App-Id: pk_live_…` (when `appId` is set)
- `X-Batch-Id: <batchId>`

Server response:

```ts
interface IngestBatchResponse {
  batchId: string;
  accepted: string[]; // event IDs successfully persisted
  rejected: string[]; // event IDs rejected (eg. validation)
}
```

Accepted IDs are deleted from the queue. Rejected IDs are dropped (no point retrying — they'll be rejected again). If both arrays are empty the batch is treated as a transient failure and retried.

### Retry behaviour

- **5xx, 408, 429, invalid acknowledgement, or network error** → retry with exponential backoff and jitter, capped at `maxRetries` attempts.
- **Other request-level 4xx response** → stop the current drain and keep the queue intact so a key/origin/configuration fix can recover it. Event-level rejections listed in a valid 2xx response are acknowledged and removed.
- **Page closing / refresh** → ask the worker to flush queued events on pagehide. This is not a terminal session end.

---

## Observers

All observers are wired through `options.observers`. Defaults: focus, visibility, fullscreen, network are **on**; clipboard fires fact-only events (no content capture); keyboard is **off**; screenshot is **off**; screen-share is **off**. Pass `observers: false` to disable everything.

### DOM observers

| Observer     | Default | Emits                                     | Description                                          |
| ------------ | ------- | ----------------------------------------- | ---------------------------------------------------- |
| `focus`      | on      | `focus.gained`, `focus.lost`              | Window focus changes.                                |
| `visibility` | on      | `tab.hidden`, `tab.visible`               | Tab switching, minimising.                           |
| `fullscreen` | on      | `fullscreen.entered`, `fullscreen.exited` | Whatever the host platform requested fullscreen for. |
| `network`    | on      | `network.online`, `network.offline`       | Browser-level connectivity.                          |

Disable individually: `observers: { focus: false, network: false }`.

### Clipboard

```ts
observers: {
  clipboard: {
    captureContent: false,           // see "Privacy and legal" below
    maxBytes: 2000,                  // truncation cap when capturing content
    block: false,                    // or: true / { copy, cut, paste, contextmenu }
  },
}
```

Emits one of `clipboard.copy` | `clipboard.paste` | `clipboard.cut` | `contextmenu.opened` per action. Payload includes:

- `targetTag` — the lowercase tagName of the element the action fired on (`input`, `textarea`, `div`, etc).
- `byteLength` — the UTF-8 byte length of the clipboard text (always populated when the text is reachable).
- `blocked` — `true` if the action was prevented.
- `content` — the actual text (truncated to `maxBytes`), **only when `captureContent` is on**.
- `truncated` — `true` if `content` was truncated.

**Per-element opt-back-in:** when `block.paste` is on, an element (or any ancestor) with the attribute `data-proctoring-allow-clipboard` still receives paste. Useful for the candidate's answer field while blocking paste elsewhere.

**Selection fallback for copy/cut:** when the clipboard event has no `clipboardData` text (eg. copy outside an editable element), the SDK falls back to `window.getSelection().toString()`.

### Keyboard shortcuts

```ts
observers: {
  keyboard: true,                    // use the default block list
  // or:
  keyboard: { block: ["Mod+P", "F12", "Mod+Shift+I"] },
}
```

The default block list: `Mod+P`, `Mod+S`, `Mod+F`, `Mod+R`, `F12`, `Mod+Shift+I`, `Mod+Shift+J`, `Mod+Shift+C`, `Mod+U`.

`Mod` resolves to Cmd on macOS, Ctrl elsewhere. Letters are case-insensitive.

Emits `keyboard.blocked` with `{ shortcut, key, code, modifiers, blocked: true }`.

**Caveat:** browser-owned shortcuts (Cmd+T new tab, Cmd+L address bar, Cmd+Tab app switch, OS screenshot keys) cannot be intercepted by a web page. The OS sees them first. This option blocks the in-page subset only.

### Screenshot

```ts
observers: {
  screenshot: true,                  // detection-only
  // or:
  screenshot: {
    blurOnSuspicion: true,           // deterrent blur overlay
    resumeRevealMs: 5000,            // 5s before Resume button shows
    resumeButtonText: "Resume session",
    warningText: "Click below to continue your session.",
  },
}
```

Emits `screenshot.attempted` with `{ trigger: "keyboard" | "visibility" | "blur", shortcut?: string }`. Triggers:

- **`keyboard`** — `Cmd+Shift`, `Ctrl+Shift`, `PrintScreen`, `Cmd+P`, `Ctrl+P` keydown. Cmd+Shift covers macOS Cmd+Shift+3/4/5/6 (the digit keydown is OS-swallowed before reaching the page; the modifier keydown isn't).
- **`visibility`** — `document.visibilityState === "hidden"`. Some screenshot tools snapshot after the page is hidden.
- **`blur`** — `window` blur. Catches the macOS Cmd+Shift+4 flow where focus leaves the browser as the clipping tool opens.

#### The blur overlay (when `blurOnSuspicion: true`)

A frosted overlay covers the page when a suspicion fires. Behaviour ported from the production teq-lib reference:

1. **Cmd+Shift keydown** → set internal latch flag `screenshotKeyActive = true`, show blur.
2. **window.blur** during clipping → re-show (idempotent).
3. **window.focus** when the clipping tool closes → **gated on the latch**. The latch is still true (macOS consumed the modifier keyups while the clipping tool was up), so the blur stays.
4. The candidate either:
   - Releases a modifier cleanly back into the page (Shift / Meta / Control / PrintScreen / p) → latch clears, blur hides.
   - Clicks "Resume session" after the `resumeRevealMs` reveal → latch clears, blur hides.

**This is a deterrent, not real prevention.** OS-level screenshot keys are intercepted before the page sees them — the actual screenshot usually completes before the overlay paints. The forensic value is the **attempt log**, not the block.

### Screen share

The SDK requests the candidate's screen via the browser's native picker,
enforces entire-screen by default, records via `MediaRecorder` into 10s VP9
WebM chunks at 500 kbps, and uploads each chunk to the server as it's produced.
The dashboard's `/sessions/[id]` page stitches the chunks into one playable
video on demand (via ffmpeg on the server — see the root README for
installation). Staged integrations can set `deferRecording: true` to acquire
and validate the live stream first, then call `client.startScreenShareRecording()`
when the test actually starts.

```ts
observers: {
  screenShare: true,                 // defaults: entire-screen enforced, 10s VP9 WebM, 500kbps, auto-upload
  // or:
  screenShare: {
    enforceEntireScreen: true,
    timesliceMs: 10_000,
    videoBitrate: 500_000,
    deferRecording: false,
    onChunkReady: (n, blob) => {
      // Optional. Customer-side hook for visibility/logging. The SDK
      // still uploads the chunk via the internal ChunkUploader either way.
      console.log(`chunk #${n}: ${blob.size} bytes ${blob.type}`);
    },
  },
}
```

#### Flow

At construction time (inside the customer's click handler), the SDK calls `getDisplayMedia()` with a monitor/full-screen preference. The browser shows its native picker. Browser options are only a hint, so the SDK still validates the returned track before recording.

- **Success** → `screen-share.started` event with `{ surface: "monitor" }`. This means the live stream is active. If `deferRecording` is false, MediaRecorder starts immediately and emits `screen-share.recording.started`. If `deferRecording` is true, call `client.startScreenShareRecording()` later.
- **Recording started** → `screen-share.recording.started`. MediaRecorder begins producing VP9 WebM chunks every `timesliceMs`. Each chunk fires `onChunkReady(chunkNumber, blob)`.
- **Wrong surface** (window/tab when `enforceEntireScreen` is on) → stream stopped immediately, `screen-share.wrong-surface` emitted with the offending surface, `onScreenShareError("wrong-surface")` called. Worker is torn down.
- **Cancelled** (Cancel button, Escape, programmatic block) → `screen-share.declined` emitted, `onScreenShareError("permission-denied")` called. Worker is torn down.
- **Candidate clicks "Stop sharing"** in the browser's toolbar mid-session → `screen-share.stopped` with `{ reason: "track-ended" }`. The session continues. `ProctoredAssessment` locks the assessment and calls `client.restartScreenShare()` from the recovery button; custom integrations should do the same from a fresh user click.
- **`endSignal.abort()`** → `screen-share.stopped` with `{ reason: "manual" }`, tracks released.
- **`pagehide`** → queued events flush only. The SDK does not emit a terminal or stopped event because refresh and close are indistinguishable.

#### Customer UX requirements

Because the picker always has a Cancel button (browser-mandated for privacy), the customer **must** present a pre-prompt explaining what to pick. The SDK paints no UI for this — see [Integration recipes](#integration-recipes) below for the pattern.

If you are not using `ProctoredAssessment`, listen for emitted session events and lock your assessment when screen sharing stops:

```ts
let locked = false;
const stream = await requestScreenShare({ enforceEntireScreen: true });

const client = new ProctoringClient({
  // ...
  observers: {
    screenShare: {
      stream,
      stopExternalStreamOnStop: true,
    },
  },
  onSessionEvent: (event) => {
    if (event.kind === "screen-share.stopped" && event.payload?.reason === "track-ended") {
      locked = true;
    }
  },
});

async function shareAgain() {
  // Must be called from the candidate's button click.
  locked = !(await client.restartScreenShare());
}
```

#### Codec selection

The SDK probes `MediaRecorder.isTypeSupported` in order: `video/webm;codecs=vp9`, `video/webm;codecs=vp8`, `video/webm`. Falls back to the browser default if none match.

#### Safari

Safari 16.4+ can record through MediaRecorder, typically producing MP4 rather than the WebM used by Chromium. The current post-session Python face analyser assumes WebM and skips Safari MP4 recordings cleanly; preflight JPEG face detection is unaffected. Treat Safari recording analysis as a known limitation until the analyser accepts the container.

### Webcam

Acquires the candidate's webcam and offers two evidence strategies. Customer assessment policies select exactly one: snapshots or continuous recording. The lower-level observer shape still accepts both for legacy/custom wrapper compatibility, but new integrations should not combine them because that duplicates evidence, bandwidth, storage, and review semantics.

- **`photos`** — periodic JPEG stills with brightness sampling. Light on storage (~16 MB/hour). Catches "someone else sat down" cases.
- **`recording`** — continuous VP9 WebM in 10s chunks, stitched into one playable video on the dashboard. Heavier on storage (~225 MB/hour at 500 kbps) but gives the proctor full behavioural review.

Either capture strategy reads from one `MediaStream` — one `getUserMedia` call and one camera indicator.

```ts
observers: {
  webcam: true,                      // defaults: browser default camera, neither capture mode on
  // or:
  webcam: {
    photos: true,                    // 20-60s random stills + brightness check
  },
  // or:
  webcam: {
    recording: true,                 // continuous 10s VP9 WebM chunks, 500 kbps
  },
}
```

The dashboard's `/sessions/[id]` page renders both — a thumbnail grid for photos and a single inline `<video>` player for the stitched recording.

#### Two acquisition modes

The webcam observer can either acquire the camera itself or share a `MediaStream` the customer's app has already acquired (e.g. for a video interview question that runs alongside proctoring).

**Mode A — SDK acquires** (default). The SDK calls `getUserMedia` when the client starts. Browser asks permission once. The SDK owns the stream and releases it on `endSignal.abort()`.

```ts
observers: {
  webcam: {
    deviceId: "abc123",   // or omit for browser default
    photos: true,
  },
}
```

**Mode B — Customer provides the stream**. Customer calls `getUserMedia` themselves, uses the stream for their own purposes (video preview, recording, etc.), and hands the same stream to the SDK. The SDK becomes a passive reader — it draws photo frames from the stream but does **not** stop the tracks on session end. The customer owns lifecycle.

```ts
const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true });
customerVideoElement.srcObject = cameraStream; // your own preview / recording

new ProctoringClient({
  observers: {
    webcam: {
      stream: cameraStream, // ← reuse the same stream
      photos: true,
    },
  },
});
```

Mode B is the right choice when the candidate's exam involves any feature that also needs the camera (interview questions, video answers, live customer-support chat). Benefits:

- One permission prompt instead of two.
- One camera light on the OS.
- No `NotReadableError` on Linux/iOS where double-acquire can fail.
- The customer's stream lifecycle is authoritative — the SDK won't kill it.

If both `deviceId` and `stream` are passed, `stream` wins and `deviceId` is ignored.

#### Flow

At construction time (inside the customer's click handler), the SDK either calls `getUserMedia` (Mode A) or attaches to the customer-provided stream (Mode B).

- **Success** → `webcam.started` event with `{ deviceLabel, deviceId }`. If photos are configured, a hidden `<video>` is mounted off-screen as the canvas source and the random-interval photo loop starts.
- **Declined / unavailable / in-use / device-not-found** (Mode A only) → `webcam.declined` / `webcam.unavailable` emitted, `onWebcamError(kind)` callback fires, worker is torn down.
- **Customer-provided stream is invalid** (no video track, already stopped) → `webcam.unavailable` emitted with the specific reason, `onWebcamError("unavailable")` fires.
- **Candidate stops sharing via the browser** (URL bar camera button) → `webcam.stopped` with `{ reason: "track-ended" }`. The session continues.
- **`endSignal.abort()`** → `webcam.stopped` with `{ reason: "manual" }`. In Mode A, tracks are released (camera light turns off). In Mode B, tracks stay alive — the customer's stream survives.
- **`pagehide`** → queued events flush only. The SDK does not emit a terminal or stopped event because refresh and close are indistinguishable.

#### Photo capture

When `photos` is enabled the observer schedules captures at uniform random intervals in `[minIntervalSeconds, maxIntervalSeconds]` (defaults 20s / 60s). On each tick:

1. Draw the live video frame to a canvas.
2. Sample 200 random pixels and compute average luminance (Rec. 601 weighting). If under `brightnessThreshold` (default 25/255), the frame is flagged `isBright: false`.
3. Export the canvas as JPEG at `jpegQuality` (default 0.7).
4. Emit `webcam.photo.captured` with `{ photoNumber, byteSize, brightness, isBright }`.
5. Call customer's `onPhotoReady` if provided.
6. Hand the blob to the internal photo uploader → POST to `/webcam/sessions/:id/photos/:n` with `x-webcam-brightness` and `x-webcam-is-bright` headers.

The randomized interval is intentional — a fixed cadence lets candidates time misbehavior between captures.

#### Device picker — `enumerateMediaDevices()`

Customers who want to build their own "pick your camera" UI should use the SDK's helper. It handles the browser quirk where device labels are empty strings until permission has been granted:

```ts
import { enumerateMediaDevices } from "@a4anthony/proctorkit-sdk";

const devices = await enumerateMediaDevices();
// → { cameras: [{ deviceId, label }], mics: [{ deviceId, label }] }
```

The helper probes with a one-shot `getUserMedia` call to unlock labels, stops the probe stream, then enumerates. Customers render the returned arrays in their own `<select>` and pass the chosen `deviceId` to `observers.webcam.deviceId` (Mode A) — or just use that `deviceId` in their own `getUserMedia` call and pass the resulting stream as `observers.webcam.stream` (Mode B).

### Audio playback (listening prompts)

Listening questions can play URL-backed audio through the SDK so the session
timeline records when the prompt was played, paused, stopped, ended, or failed.
Query strings and hash fragments are removed from emitted payloads, so signed
CDN URLs do not leak into event logs.

```ts
const promptAudio = client.playAudioFile({
  url: "https://cdn.example.com/prompts/q5.mp3?signature=...",
  label: "Question 5 listening audio",
  onEnded: () => {
    markQuestionPromptListened("q5");
  },
  onError: (error) => {
    showPlaybackError(error.message);
  },
});

// Later, if the candidate leaves the question:
promptAudio.stop();
```

If your app already renders an `<audio>` element with native controls, pass it
to the helper and disable autoplay:

```ts
client.playAudioFile({
  url: question.audioUrl,
  label: question.audioLabel,
  element: audioElement,
  autoplay: false,
});
```

If you collect a speaker/headphone choice during preflight, pass that output
device id once on the client. The SDK applies it to listening prompts with
`HTMLMediaElement.setSinkId()` where the browser supports output routing:

```ts
const client = new ProctoringClient({
  sessionId,
  ingestUrl,
  appId,
  audioOutputDeviceId: selectedSpeakerId,
});
```

You can override the output for one prompt with `sinkId` on `playAudioFile()`.
If the candidate changes speakers while a prompt is already playing, call
`promptAudio.setSinkId(nextSpeakerId)` on the returned handle to reroute the
same audio element without restarting playback.
While a selected output is active, the helper also listens for browser
`devicechange` events. If the selected Bluetooth speaker/headphones disappear,
the SDK emits `audio-playback.sink-disconnected` and attempts to route the same
audio element back to the system default output.
When `setSinkId()` is unavailable or the saved device is gone, playback falls
back to the browser's default output.

Audio output routing is recorded on the session timeline:

- `audio-playback.sink-applied` when the selected output device was applied.
- `audio-playback.sink-unsupported` when the browser cannot route audio output.
- `audio-playback.sink-failed` when the saved output device could not be used.
- `audio-playback.sink-disconnected` when the selected output device disappears.

### Video clips (ad-hoc recordings inside a session)

Some exams have a video-answer question — the candidate records a clip in response to a prompt, separate from the continuous capture (screen share, photos) the proctoring session is already doing. The SDK supports this with two methods on `ProctoringClient`:

| Method                      | Customer code                                                                         | Best for                                           |
| --------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------- |
| **`recordVideoClip()`**     | One call to start, `.stop()` on the returned handle                                   | Simple "record now" buttons. No preview or retake. |
| **`uploadVideoClip(blob)`** | Customer records with their own `MediaRecorder`, hands the finished `Blob` to the SDK | Custom UX — preview, retake, accept-before-submit  |

Both methods upload to the same server endpoint and produce the same `VideoClip` rows in the dashboard. Both emit the same lifecycle events on the timeline. The only difference is who runs the recorder.

#### Path A — SDK records (simplest)

```ts
// During a proctored session
const handle = await client.recordVideoClip({
  onUploaded: (n, byteSize, durationMs) => {
    console.log(`clip ${n}: ${byteSize} bytes, ${durationMs}ms`);
  },
});

// …candidate records, customer's button toggles to "Stop"…

await handle.stop();
// Clip is uploaded. Dashboard shows it under "Video clips".
```

The SDK acquires the camera if it isn't already running, records, uploads, releases. Two-minute safety cap by default. Customer's only API surface is `recordVideoClip()` and `handle.stop()`.

#### Path B — Customer records, SDK ships

For a polished UX with preview / retake / accept controls, the customer runs their own `MediaRecorder` and hands us the final `Blob`. Minimal working example (vanilla TypeScript, ~80 lines):

```ts
// VideoQuestion.ts — runs inside an active proctored session
import type { ProctoringClient } from "@a4anthony/proctorkit-sdk";

export async function videoQuestion(
  client: ProctoringClient,
  container: HTMLElement,
  maxSeconds = 90,
): Promise<void> {
  container.innerHTML = `
    <div style="max-width:480px;margin:1rem auto;padding:1rem;border:1px solid #ccc;border-radius:8px">
      <video id="vq-video" autoplay playsinline muted style="width:100%;aspect-ratio:16/9;background:#000;border-radius:6px"></video>
      <div id="vq-status" style="margin:0.5rem 0;color:#666;font-size:0.9rem">Click "Start" to record (up to ${maxSeconds}s).</div>
      <button id="vq-action" style="padding:0.5rem 1rem;background:#111;color:#fff;border:none;border-radius:6px">Start</button>
    </div>
  `;
  const video = container.querySelector<HTMLVideoElement>("#vq-video")!;
  const status = container.querySelector<HTMLDivElement>("#vq-status")!;
  const btn = container.querySelector<HTMLButtonElement>("#vq-action")!;

  // 1. Acquire camera + mic. The SDK's photo loop (if on) is reading
  //    from its own stream; the browser fans frames to both.
  const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  video.srcObject = stream;

  // 2. Wait for Start
  await new Promise<void>((r) => (btn.onclick = () => r()));

  const chunks: Blob[] = [];
  const recorder = new MediaRecorder(stream);
  recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
  recorder.start();
  const startedAt = Date.now();

  btn.textContent = "Stop";
  btn.style.background = "#dc2626";
  const tick = setInterval(() => {
    const s = Math.floor((Date.now() - startedAt) / 1000);
    status.textContent = `Recording… ${s}s / ${maxSeconds}s`;
  }, 500);

  // 3. Wait for Stop OR the cap
  await new Promise<void>((r) => {
    btn.onclick = () => r();
    setTimeout(r, maxSeconds * 1000);
  });
  clearInterval(tick);

  // 4. Finalize
  await new Promise<void>((r) => {
    recorder.onstop = () => r();
    recorder.stop();
  });
  stream.getTracks().forEach((t) => t.stop());

  const blob = new Blob(chunks, { type: recorder.mimeType || "video/webm" });
  const durationMs = Date.now() - startedAt;

  // 5. Ship the blob through the SDK — same dashboard surface as
  //    recordVideoClip(). Do NOT call client.stop() — the proctored
  //    session continues for the rest of the exam.
  status.textContent = "Submitting…";
  btn.disabled = true;
  await client.uploadVideoClip(blob, { durationMs });
  status.textContent = "Submitted.";
  btn.textContent = "Done";
}
```

Usage inside an exam that already has a proctored session running:

```ts
const endController = new AbortController();
const client = new ProctoringClient({
  sessionId,
  ingestUrl: "/api/proctoring/ingest",
  appId: import.meta.env.VITE_PROCTORING_APP_ID,
  workerUrl: new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url),
  candidate: { id: user.id, email: user.email },
  observers: {
    clipboard: { block: true },
    keyboard: true,
    screenshot: { blurOnSuspicion: true },
    screenShare: true,
    webcam: { photos: true },
  },
  endSignal: endController.signal,
});

// …later, when the candidate reaches a video-answer question:
await videoQuestion(client, document.getElementById("question-5")!);
// Question done. The proctored session keeps running for the rest of
// the exam (photos, screen share, all observers).

// …at the very end of the exam:
endController.abort();
```

### Stream sharing — three modes

When webcam photos are enabled AND the customer records video clips, there are three ways the camera can be shared between the SDK and the customer's code.

| Mode                         | Who calls `getUserMedia` | Prompts      | Camera light          | Best when                                    |
| ---------------------------- | ------------------------ | ------------ | --------------------- | -------------------------------------------- |
| **1 — Customer-owned**       | Customer                 | 1 (customer) | On for the whole exam | Customer has a live preview UI               |
| **2 — SDK-owned, shared**    | SDK                      | 1 (SDK)      | On for the whole exam | Customer wants photos + clips but no preview |
| **3 — Per-clip acquisition** | SDK, per clip            | 1 per clip   | On only during clips  | Single clip, no photos                       |

**Mode 1** — customer acquires the stream once, hands the same `MediaStream` to both the SDK and to their own UI:

```ts
const sharedStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
previewElement.srcObject = sharedStream; // customer's preview
new ProctoringClient({
  observers: { webcam: { stream: sharedStream, photos: true } },
  /* … */
});
// Later
await client.recordVideoClip({ stream: sharedStream });
```

**Mode 2** — SDK acquires the stream, customer reuses it via `recordVideoClip()` with no arguments:

```ts
new ProctoringClient({
  observers: { webcam: { photos: true } }, // SDK calls getUserMedia
  /* … */
});
// Later — SDK reuses its own webcam stream automatically
await client.recordVideoClip();
```

**Mode 3** — no shared stream available; the recorder acquires fresh on each call:

```ts
new ProctoringClient({
  observers: { screenShare: true }, // no webcam observer
  /* … */
});
await client.recordVideoClip();
// ↑ triggers a getUserMedia call now; permission prompt may appear
```

#### What happens when the customer calls `getUserMedia` a second time

In Mode 1, the customer may legitimately call `getUserMedia` again for an unrelated purpose. Behaviour by browser:

- **Chrome / Firefox / Safari (macOS)** — both calls succeed. The browser opens the camera once at the OS level and fans frames out to every `MediaStreamTrack`. Each `MediaStream` instance is independent: stopping one doesn't affect the other.
- **Linux with some V4L2 drivers / older iOS Safari** — second call may fail with `NotReadableError` ("device in use"). Rare but real.
- **Constraints conflict** — if the second call demands a resolution incompatible with the active camera config, you'll get `OverconstrainedError` or silently inherit the first call's resolution.

#### The "don't kill the shared stream" rule

In Mode 1, the customer owns the stream's lifecycle. **Do not call `.stop()` on the shared stream until end-of-exam.** Specifically:

- If you acquire a _second_ stream for a brief throwaway purpose, stop only that second stream.
- Treat the shared stream as immutable — only release it alongside `endController.abort()` at the very end.
- Killing the shared stream early surfaces as `webcam.stopped { reason: "track-ended" }` in the timeline; the SDK degrades gracefully (subsequent `recordVideoClip()` falls back to Mode 3 fresh acquisition).

If you need a second stream that the SDK can also see, **acquire a single stream and share it** — don't try to merge two independent streams.

---

## Event kinds reference

All events carry `{ id, sessionId, kind, timestamp, payload? }`. Payload schemas:

| Kind                                        | Payload                                                                                                |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `session.started`                           | —                                                                                                      |
| `session.end_requested`                     | `{ reason, drainDeadlineMs }`                                                                          |
| `session.ended`                             | `{ reason, drainStatus?, drainDeadlineMs?, drainTimeoutStreams? }`                                     |
| `session.heartbeat`                         | `{ intervalMs, visibilityState? }`                                                                     |
| `focus.lost`, `focus.gained`                | —                                                                                                      |
| `tab.hidden`, `tab.visible`                 | —                                                                                                      |
| `fullscreen.entered`, `fullscreen.exited`   | —                                                                                                      |
| `clipboard.copy`, `.paste`, `.cut`          | `{ targetTag, byteLength, blocked?, content?, truncated? }`                                            |
| `contextmenu.opened`                        | `{ blocked?: true }`                                                                                   |
| `keyboard.blocked`                          | `{ shortcut, key, code, modifiers, blocked: true }`                                                    |
| `screenshot.attempted`                      | `{ trigger: "keyboard" \| "visibility" \| "blur", shortcut?: string }`                                 |
| `screen-share.started`                      | `{ surface: "monitor" \| "window" \| "browser" \| string }`                                            |
| `screen-share.stopped`                      | `{ reason: "manual" \| "track-ended" }`                                                                |
| `screen-share.declined`                     | `{ reason: string }` (browser error message)                                                           |
| `screen-share.wrong-surface`                | `{ surface: string }`                                                                                  |
| `screen-share.recording.started`            | `{ timesliceMs, videoBitrate }`                                                                        |
| `screen-share.recording.stopped`            | `{ reason }`                                                                                           |
| `screen-share.recording.unavailable`        | `{ reason }`                                                                                           |
| `screen-share.chunk-uploaded`               | `{ chunkNumber, byteSize }`                                                                            |
| `screen-share.chunk-dropped`                | `{ chunkNumber, reason: "max-retries" \| "rejected" \| "overflow" \| "aborted" }`                      |
| `webcam.started`                            | `{ deviceLabel, deviceId }`                                                                            |
| `webcam.stopped`                            | `{ reason: "manual" \| "track-ended" }`                                                                |
| `webcam.declined`                           | `{ reason: string }`                                                                                   |
| `webcam.unavailable`                        | `{ reason: string }`                                                                                   |
| `webcam.photo.captured`                     | `{ photoNumber, byteSize, brightness, isBright }` (and `uploaded: true` once the JPEG hits the server) |
| `webcam.photo.dropped`                      | `{ photoNumber, reason }`                                                                              |
| `webcam.recording.started`                  | `{ mimeType, timesliceMs, videoBitrate }`                                                              |
| `webcam.recording.stopped`                  | `{ reason }`                                                                                           |
| `webcam.recording.chunk-uploaded`           | `{ chunkNumber, byteSize }`                                                                            |
| `webcam.recording.chunk-dropped`            | `{ chunkNumber, reason }`                                                                              |
| `video-clip.started`                        | `{ clipNumber, source?: "customer-blob" }`                                                             |
| `video-clip.stopped`                        | `{ clipNumber }`                                                                                       |
| `video-clip.uploaded`                       | `{ clipNumber, byteSize, durationMs }`                                                                 |
| `video-clip.dropped`                        | `{ clipNumber, reason }`                                                                               |
| `network.online`, `network.offline`         | —                                                                                                      |
| `media.frame.captured`, `media.audio.chunk` | reserved for future audio/video work                                                                   |

Custom kinds emitted via `client.emit()` use the same envelope; the server accepts any string in `kind` so long as the rest of the payload validates.

---

## Integration recipes

### Minimal — React

```tsx
import { useRef } from "react";
import { ProctoringClient } from "@a4anthony/proctorkit-sdk";

export function ExamShell({ user, sessionId }) {
  const endRef = useRef(new AbortController());

  function handleStart() {
    new ProctoringClient({
      sessionId,
      ingestUrl: "/api/proctoring/ingest",
      appId: import.meta.env.VITE_PROCTORING_APP_ID,
      workerUrl: new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url),
      candidate: { id: user.id, name: user.name, email: user.email },
      observers: {
        clipboard: { block: true },
        keyboard: true,
        screenshot: { blurOnSuspicion: true },
        screenShare: true,
      },
      endSignal: endRef.current.signal,
    });
  }

  function handleFinish() {
    endRef.current.abort();
  }

  return (
    <>
      <button onClick={handleStart}>Start session</button>
      <button onClick={handleFinish}>Finish exam</button>
    </>
  );
}
```

That's the whole integration when screen sharing is off. For screen sharing,
use the explicit `requestScreenShare()` grant-and-inject pattern documented
above so permission failures are caught before constructing the client.

### With screen-share error UI

The default behaviour (screen-share fails silently with a console warning) is rarely what a real customer wants. Wire `onScreenShareError` to your banner state:

```tsx
const [shareError, setShareError] = useState(null);

function handleStart() {
  new ProctoringClient({
    /* …same as above… */
    onScreenShareError: (kind) => {
      setShareError(
        kind === "wrong-surface"
          ? "You shared a window or tab. Pick Entire Screen when prompted."
          : kind === "activation-required"
            ? "Start screen sharing from the Start session button."
            : kind === "unsupported"
              ? "Screen sharing is not available in this browser."
              : "Screen sharing was not granted. Start it again to continue.",
      );
    },
  });
}
```

### Recommended pre-prompt pattern

Customers should explain what's about to happen **before** the picker opens. The native browser dialog can't be customised — the explainer has to come from you.

```tsx
{
  !running && observersIncludeScreenShare && (
    <aside className="rounded-md bg-indigo-50 p-4">
      <strong>Screen share required.</strong> When you click Start session, your browser will ask
      which screen to share.
      <ul>
        <li>
          Pick the <strong>Entire Screen</strong> tab (not Window or Tab).
        </li>
        <li>Select the monitor showing this page, then click Share.</li>
        <li>If you cancel or pick the wrong option the session won't start.</li>
      </ul>
    </aside>
  );
}
```

### Reacting to upload health

```ts
new ProctoringClient({
  /* … */
  onEvent: (msg) => {
    switch (msg.type) {
      case "queued":
        // diagnostics only
        break;
      case "uploaded":
        // events delivered
        break;
      case "upload-failed":
        // batch failed an attempt; will retry
        if (msg.attempt >= 3) showNetworkWarning();
        break;
      case "dropped":
        // queue overflow — usually means the server has been down long enough
        // for the local buffer to fill up. Tell the candidate.
        showOfflineWarning();
        break;
      case "init-failed":
        showFatalError(msg.error);
        break;
    }
  },
});
```

### Vanilla JS, no observers beyond defaults

```ts
import { ProctoringClient } from "@a4anthony/proctorkit-sdk";

document.getElementById("startBtn").addEventListener("click", () => {
  new ProctoringClient({
    sessionId: crypto.randomUUID(),
    ingestUrl: "/api/proctoring/ingest",
    appId: "pk_live_abc123",
    workerUrl: new URL("@a4anthony/proctorkit-sdk/worker", import.meta.url),
    candidate: { id: "user_42", name: "Alice", email: "alice@example.com" },
  });
});
```

---

## Privacy and legal

A few capture features have material legal/ethical consequences. Defaults are conservative on purpose.

### Clipboard content capture (`captureContent: true`)

When this is on, the SDK records the actual text the candidate copies/cuts/pastes. Candidate clipboards routinely contain unrelated personal data — passwords from a password manager, OTPs, addresses, private messages. **Capturing it without consent is unlawful in many jurisdictions and unethical everywhere.**

Required pattern:

1. Disclose to the candidate, before the test, that clipboard content will be recorded.
2. Get explicit consent (a checkbox/modal that they must affirm).
3. Only then construct the client with `captureContent: true`.

The SDK truncates captured content to `maxBytes` (default 2 KB) so a paste of a 5 MB image base64 doesn't blow up the queue.

### Screen recording

Same standard applies. The candidate must understand that their entire screen is being recorded. Use the pre-prompt pattern above as the minimum baseline.

### Storage and retention (server-side concerns)

That's a server-side question, but flag for your policy review:

- Recordings should have a defined retention window (typically the test review period — 30/60/90 days).
- Captured clipboard content should be encrypted at rest.
- Customers should be able to request deletion under GDPR/CCPA-style data-subject requests.

Phase 2 server work will need to address this concretely.

---

## Testing

The SDK has two layers of test coverage:

- **Unit tests** with vitest + happy-dom + fake-indexeddb. Cover EventQueue concurrency, Uploader retry/backoff/abort, every DOM observer, ScreenshotObserver overlay state machine, ScreenShareObserver picker/recorder lifecycle.
- **Playwright e2e** at the workspace root. Drives a real Chromium browser against the bundled Worker and a real server. Validates the full POST→DB roundtrip.

Run from the SDK package:

```sh
pnpm test            # vitest run
pnpm build           # types + lib + worker bundles
```

Run e2e from the repo root:

```sh
pnpm test:e2e
```

Current count: 72 SDK unit tests, 5 server integration tests.

---

## Limitations and known gaps

These are conscious choices, not bugs.

| Limitation                                                                                           | Why                                                                            | Mitigation                                                                                                                                                                               |
| ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OS-level screenshot shortcuts (Cmd+Shift+3/4/5) are intercepted by the OS before the page sees them. | Web pages cannot prevent OS shortcuts.                                         | The screenshot observer **detects** the attempt via the modifier-only keydown and window blur, and the blur overlay deters delayed-capture tools. The forensic value is the attempt log. |
| Cmd+T, Cmd+W (new/close tab) cannot be blocked.                                                      | Browser-owned.                                                                 | Customer should run the exam in fullscreen with the fullscreen-exit observer wired to a callback. The dashboard sees `fullscreen.exited` immediately.                                    |
| Two open tabs of the same `sessionId` would race.                                                    | The IndexedDB store is keyed by sessionId; concurrent writes would interleave. | Customer must ensure one tab per session — usually by checking server-side that a session isn't already running.                                                                         |
| Screen-share chunks are uploaded to the server filesystem (no blob store yet).                       | Cost ceiling — R2/Tigris add an account dependency.                            | Fine for MVP; future phase swaps storage backend without touching the SDK or dashboard.                                                                                                  |
| Webcam / live stream / random photos.                                                                | Not implemented yet.                                                           | Planned after screen share Phase 3.                                                                                                                                                      |

---

## File map

```
packages/sdk/
├── src/
│   ├── index.ts                          # ProctoringClient + exports
│   ├── queue/event-queue.ts              # IndexedDB-backed FIFO
│   ├── uploader/uploader.ts              # Batched POST + retry
│   ├── worker/
│   │   ├── worker-core.ts                # Worker state machine
│   │   └── worker-entry.ts               # Worker bootstrap
│   ├── observers/
│   │   ├── dom-observers.ts              # focus, visibility, clipboard, etc
│   │   ├── screenshot-observer.ts        # screenshot detection + blur overlay
│   │   └── screen-share-observer.ts      # getDisplayMedia + MediaRecorder
│   └── internal/
│       └── ids.ts                        # uuid helper
├── e2e/
│   └── host/                             # demo page that drives a real worker
├── dist/                                 # build output
└── README.md                             # this file
```

---

## Versioning and stability

Pre-1.0. Public API can break between minor versions until we cut 1.0. Once tagged, semver applies: breaking changes only on major, additive changes on minor, fixes on patch. Changesets in the repo root track changes per-PR.
