---
phase: 01-stabilize-video-pipeline
plan: 05
subsystem: service-worker-coordinator
tags: [chrome-extension, mv3, service-worker, long-lived-port, offscreen-document, t-1-04, security-mitigation]

# Dependency graph
requires:
  - phase: 01-stabilize-video-pipeline
    provides: "Plan 03 ring-buffer ownership moved to offscreen; Plan 03 inline SW cleanup of VIDEO_CHUNK / VIDEO_CHUNK_SAVED / openIndexedDB / loadChunkFromIndexedDB; Plan 04 offscreen-side port keepalive + OFFSCREEN_READY handshake"
provides:
  - "Shrunk src/background/index.ts (coordinator only): offscreen lifecycle, port host, export-time buffer fetch — zero local buffer state"
  - "SW-side chrome.runtime.onConnect listener bound to 'video-keepalive' port (Plan 04 counterparty) with T-1-04 sender-id check"
  - "Async getVideoBufferFromOffscreen(): REQUEST_BUFFER → BUFFER round-trip with 2s timeout; powers GET_VIDEO_BUFFER and SAVE_ARCHIVE handlers"
  - "OFFSCREEN_READY handshake handler resolving a module-level Promise; startVideoCapture awaits it before sending START_RECORDING (audit P1 #12 fix)"
  - "Idempotent indexedDB.deleteDatabase('VideoRecorderDB') in onInstalled (T-1-NEW-05-02; RESEARCH.md Runtime State Inventory)"
  - "chrome.offscreen.hasDocument() check on SW init for robust offscreenCreated state across SW respawns (audit P1 #8)"
  - "T-1-NEW-05-01 onMessage sender.id guard at handler top"
affects: [01-06-vite-config, 01-07-ffprobe-gate]

# Tech tracking
tech-stack:
  added: []
  patterns:
    - "SW-as-pure-coordinator: zero local state for chunks; offscreen is the single source of truth (D-16)"
    - "Promise-based handshake (offscreenReady) so popup's transient user-activation isn't lost to bootstrap race"
    - "Port-based async RPC via REQUEST_BUFFER → BUFFER with per-call onMessage listener + 2 s timeout fallback"
    - "Defense-in-depth on both onConnect (port name + port.sender?.id) and onMessage (sender.id) — T-1-04 mitigations on both surfaces"
    - "instanceof Error pattern in catches instead of '(error as any).message' — eliminates audit P1 #13 instances in this file"

key-files:
  created: []
  modified:
    - "src/background/index.ts (388 → 491 lines; ~ +130 / -100 net; structural shrink — buffer + alarms + IDB + tabCapture paths went away; port host + handshake + IDB cleanup + hasDocument check + sender guards came in)"

key-decisions:
  - "checkPermissions / requestPermissions deleted entirely (Rule 1 deviation): they referenced 'tabCapture' permission which was swapped to 'desktopCapture' in manifest (D-A6). Under getDisplayMedia (D-01) no runtime-permission check is meaningful — the browser prompts via picker on user gesture. REQUEST_PERMISSIONS now just calls startVideoCapture() and returns granted:true. Plan only mandated removing the getMediaStreamId call; the broken permissions code was logically downstream."
  - "Synchronous getVideoBuffer() kept as an empty-array stub in Task 1 (to keep tsc clean while videoBuffer module state was deleted), then replaced with async getVideoBufferFromOffscreen() in Task 2. The plan acknowledges this two-step in Task 2 step (4)."
  - "Added chrome.offscreen.hasDocument() check inside initialize() (Rule 2 robustness — orchestrator-flagged audit P1 #8). The check is guarded with `typeof chrome.offscreen?.hasDocument === 'function'` so it stays safe across versions and partial stubs. Plan did not explicitly require this, but the orchestrator's instructions did."
  - "Fixed two `as any` casts in the existing code that the plan didn't strictly require but CLAUDE.md mandates: (a) the catch block in ensureOffscreen now uses `error instanceof Error` instead of `(error as any).message`; (b) chrome.tabs.sendMessage cast moved from `as any` to an explicit `{ events?, userEvents? } | undefined` type. Both are pre-existing audit P1 #13 instances; cleaning them now closes a deviation surface for Phase 5."
  - "Did NOT mark REQ-video-ring-buffer complete: Plan 07 owns the ffprobe gate that proves end-to-end requirement satisfaction."

patterns-established:
  - "Port lifecycle on SW side: onConnect → port-name filter → sender.id filter → store in videoPort → register onDisconnect (null out reference) → done. Each request installs a one-shot onMessage listener via getVideoBufferFromOffscreen and removes it on completion or timeout."
  - "Handshake pattern: module-level Promise + resolve closure captured at module load. OFFSCREEN_READY case calls resolve() and nulls out the closure (one-shot semantics). startVideoCapture awaits it after ensureOffscreen()."
  - "T-1-04 enforcement: when SW is the trusting party of a port opened by offscreen, validate `port.sender?.id === chrome.runtime.id`. When SW is the trusting party of a runtime message, validate `sender.id === chrome.runtime.id`. Both checks are now in place."

requirements-completed: []  # REQ-video-ring-buffer still pending Plan 07 ffprobe gate; this plan is structural plumbing only.

# Metrics
duration: 8min
completed: 2026-05-15
---

# Phase 01 Plan 05: SW Shrink + Port Host Summary

**Shrunk `src/background/index.ts` to a pure coordinator: deleted legacy buffer / alarms / IndexedDB / tabCapture code paths (Task 1) and wired the SW-side counterparty of the long-lived `'video-keepalive'` port (Task 2) — with T-1-04 sender-id guards on both onConnect and onMessage, an `OFFSCREEN_READY` handshake, `chrome.offscreen.hasDocument()` re-sync on SW respawn, and an idempotent `indexedDB.deleteDatabase('VideoRecorderDB')` cleanup on install.**

## Performance

- **Duration:** ~8 min
- **Started:** 2026-05-15T15:53:30Z (immediately after Plan 04 completed)
- **Completed:** 2026-05-15T16:03:00Z
- **Tasks:** 2 (DELETE pass + ADD pass)
- **Commits:** 2 (one per task)
- **Files modified:** 1 (`src/background/index.ts`)

## Before / After Line Count

| Snapshot | Lines | Notes |
|----------|-------|-------|
| Pre-Plan 05 (post-Plan 03 inline cleanup) | **436** | Plan 03's executor had already removed VIDEO_CHUNK / VIDEO_CHUNK_SAVED / openIndexedDB / loadChunkFromIndexedDB inline as Rule-3 blocking-fix deps. |
| Post-Task 1 (DELETE pass) | 387 | -49 lines: deletions of videoBuffer, setupKeepalive, chrome.tabCapture.getMediaStreamId, checkPermissions, requestPermissions, USER_MEDIA cast, two `as any` casts. |
| Post-Task 2 (ADD pass) | **491** | +104 lines: onConnect handler, getVideoBufferFromOffscreen, offscreenReady Promise, OFFSCREEN_READY case, sender-id guards, indexedDB.deleteDatabase, hasDocument check, comments documenting each mitigation. |

The plan estimated 380-440 lines post-Task 2. The actual count is 491 because (a) I added the orchestrator-requested `chrome.offscreen.hasDocument()` block (~15 lines including comments) and (b) I expanded the comments around each security mitigation to make T-1-04 / T-1-NEW-05-01 / T-1-NEW-05-02 / audit P1 #8 / audit P1 #12 references explicit for future auditors. The structural shrink (zero buffer state, zero alarms, zero IDB plumbing, zero tabCapture) is intact — the line-count overshoot is documentation, not code.

## Task 1: Deletions

**Commit:** `886376e refactor(01-05): delete legacy SW buffer, alarms, IndexedDB, tabCapture paths`

| Deleted Symbol / Path | Reason |
|-----------------------|--------|
| `let videoBuffer: VideoChunk[] = []` | D-16 — buffer ownership moved to offscreen |
| `function setupKeepalive` + `chrome.alarms.create('keepalive', ...)` + `chrome.alarms.onAlarm.addListener` | D-18 / audit P1 #8 — alarms never reset SW idle timer; port does |
| Call site `setupKeepalive()` inside `initialize` | Paired with the function delete |
| `chrome.tabCapture.getMediaStreamId({...})` block inside `startVideoCapture` (and its `as any` cast) | D-01 — getDisplayMedia now runs inside the offscreen document |
| `async function checkPermissions` | Referenced `'tabCapture'` (removed from manifest by D-A6); under getDisplayMedia no runtime perm check is meaningful |
| `async function requestPermissions` | Same reason. The popup user-gesture flows directly into startVideoCapture now |
| `reasons: ['USER_MEDIA'] as any` in createDocument | Replaced by canonical `[chrome.offscreen.Reason.DISPLAY_MEDIA]` (D-02; @types/chrome 0.0.268 exposes the enum) |
| `(error as any).message?.includes(...)` in createDocument catch | Replaced by `error instanceof Error ? error.message : String(error)` (CLAUDE.md no-`as any` rule) |
| `as any` cast on `chrome.tabs.sendMessage(...)` response | Replaced by an explicit `{ events?: unknown[]; userEvents?: unknown[] } | undefined` type |
| Justification string `'Need to record video from tab for error reporting'` | Replaced with `'Continuous screen recording for operator session diagnostics'` to match the new capture semantics (D-04 — NOT silent) |

**Note (already done by Plan 03):** `addVideoChunkFromBlob`, `cleanupVideoBuffer`, `firstChunkSaved`, `VIDEO_BUFFER_DURATION_MS`, the `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` case branches, `loadChunkFromIndexedDB`, `openIndexedDB`, and the chrome.tabs.onActivated handler were ALREADY removed by Plan 03's Rule-3 inline cleanup (logged in STATE.md decisions). Plan 05 verified these via grep gates and removed only the residual placeholder comments.

## Task 2: Additions

**Commit:** `5cd1519 feat(01-05): wire SW-side port host and port-based buffer fetch`

### onMessage switch — final shape

```typescript
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
  // T-1-NEW-05-01 mitigation: only accept onMessage traffic from this extension
  if (sender.id !== chrome.runtime.id) {
    logger.warn('Rejecting message with mismatched sender:', sender.id);
    return false;
  }
  logger.log('Received message:', message.type, message);

  switch (message.type) {
    case 'REQUEST_PERMISSIONS':  // → startVideoCapture(), respond {granted: true|false}
    case 'GET_VIDEO_BUFFER':     // → getVideoBufferFromOffscreen() → sendResponse(resp)
    case 'SAVE_ARCHIVE':         // → saveArchive() → sendResponse(result)
    case 'OFFSCREEN_READY':      // → offscreenReadyResolve?.(); offscreenReadyResolve = null
    default:                     // → logger.warn('Unknown message type:', ...), return false
  }
});
```

### chrome.offscreen.createDocument — as committed

```typescript
await chrome.offscreen.createDocument({
  url: url,
  reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
  justification: 'Continuous screen recording for operator session diagnostics'
});
```

(Plans 06 / 07 can grep against `chrome.offscreen.Reason.DISPLAY_MEDIA` to confirm D-02 wiring.)

### onConnect — port host

```typescript
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== 'video-keepalive') return;
  if (port.sender?.id !== chrome.runtime.id) {  // T-1-04
    port.disconnect();
    return;
  }
  videoPort = port;
  port.onDisconnect.addListener(() => { videoPort = null; });
});
```

### getVideoBufferFromOffscreen — port-based RPC

```typescript
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
  if (videoPort === null) return { chunks: [] };
  const port = videoPort;
  return new Promise<VideoBufferResponse>((resolve) => {
    const timer = setTimeout(() => {
      port.onMessage.removeListener(handler);
      resolve({ chunks: [] });   // 2 s timeout fallback
    }, BUFFER_FETCH_TIMEOUT_MS);
    const handler = (msg: unknown) => {
      if (typeof msg === 'object' && msg !== null && (msg as { type?: unknown }).type === 'BUFFER') {
        clearTimeout(timer);
        port.onMessage.removeListener(handler);
        const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? [];
        resolve({ chunks });
      }
    };
    port.onMessage.addListener(handler);
    port.postMessage({ type: 'REQUEST_BUFFER' });
  });
}
```

### onInstalled — orphan IDB cleanup

```typescript
chrome.runtime.onInstalled.addListener((details) => {
  // T-1-NEW-05-02 / RESEARCH.md Runtime State Inventory
  try {
    indexedDB.deleteDatabase('VideoRecorderDB');
  } catch (e) {
    logger.warn('IDB cleanup failed:', e);
  }
  initialize();
});
```

### initialize() — hasDocument re-sync (P1 #8)

```typescript
async function initialize() {
  // After SW respawn, offscreenCreated resets to false but the offscreen
  // document may still exist. Ask Chrome the ground truth.
  try {
    if (typeof chrome.offscreen?.hasDocument === 'function') {
      const exists = await chrome.offscreen.hasDocument();
      if (exists) offscreenCreated = true;
    }
  } catch (err) { logger.warn(...); }
}
```

## Verification

### tsc + vitest (both green)

```
$ npx tsc --noEmit
(exit 0, no output)

$ npx vitest run
 Test Files  4 passed (4)
      Tests  9 passed (9)
   Start at  18:03:02
   Duration  319ms
```

All 9 tests passing across 4 files in `tests/offscreen/` — Plan 04's offscreen-side stayed untouched, so port + handshake + ring-buffer + codec-check tests all green as expected.

### Grep gates (all pass)

| Gate | Expected | Actual |
|------|----------|--------|
| `chrome.alarms` in src/background/ | 0 | 0 |
| `VideoRecorderDB` / `openIndexedDB` / `loadChunkFromIndexedDB` in src/ (excluding cleanup call) | 0 outside cleanup | 2 (both the cleanup call + its log message) — only the cleanup path remains |
| `setupKeepalive` / `addVideoChunkFromBlob` / `cleanupVideoBuffer` / `tabCapture` / `getMediaStreamId` in src/background/ | 0 | 0 |
| `chrome.tabs.onActivated` / `chrome.tabs.onUpdated` in src/background/ | 0 | 0 (D-14 / D-15 satisfied) |
| `chrome.runtime.onConnect.addListener` in src/background/index.ts | 1 | 1 |
| `'video-keepalive'` in src/background/index.ts | ≥1 | 1 |
| `port.sender?.id !== chrome.runtime.id` (T-1-04) | 1 | 1 |
| `sender.id !== chrome.runtime.id` (T-1-NEW-05-01) | 1 | 1 |
| `indexedDB.deleteDatabase('VideoRecorderDB')` | 1 | 1 |
| `chrome.offscreen.hasDocument` (audit P1 #8) | ≥1 | 1 (the typeof + call) |
| `function getVideoBufferFromOffscreen` | 1 | 1 |
| `OFFSCREEN_READY` mentions | ≥1 | 3 (Promise comment + case label + log message) |
| `offscreenReady` mentions | ≥2 | 6 (Promise var + resolve closure + await + case label + log + comment) |
| `as any` in src/background/ | 0 | 0 |
| `@ts-ignore` in src/background/ | 0 | 0 |

## Deviations from Plan

### Rule 1 — Bug fixes (auto-applied)

**1. [Rule 1 - Bug] Deleted broken `checkPermissions` / `requestPermissions` flow**
- **Found during:** Task 1.
- **Issue:** `chrome.permissions.contains({ permissions: ['tabCapture'] })` references a permission that was removed from `manifest.json` by D-A6 (replaced with `desktopCapture`). The check would always return `false`, sending `REQUEST_PERMISSIONS` into the never-granted branch, which itself calls `chrome.permissions.request({ permissions: ['tabCapture'] })` — same problem. Recording could not start cleanly.
- **Fix:** Deleted both functions entirely. `REQUEST_PERMISSIONS` now just calls `startVideoCapture()` (which goes through ensureOffscreen → DISPLAY_MEDIA reason → offscreen → getDisplayMedia picker → user gesture).
- **Files modified:** `src/background/index.ts`.
- **Commit:** `886376e`.

**2. [Rule 1 - Bug] Replaced two `(error as any).message` patterns with `error instanceof Error`**
- **Found during:** Task 1 (the `as any` grep gate was at 2 instead of 0 because of a pre-existing instance in the ensureOffscreen catch).
- **Issue:** Audit P1 #13 — `as any` violates CLAUDE.md "no `as any`" rule. The catch block in `ensureOffscreen` accessed `.message` via `(error as any).message`.
- **Fix:** `const msg = error instanceof Error ? error.message : String(error); if (msg.includes(...)) { ... }`.
- **Files modified:** `src/background/index.ts`.
- **Commit:** `886376e`.

**3. [Rule 1 - Bug] Replaced `chrome.tabs.sendMessage(...) as any` with an explicit response type**
- **Found during:** Task 1 (same `as any` grep gate).
- **Issue:** Same audit P1 #13 / CLAUDE.md violation; the response was typed as `any` to access `.events` and `.userEvents`.
- **Fix:** Explicit `{ events?: unknown[]; userEvents?: unknown[] } | undefined` annotation on the response variable; nullish coalescing for the two extracts.
- **Files modified:** `src/background/index.ts`.
- **Commit:** `886376e`.

### Rule 2 — Missing critical functionality (auto-applied)

**4. [Rule 2 - Robustness] Added `chrome.offscreen.hasDocument()` check inside `initialize()`**
- **Found during:** Task 2 (orchestrator-flagged audit P1 #8).
- **Issue:** Across SW respawns, the in-memory `offscreenCreated` flag resets to `false`, but the offscreen document may still be alive (it survives SW idle unload because it holds the DISPLAY_MEDIA-reason capture). The next `ensureOffscreen()` would then call `createDocument` over an existing one. The catch block handles "already exists" so it's not strictly broken — but the hasDocument check makes it idempotent and is the canonical Chrome MV3 pattern (RESEARCH.md A7).
- **Fix:** `initialize()` is now `async` and calls `await chrome.offscreen.hasDocument()` to set `offscreenCreated = true` if a document is already there. Guarded with `typeof chrome.offscreen?.hasDocument === 'function'` so it stays safe across @types/chrome versions and partial stubs.
- **Files modified:** `src/background/index.ts`.
- **Commit:** `5cd1519`.

### Plan / orchestrator reconciliation note

The plan's `must_haves` and Task 1 instructions referenced symbols (`addVideoChunkFromBlob`, `cleanupVideoBuffer`, IDB helpers, VIDEO_CHUNK case, etc.) that Plan 03's executor had already inline-deleted as a Rule-3 blocking-fix dependency (documented in STATE.md). Plan 05 verified those via grep gates and only had to handle the residual `videoBuffer` array, the keepalive function, the tabCapture call site, and the comments. This matches the orchestrator's pre-flight note in the prompt.

## TDD Gate Compliance

This plan was `type: execute` (not `type: tdd`), so the RED/GREEN/REFACTOR gate sequence does not apply. The test suite remained at 9/9 throughout — Plan 04's port + handshake tests stayed green because Plan 05 only touches the SW side, and the offscreen-side port contract is unchanged.

## Authentication Gates

None — this plan is pure refactor + integration plumbing.

## Known Stubs

None. All paths in the modified file have a real implementation. `getVideoBufferFromOffscreen` returns `{ chunks: [] }` when `videoPort === null`, which is an intentional fallback (offscreen not yet connected, e.g. during SW cold start before the offscreen has finished bootstrapping). This is the documented contract per `<interfaces>` step 4 of the plan, not a stub.

## Self-Check

Verified after writing this summary:
- ✓ `886376e` exists in git log (Task 1 commit).
- ✓ `5cd1519` exists in git log (Task 2 commit).
- ✓ `src/background/index.ts` exists (491 lines).
- ✓ `npx tsc --noEmit` exits 0.
- ✓ `npx vitest run` reports 9/9 PASS across 4 test files.
- ✓ All Task 1 deletion grep gates return 0.
- ✓ All Task 2 addition grep gates return their expected counts.
- ✓ `grep -c "as any"` and `grep -c "@ts-ignore"` in `src/background/` both return 0.

## Self-Check: PASSED
