From 237feb024a2d5035568fcdc4d4939c57a4a2b7ae Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Tue, 9 Jun 2026 20:11:00 -0400 Subject: [PATCH] =?UTF-8?q?Refactor:=20typed=20message=20router=20via=20we?= =?UTF-8?q?bext-messenger=20+=20zod=20validation=20at=20the=20page?= =?UTF-8?q?=E2=86=92worker=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled 190-line `chrome.runtime.onMessage` switch in background.ts (12 message types, ~7 blind `as unknown as` casts, a per-handler guard each) with a typed `webext-messenger` router and uniform zod validation of every content→worker payload. Addresses both halves of the review note: - Untyped routing → every cross-context call is now a named method on a shared `MessengerMethods` contract (`lib/messenger.ts`). A renamed or mistyped message is a compile error, not a silent runtime no-op. The blind casts are gone. - ~50% payload validation → `lib/message-schemas.ts` decodes every content-script payload (detection host/url/difficulty, rule counts, debug-trace entries, page-world inject kind) through a zod schema before it touches the popup-facing maps or IndexedDB. `recordDetection`'s attacker-influenceable payload is now validated, not trusted wholesale. The old hand-rolled rule-count sanitization is preserved exactly (drop unknown ids, floor, drop non-positive) inside `ruleCountsSchema`. Design notes: - zod stays OUT of the content and popup bundles (verified): the shared `messenger.ts` contract is dependency-free; validation lives in the background-only `message-schemas.ts`. The trust boundary is the page→worker hop, so the worker is the only context that validates. - Fire-and-forget paths use `getNotifier` (no response, no retries, swallows errors) — same posture as the old `sendMessage(...).catch()`. - The tab-pause push broadcasts via `frameId: "allFrames"`, matching the prior all-frames `tabs.sendMessage`. - Dropped the dead `get-tab-detections` handler (no sender anywhere) and the now-obsolete `{ type: ... }` envelope interfaces. - The three `inject-*` messages collapse into one validated `requestPageWorldInject(kind)` over a zod enum. Testing: - New `message-schemas.test.ts` pins the validation boundary (happy path + attacker-shaped-payload rejection for each message). - Rule/recorder tests now mock `lib/messenger` and assert the semantic call rather than a wire envelope. - `webext-messenger` (ESM-only) gets a test mock via moduleNameMapper, same as webext-storage/abort-utils; `messenger.ts` is coverage-excluded as transport glue, like effective-enforcement.ts. - 2042 tests pass; typecheck, biome+eslint, knip, build (+ background purity) all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- extension/bun.lock | 13 + extension/jest.config.cjs | 10 + extension/package.json | 1 + .../src/__test-mocks__/debug-trace-stub.ts | 46 +-- .../src/__test-mocks__/webext-messenger.ts | 34 ++ extension/src/background.ts | 331 ++++++++---------- .../src/lib/__tests__/debug-trace.test.ts | 17 +- .../dump-trace-content-bridge.test.ts | 47 +-- .../src/lib/__tests__/message-schemas.test.ts | 204 +++++++++++ .../lib/__tests__/page-world-hooks.test.ts | 39 ++- .../src/lib/__tests__/rule-count.test.ts | 50 +-- .../src/lib/__tests__/segment-tracker.test.ts | 6 + .../src/lib/__tests__/trace-mutation.test.ts | 11 +- extension/src/lib/debug-trace.ts | 13 +- extension/src/lib/detection-messages.ts | 97 +---- .../src/lib/dump-trace-content-bridge.ts | 23 +- extension/src/lib/effective-enforcement.ts | 64 ++-- extension/src/lib/message-schemas.ts | 159 +++++++++ extension/src/lib/messenger.ts | 119 +++++++ extension/src/lib/options-badge.ts | 9 +- extension/src/lib/page-world-hooks.ts | 35 +- extension/src/lib/rule-count.ts | 15 +- extension/src/popup/use-tab-detections.ts | 16 +- .../checkout-checkbox-sanitize.test.ts | 25 +- .../closed-shadow-root-annotate.test.ts | 72 ++-- .../__tests__/roach-motel-annotate.test.ts | 44 ++- .../webdriver-probe-annotate.test.ts | 86 ++--- .../src/rules/checkout-checkbox-sanitize.ts | 18 +- .../src/rules/closed-shadow-root-annotate.ts | 36 +- extension/src/rules/roach-motel-annotate.ts | 26 +- .../src/rules/webdriver-probe-annotate.ts | 31 +- 31 files changed, 1021 insertions(+), 676 deletions(-) create mode 100644 extension/src/__test-mocks__/webext-messenger.ts create mode 100644 extension/src/lib/__tests__/message-schemas.test.ts create mode 100644 extension/src/lib/message-schemas.ts create mode 100644 extension/src/lib/messenger.ts diff --git a/extension/bun.lock b/extension/bun.lock index d1b9283..7344287 100644 --- a/extension/bun.lock +++ b/extension/bun.lock @@ -14,6 +14,7 @@ "react-dom": "19.2.6", "tldts": "^7.4.2", "urlpattern-polyfill": "10.1.0", + "webext-messenger": "^0.35.0", "webext-storage": "^3.1.0", }, "devDependencies": { @@ -895,6 +896,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "one-event": ["one-event@4.4.0", "", {}, "sha512-pBKpjHoqf0P4Ne59sVP4mdkudGqASabycau4OOOch+PYj/wEQwNw5ECUuIj9Cw42TKVyKcTCewugg4Nt5XLlKQ=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -971,6 +974,8 @@ "semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + "serialize-error": ["serialize-error@12.0.0", "", { "dependencies": { "type-fest": "^4.31.0" } }, "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1015,6 +1020,8 @@ "synckit": ["synckit@0.11.13", "", { "dependencies": { "@pkgr/core": "^0.3.6" } }, "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], @@ -1069,6 +1076,10 @@ "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + "webext-detect": ["webext-detect@5.3.2", "", {}, "sha512-RU/e8OLiPwU0efWs/cHp3sRCY+vakaTnbOS/x8VM/FkTtv6cfW/Ofed0i7WnkAKw2jbp4gki3WcpAdii+2QVMw=="], + + "webext-messenger": ["webext-messenger@0.35.0", "", { "dependencies": { "one-event": "^4.3.0", "serialize-error": "^12.0.0", "type-fest": "^5.0.1", "webext-detect": "^5.3.2" } }, "sha512-gxm/i8s8QZUGE88/nMs6OEAKBHkJz7UbVKpCJyQg6l8rlummTJKqQ6riqIZd1Gm9DYlxxOqrFiKBbK3wKhAc6w=="], + "webext-storage": ["webext-storage@3.1.0", "", {}, "sha512-tjOqhDCNfNNh5fj36jI84LJys1oYHNPEc1wjivEQe18Cz9QCuaqtIjzMQ29Io/IaM4nq3Er+HWgEXoN3u/Ox7g=="], "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], @@ -1257,6 +1268,8 @@ "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.0", "@typescript-eslint/types": "8.60.0", "@typescript-eslint/typescript-estree": "8.60.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA=="], + "webext-messenger/type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/extension/jest.config.cjs b/extension/jest.config.cjs index e5959e7..e14d939 100644 --- a/extension/jest.config.cjs +++ b/extension/jest.config.cjs @@ -39,6 +39,10 @@ module.exports = { moduleNameMapper: { "^webext-storage$": "/src/__test-mocks__/webext-storage.ts", "^abort-utils$": "/src/__test-mocks__/abort-utils.ts", + // webext-messenger is ESM-only too; the stub keeps the real `lib/messenger` + // loadable in jsdom (its wrappers go inert). Tests that assert on messaging + // mock `lib/messenger` directly. + "^webext-messenger$": "/src/__test-mocks__/webext-messenger.ts", }, // - jest-webextension-mock: installs globalThis.chrome + browser with // jest.fn() stubs for MV2/MV3 APIs the extension uses (runtime, storage, @@ -71,6 +75,12 @@ module.exports = { "!src/lib/*.tsx", "!src/lib/options-badge.ts", "!src/lib/options-button-toggle.ts", + // Transport glue over `webext-messenger`: the typed method contract plus + // thin getMethod/getNotifier wrappers. Exercising it requires the loaded + // extension (real cross-context messaging); unit-testing it would just test + // the library. The runtime validation it gates lives in `message-schemas.ts`, + // which IS covered. Same posture as `effective-enforcement.ts` below. + "!src/lib/messenger.ts", "!src/lib/use-chrome-storage-value.ts", "!src/lib/use-transient-status.ts", "!src/lib/placeholder-count.ts", diff --git a/extension/package.json b/extension/package.json index dce5ae5..e48511c 100644 --- a/extension/package.json +++ b/extension/package.json @@ -51,6 +51,7 @@ "react-dom": "19.2.6", "tldts": "^7.4.2", "urlpattern-polyfill": "10.1.0", + "webext-messenger": "^0.35.0", "webext-storage": "^3.1.0" }, "devDependencies": { diff --git a/extension/src/__test-mocks__/debug-trace-stub.ts b/extension/src/__test-mocks__/debug-trace-stub.ts index d379cd8..95c25ce 100644 --- a/extension/src/__test-mocks__/debug-trace-stub.ts +++ b/extension/src/__test-mocks__/debug-trace-stub.ts @@ -4,9 +4,17 @@ // Shared setup for tests that exercise the debug-trace recorder, the // segment tracker, or anything downstream of `recordRuleApplication`. // -// Installs a jest.fn() over `chrome.runtime.sendMessage`, resets the -// recorder module state, and gives callers a typed accessor for the -// captured trace entries. Use in beforeEach: +// The recorder now emits via the typed `lib/messenger` wrapper +// `reportDebugTraceEvent` instead of raw `chrome.runtime.sendMessage`. Tests +// that use this stub MUST mock that module so the import below resolves to a +// jest mock: +// +// jest.mock("../../lib/messenger", () => ({ +// reportDebugTraceEvent: jest.fn(), +// // ...any other messenger exports the test's code-under-test uses +// })); +// +// Usage: // // let stub: DebugTraceStub; // beforeEach(() => { stub = installDebugTraceStub(); }); @@ -16,36 +24,30 @@ import { __resetDebugTraceForTesting, __setDebugTraceEnabledForTesting, } from "../lib/debug-trace"; -import type { - DebugTraceEntry, - DebugTraceEventMessage, -} from "../lib/detection-messages"; +import type { DebugTraceEntry } from "../lib/detection-messages"; +import { reportDebugTraceEvent } from "../lib/messenger"; export interface DebugTraceStub { - sendMessage: jest.Mock; setEnabled: (value: boolean) => void; - // Every entry sent via the `debug-trace-event` message, in call order. + // The mocked `reportDebugTraceEvent` — assert call counts directly. + events: jest.Mock; + // Every entry passed to `reportDebugTraceEvent`, in call order. sentEntries: () => DebugTraceEntry[]; reset: () => void; } export function installDebugTraceStub(): DebugTraceStub { __resetDebugTraceForTesting(); - const sendMessage = jest.fn().mockResolvedValue(undefined); - (globalThis as { chrome: unknown }).chrome = { - runtime: { sendMessage }, - }; + const events = reportDebugTraceEvent as jest.Mock; + events.mockClear(); return { - sendMessage, setEnabled: __setDebugTraceEnabledForTesting, + events, sentEntries: () => - sendMessage.mock.calls - .map(([message]) => message as { type: string }) - .filter( - (message): message is DebugTraceEventMessage => - message.type === "debug-trace-event", - ) - .map((message) => message.entry), - reset: __resetDebugTraceForTesting, + events.mock.calls.map(([entry]) => entry as DebugTraceEntry), + reset: () => { + __resetDebugTraceForTesting(); + events.mockClear(); + }, }; } diff --git a/extension/src/__test-mocks__/webext-messenger.ts b/extension/src/__test-mocks__/webext-messenger.ts new file mode 100644 index 0000000..2cce4f8 --- /dev/null +++ b/extension/src/__test-mocks__/webext-messenger.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Test mock for the ESM-only `webext-messenger` package. ts-jest emits +// CommonJS, so importing the real package trips +// `SyntaxError: Unexpected token 'export'` — the same treatment `webext-storage` +// and `abort-utils` get (see jest.config.cjs moduleNameMapper). +// +// None of our unit tests exercise real cross-context messaging. Tests that +// assert on a specific messenger call mock `lib/messenger` directly; every +// other test only loads `lib/messenger` transitively (e.g. via `debug-trace`) +// and just needs its getMethod/getNotifier wrappers to be constructible and +// inert. So the senders here are no-op stubs and `registerMethods` does +// nothing. + +export const backgroundTarget = { page: "background" }; + +const inertMethod = (): Promise => Promise.resolve(undefined); + +const inertNotifier = (): void => { + // noop — notifications are fire-and-forget +}; + +export function getMethod(): () => Promise { + return inertMethod; +} + +export function getNotifier(): () => void { + return inertNotifier; +} + +export function registerMethods(): void { + // noop — no real receiver in the unit-test world +} diff --git a/extension/src/background.ts b/extension/src/background.ts index c35683b..1a1023c 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -10,18 +10,10 @@ import { } from "./lib/debug-trace-store"; import type { DebugTraceEntry, - DebugTraceEventMessage, DetectionKind, DetectionPayload, - GetTabDebugTraceResponse, - GetTabDetectionsRequest, - GetTabDetectionsResponse, - GetTabPauseResponse, - GetTabRuleCountsRequest, GetTabRuleCountsResponse, RuleCountEntry, - RuleDetectionMessage, - TabPauseChangedMessage, } from "./lib/detection-messages"; import { getEnforcementEnabled, @@ -29,6 +21,17 @@ import { } from "./lib/enforcement"; import { startClassifyPortListener } from "./lib/llm-background"; import { log } from "./lib/log"; +import type { RuleCountMap } from "./lib/message-schemas"; +import { + debugTraceEntrySchema, + detectionPayloadSchema, + injectTypeSchema, + ruleCountsSchema, + tabIdSchema, + validatedNotification, +} from "./lib/message-schemas"; +import type { MessengerMeta } from "./lib/messenger"; +import { notifyTabPause, registerMethods } from "./lib/messenger"; import { dispatchPageWorldInject, startPageWorldHooks, @@ -48,15 +51,13 @@ import { protectionAppearanceKey, } from "./lib/toolbar-protection"; import type { RuleId } from "./rules/rule-metadata"; -import { RULE_IDS } from "./rules/rule-metadata"; // Per-tab, per-frame, per-rule footprint counts. Each content script reports // its own frame's tally grouped by rule id; the badge shows the cross-frame // sum across all rules, and the popup renders per-rule entries derived from -// the same map. -type RuleCountMap = Partial>; +// the same map. The reported counts are sanitized against the known rule ids +// by `ruleCountsSchema` before they reach `recordFrameRuleCounts`. const tabRuleCounts = new Map>(); -const KNOWN_RULE_IDS = new Set(RULE_IDS); // Per-tab record of rule detections surfaced to the popup. One entry per // kind per tab — both contributing rules are topFrameOnly and self-dedupe @@ -443,10 +444,10 @@ tabPauseMap.onChanged((key, value: TabPause | undefined) => { tabPauses.set(tabId, value); } const paused = isPauseActive(tabPauses.get(tabId) ?? null, Date.now()); - const message: TabPauseChangedMessage = { type: "tab-pause-changed", paused }; - chrome.tabs.sendMessage(tabId, message).catch(() => { - // Tab has no content script (restricted URL) or has closed — ignore. - }); + // Broadcast to every frame in the tab. The notifier is fire-and-forget: a tab + // with no content script (restricted URL) or one that has closed just drops + // the message. + notifyTabPause(tabId, paused); refreshBadge(tabId); }); // Hydrate the cache from the session store so a service-worker restart doesn't @@ -497,196 +498,138 @@ void ruleStatesStorage.get().then((initial) => { previousRuleStates ??= { ...initial }; }); -chrome.runtime.onMessage.addListener( - (rawMessage: unknown, sender, sendResponse) => { - if (!rawMessage || typeof rawMessage !== "object") { - return undefined; - } - const message = rawMessage as { - type?: unknown; - counts?: unknown; - }; - - if (message.type === "open-options") { - chrome.runtime.openOptionsPage(() => { - sendResponse({ ok: true }); - }); - return true; - } - - if (message.type === "get-tab-url") { - // Subframe content scripts ask this once at startup so they can - // evaluate the per-site denylist (ADR-0018) against the tab's - // top-frame URL instead of their own iframe URL. `sender.tab.url` - // requires host permission for the URL — we have , so - // this is just a property read. Frames inside a tab whose URL the - // background can't resolve get `{ url: null }` and the requesting - // content script falls back to "URL unknown → fail open." - const url = sender.tab?.url ?? null; - sendResponse({ url }); - return undefined; - } - - if (message.type === "get-tab-pause") { - // Content scripts ask this once at rule-engine init to seed the - // tab-scoped recovery pause (ADR-0019). Read the session store directly - // rather than the in-memory cache so a request that lands before startup - // hydration still resolves accurately. The background applies the - // `expiresAt` check so the content side only ever sees a boolean. - const tabId = sender.tab?.id; - if (typeof tabId !== "number") { - const response: GetTabPauseResponse = { paused: false }; - sendResponse(response); - return undefined; +// The typed message router. Every method below is the receiving half of a +// `lib/messenger.ts` call; the sender's payload is decoded through its +// `message-schemas.ts` validator before it touches any state. `this.trace[0]` +// is the immediate sender (`chrome.runtime.MessageSender`) — the content +// script's tabId / frameId / url are read from there, never from the payload. +registerMethods({ + // Record a rule's a11y-tree detection so the popup can surface it. + recordDetection: validatedNotification( + detectionPayloadSchema, + (payload, meta) => { + const tabId = meta.trace[0]?.tab?.id; + if (typeof tabId === "number") { + recordDetection(tabId, payload); } - void tabPauseMap - .get(String(tabId)) - .then((value) => { - const response: GetTabPauseResponse = { - paused: isPauseActive(value ?? null, Date.now()), - }; - sendResponse(response); - }) - .catch(() => { - const response: GetTabPauseResponse = { paused: false }; - sendResponse(response); - }); - return true; - } - - // The page-world `inject-*` fallbacks (webdriver-probe, - // checkout-checkbox-defense, shadow-root-probe) share one executeScript - // shape — the registered content script covers future navigations, this - // round-trip runs the install fn on the tab the user was already viewing - // when they toggled the rule on. The table and the per-install - // `__abs_*_installed` page-world guards live in `lib/page-world-hooks.ts`. - if ( - typeof message.type === "string" && - dispatchPageWorldInject(message.type, sender) - ) { - return undefined; + }, + ), + + // Per-frame rule footprint. `ruleCountsSchema` has already dropped unknown + // rule ids and non-positive counts, so a misbehaving content script can't + // poison the badge or popup. + reportRuleCounts: validatedNotification(ruleCountsSchema, (counts, meta) => { + const tabId = meta.trace[0]?.tab?.id; + const frameId = meta.trace[0]?.frameId; + if (typeof tabId === "number" && typeof frameId === "number") { + recordFrameRuleCounts(tabId, frameId, counts); } - - if (message.type === "rule-count") { - const tabId = sender.tab?.id; - const frameId = sender.frameId; - const raw = message.counts; - if ( - typeof tabId === "number" && - typeof frameId === "number" && - typeof raw === "object" && - raw !== null - ) { - // Sanitize: drop unknown rule ids and non-positive counts so a - // misbehaving content script can't poison the popup or badge. - const sanitized: RuleCountMap = {}; - for (const [key, value] of Object.entries( - raw as Record, - )) { - if ( - KNOWN_RULE_IDS.has(key) && - typeof value === "number" && - Number.isFinite(value) && - value > 0 - ) { - sanitized[key as RuleId] = Math.floor(value); - } - } - recordFrameRuleCounts(tabId, frameId, sanitized); + }), + + // Dev-mode trace event → IndexedDB. Fire-and-forget: the IDB write is async + // but the handler shouldn't block on disk; pruning happens inside + // `appendEvent`. + reportDebugTraceEvent: validatedNotification( + debugTraceEntrySchema, + (entry, meta) => { + const tabId = meta.trace[0]?.tab?.id; + const frameId = meta.trace[0]?.frameId; + if (typeof tabId !== "number" || typeof frameId !== "number") { + return; } - return undefined; - } - - if (message.type === "rule-detection") { - const tabId = sender.tab?.id; - if (typeof tabId === "number") { - recordDetection(tabId, (message as RuleDetectionMessage).payload); + // `entry` is a validated debug-trace entry; the cast only bridges + // `exactOptionalPropertyTypes` (zod infers `key?: T | undefined` for the + // optional fields where the interface declares `key?: T`). + void appendDebugTraceEvent( + tabId, + frameId, + entry as DebugTraceEntry, + ).catch((error: unknown) => { + log.error("debug-trace IDB write failed", { error }); + }); + }, + ), + + // Run a page-world install fn on the tab the user was already viewing when + // they toggled a rule on — dynamic registrations only take effect on the next + // navigation. The table and the per-install `__abs_*_installed` page-world + // guards live in `lib/page-world-hooks.ts`. + requestPageWorldInject: validatedNotification( + injectTypeSchema, + (injectType, meta) => { + const sender = meta.trace[0]; + if (sender) { + dispatchPageWorldInject(injectType, sender); } - return undefined; - } + }, + ), + + // Subframe content scripts ask this once at startup so they can evaluate the + // per-site denylist (ADR-0018) against the tab's top-frame URL instead of + // their own iframe URL. `sender.tab.url` requires host permission — we have + // , so this is just a property read. A frame whose tab URL the + // background can't resolve gets `null` and falls back to "URL unknown → fail + // open." + getTabUrl(this: MessengerMeta) { + return this.trace[0]?.tab?.url ?? null; + }, - if (message.type === "get-tab-detections") { - const request = message as unknown as GetTabDetectionsRequest; - const entry = - typeof request.tabId === "number" - ? tabDetections.get(request.tabId) - : undefined; - const response: GetTabDetectionsResponse = { - detections: entry ? [...entry.values()] : [], - }; - sendResponse(response); - return undefined; + // Content scripts ask this once at rule-engine init to seed the tab-scoped + // recovery pause (ADR-0019). Read the session store directly rather than the + // in-memory cache so a request that lands before startup hydration still + // resolves accurately. The background applies the `expiresAt` check so the + // content side only ever sees a boolean. + async getTabPause(this: MessengerMeta) { + const tabId = this.trace[0]?.tab?.id; + if (typeof tabId !== "number") { + return false; } - - if (message.type === "get-tab-rule-counts") { - const request = message as unknown as GetTabRuleCountsRequest; - const response: GetTabRuleCountsResponse = - typeof request.tabId === "number" - ? buildRuleCountsResponse(request.tabId) - : { entries: [], detections: [] }; - sendResponse(response); - return undefined; + try { + const value = await tabPauseMap.get(String(tabId)); + return isPauseActive(value ?? null, Date.now()); + } catch { + return false; } + }, - if (message.type === "get-tab-debug-trace") { - const tabId = sender.tab?.id; - if (typeof tabId !== "number") { - return undefined; - } - // Async response — the page-world bridge is awaiting via - // postMessage round-trip, so a missing reply hangs the caller - // (up to its 10s timeout). `return true` keeps the message - // channel open until `sendResponse` fires. - void getDebugTraceForTab(tabId) - .then((stored) => { - // Flatten to the public wire shape: `addedAt` is internal IDB - // bookkeeping and the tabId/frameId live on the entry, matching - // `extension/data/debug-trace.schema.json`. - const response: GetTabDebugTraceResponse = { - entries: stored.map((record) => toExportedRecord(record)), - }; - sendResponse(response); - }) - .catch((error: unknown) => { - log.error("get-tab-debug-trace IDB read failed", { error }); - const response: GetTabDebugTraceResponse = { entries: [] }; - sendResponse(response); - }); - return true; - } + // Combined per-rule + detection snapshot the popup renders on open. + getTabRuleCounts( + this: MessengerMeta, + tabId: number, + ): GetTabRuleCountsResponse { + const parsed = tabIdSchema.safeParse(tabId); + return parsed.success + ? buildRuleCountsResponse(parsed.data) + : { entries: [], detections: [] }; + }, - if (message.type === "debug-trace-event") { - const tabId = sender.tab?.id; - const frameId = sender.frameId; - // `entry` is typed as `unknown` rather than `DebugTraceEntry` so the - // runtime guards below are actually type-meaningful — a malformed - // sendMessage payload could carry `entry: null` (typeof null is - // "object") or omit it entirely, and the cast on the message - // envelope wouldn't catch either. - const entry: unknown = (message as unknown as DebugTraceEventMessage) - .entry; - if ( - typeof tabId === "number" && - typeof frameId === "number" && - typeof entry === "object" && - entry !== null - ) { - // Fire-and-forget — IDB writes are async, but the message handler - // shouldn't block on disk. Pruning happens inside `appendEvent`. - void appendDebugTraceEvent( - tabId, - frameId, - entry as DebugTraceEntry, - ).catch((error: unknown) => { - log.error("debug-trace IDB write failed", { error }); - }); - } - return undefined; + // Page-world `__abs_dumpTrace` bridge → IDB read. Flattened to the public + // wire shape: `addedAt` is internal IDB bookkeeping and the tabId/frameId + // live on the entry, matching `extension/data/debug-trace.schema.json`. + async getTabDebugTrace(this: MessengerMeta) { + const tabId = this.trace[0]?.tab?.id; + if (typeof tabId !== "number") { + return { entries: [] }; } + try { + const stored = await getDebugTraceForTab(tabId); + return { entries: stored.map((record) => toExportedRecord(record)) }; + } catch (error: unknown) { + log.error("get-tab-debug-trace IDB read failed", { error }); + return { entries: [] }; + } + }, - return undefined; + // Popup / page badge → open the options page. + async openOptions() { + await new Promise((resolve) => { + chrome.runtime.openOptionsPage(() => { + resolve(); + }); + }); + return { ok: true } as const; }, -); +}); // Build the combined per-rule + detection snapshot the popup renders. // Entries are sorted by count desc, breaking ties by rule id for a stable diff --git a/extension/src/lib/__tests__/debug-trace.test.ts b/extension/src/lib/__tests__/debug-trace.test.ts index b970813..4dd3db7 100644 --- a/extension/src/lib/__tests__/debug-trace.test.ts +++ b/extension/src/lib/__tests__/debug-trace.test.ts @@ -6,6 +6,13 @@ // toggle is read through `debug-trace.ts`'s test-only override so the // chrome.storage round-trip stays out of the unit test. +// The recorder emits via the typed `lib/messenger` wrapper; mock it so the +// suite reads the captured entries through `installDebugTraceStub` and the real +// `webext-messenger` never loads in jsdom. +jest.mock("../messenger", () => ({ + reportDebugTraceEvent: jest.fn(), +})); + import type { DebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import { installDebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import { recordRuleApplication, recordSegment } from "../debug-trace"; @@ -37,7 +44,7 @@ describe("debug-trace recorder", () => { afterHtml: "
", }); - expect(stub.sendMessage).not.toHaveBeenCalled(); + expect(stub.events).not.toHaveBeenCalled(); }); it("emits segment markers with monotonically increasing ids", () => { @@ -84,15 +91,15 @@ describe("debug-trace recorder", () => { expect(applications[1]?.segmentId).toBe(2); }); - it("swallows sendMessage rejections so a sleeping SW doesn't surface as unhandled", () => { + it("emits as a fire-and-forget notification (recorder never throws)", () => { stub.setEnabled(true); - stub.sendMessage.mockRejectedValueOnce( - new Error("Receiving end does not exist"), - ); expect(() => { recordSegment("initial-load", { url: "https://example.com" }); }).not.toThrow(); + // The notifier handles a sleeping service worker; the recorder just hands + // the entry off. + expect(stub.events).toHaveBeenCalledTimes(1); }); it("resets the segment counter when the toggle is turned off", () => { diff --git a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts index e043298..cc4bbda 100644 --- a/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts +++ b/extension/src/lib/__tests__/dump-trace-content-bridge.test.ts @@ -8,15 +8,23 @@ // Tests for the isolated-world content-script half of the // `window.__abs_dumpTrace` bridge. The listener accepts // `{ source: "abs-dump-trace", direction: "request", id }` messages -// from the page world, forwards a `get-tab-debug-trace` runtime -// message to the background, and posts the response (or error) back -// keyed by id. +// from the page world, calls the typed `getTabDebugTrace` messenger method +// against the background, and posts the response (or error) back keyed by id. // // jsdom's single-world model means a message posted with // `window.postMessage` reaches the bridge's listener the same way a // page-world script would in production. +// Mock the messenger so the suite drives `getTabDebugTrace` directly and the +// real `webext-messenger` never loads in jsdom. +jest.mock("../messenger", () => ({ + getTabDebugTrace: jest.fn(), +})); + import { startDumpTraceContentBridge } from "../dump-trace-content-bridge"; +import { getTabDebugTrace } from "../messenger"; + +const getTabDebugTraceMock = getTabDebugTrace as jest.Mock; interface ResponseBody { source?: unknown; @@ -49,7 +57,7 @@ function captureResponses(): { responses: ResponseBody[]; stop: () => void } { // Wait for one tick of the macrotask queue so jsdom's queued postMessage // dispatches and any pending microtasks settle. A single `setTimeout(0)` -// flush is enough for the request → sendMessage → response chain in +// flush is enough for the request → getTabDebugTrace → response chain in // these tests because each hop only adds microtasks beyond the // already-queued message event. function flush(): Promise { @@ -78,13 +86,10 @@ function dispatchBridgeMessage(data: unknown): MessageEvent { return event; } -let sendMessage: jest.Mock; let stopBridge: () => void; let stopCapture: () => void; beforeEach(() => { - sendMessage = chrome.runtime.sendMessage as unknown as jest.Mock; - sendMessage.mockReset(); stopBridge = () => { // noop until a test starts the bridge }; @@ -108,7 +113,7 @@ describe("startDumpTraceContentBridge", () => { entry: { type: "segment", segmentId: 1, kind: "initial-load" }, }, ]; - sendMessage.mockResolvedValueOnce({ entries: fakeEntries }); + getTabDebugTraceMock.mockResolvedValueOnce({ entries: fakeEntries }); stopBridge = startDumpTraceContentBridge(); const capture = captureResponses(); stopCapture = capture.stop; @@ -122,8 +127,7 @@ describe("startDumpTraceContentBridge", () => { await flush(); await flush(); - expect(sendMessage).toHaveBeenCalledTimes(1); - expect(sendMessage).toHaveBeenCalledWith({ type: "get-tab-debug-trace" }); + expect(getTabDebugTraceMock).toHaveBeenCalledTimes(1); expect(responses).toHaveLength(1); expect(responses[0]).toMatchObject({ source: "abs-dump-trace", @@ -133,13 +137,10 @@ describe("startDumpTraceContentBridge", () => { }); }); - it("treats an undefined background reply as an empty trace", async () => { - // chrome.runtime.sendMessage resolves with `undefined` when the - // background listener returns without calling sendResponse — that - // happens in the get-tab-debug-trace branch when sender.tab?.id is - // missing. Earlier code dereferenced response.entries and crashed - // with TypeError; now it should post an empty-entries response. - sendMessage.mockResolvedValueOnce(undefined); + it("echoes an empty trace back when the background has none", async () => { + // The background resolves a missing `sender.tab?.id` to `{ entries: [] }`, + // so the bridge posts an empty-entries response rather than crashing. + getTabDebugTraceMock.mockResolvedValueOnce({ entries: [] }); stopBridge = startDumpTraceContentBridge(); const capture = captureResponses(); stopCapture = capture.stop; @@ -164,7 +165,7 @@ describe("startDumpTraceContentBridge", () => { }); it("posts an error response when the background rejects", async () => { - sendMessage.mockRejectedValueOnce(new Error("nope")); + getTabDebugTraceMock.mockRejectedValueOnce(new Error("nope")); stopBridge = startDumpTraceContentBridge(); const capture = captureResponses(); stopCapture = capture.stop; @@ -193,7 +194,7 @@ describe("startDumpTraceContentBridge", () => { }); await flush(); - expect(sendMessage).not.toHaveBeenCalled(); + expect(getTabDebugTraceMock).not.toHaveBeenCalled(); }); it("ignores messages with a non-string id", async () => { @@ -206,7 +207,7 @@ describe("startDumpTraceContentBridge", () => { }); await flush(); - expect(sendMessage).not.toHaveBeenCalled(); + expect(getTabDebugTraceMock).not.toHaveBeenCalled(); }); it("ignores response-direction messages so it doesn't echo its own replies", async () => { @@ -220,7 +221,7 @@ describe("startDumpTraceContentBridge", () => { }); await flush(); - expect(sendMessage).not.toHaveBeenCalled(); + expect(getTabDebugTraceMock).not.toHaveBeenCalled(); }); it("ignores messages whose id exceeds the length cap", async () => { @@ -233,7 +234,7 @@ describe("startDumpTraceContentBridge", () => { }); await flush(); - expect(sendMessage).not.toHaveBeenCalled(); + expect(getTabDebugTraceMock).not.toHaveBeenCalled(); }); it("ignores cross-origin messages even with the right source literal", async () => { @@ -247,6 +248,6 @@ describe("startDumpTraceContentBridge", () => { eventSource.dispatchEvent(event); await flush(); - expect(sendMessage).not.toHaveBeenCalled(); + expect(getTabDebugTraceMock).not.toHaveBeenCalled(); }); }); diff --git a/extension/src/lib/__tests__/message-schemas.test.ts b/extension/src/lib/__tests__/message-schemas.test.ts new file mode 100644 index 0000000..ea3d588 --- /dev/null +++ b/extension/src/lib/__tests__/message-schemas.test.ts @@ -0,0 +1,204 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// The page→worker trust boundary. These schemas decode every content-script +// payload before it touches the popup-facing maps or IndexedDB, so the tests +// pin both the happy path and the "attacker-shaped payload is rejected/dropped" +// path for each message. + +import { RULE_IDS } from "../../rules/rule-metadata"; +import { + debugTraceEntrySchema, + detectionPayloadSchema, + injectTypeSchema, + ruleCountsSchema, + tabIdSchema, + validatedNotification, +} from "../message-schemas"; +import type { MessengerMeta } from "../messenger"; + +const [KNOWN_RULE_ID] = RULE_IDS; +if (KNOWN_RULE_ID === undefined) { + throw new Error("expected at least one rule id in the catalog"); +} + +describe("detectionPayloadSchema", () => { + it("accepts a roach-motel payload", () => { + const payload = { + kind: "roach-motel", + host: "example.com", + url: "https://example.com/x", + difficulty: "hard", + cancellationUrl: null, + source: "curated", + }; + expect(detectionPayloadSchema.parse(payload)).toEqual(payload); + }); + + it("accepts webdriver-probe and closed-shadow-root payloads", () => { + for (const kind of ["webdriver-probe", "closed-shadow-root"] as const) { + const payload = { kind, host: "h", url: "u" }; + expect(detectionPayloadSchema.parse(payload)).toEqual(payload); + } + }); + + it("rejects an unknown kind", () => { + expect( + detectionPayloadSchema.safeParse({ kind: "totally-made-up", host: "h" }) + .success, + ).toBe(false); + }); + + it("rejects a roach-motel payload with an out-of-range difficulty", () => { + expect( + detectionPayloadSchema.safeParse({ + kind: "roach-motel", + host: "h", + url: "u", + difficulty: "trivial", + cancellationUrl: null, + source: "curated", + }).success, + ).toBe(false); + }); + + it("rejects a payload missing required fields", () => { + expect( + detectionPayloadSchema.safeParse({ kind: "webdriver-probe", host: "h" }) + .success, + ).toBe(false); + }); +}); + +describe("ruleCountsSchema", () => { + it("keeps known rule ids with positive counts and floors them", () => { + const result = ruleCountsSchema.parse({ [KNOWN_RULE_ID]: 3.9 }); + expect(result).toEqual({ [KNOWN_RULE_ID]: 3 }); + }); + + it("drops unknown rule ids, non-positive, and non-finite counts", () => { + const result = ruleCountsSchema.parse({ + [KNOWN_RULE_ID]: 2, + "not-a-real-rule": 5, + [`${KNOWN_RULE_ID}-zero`]: 0, + bogusInfinity: Number.POSITIVE_INFINITY, + }); + expect(result).toEqual({ [KNOWN_RULE_ID]: 2 }); + }); + + it("rejects a non-object payload", () => { + expect(ruleCountsSchema.safeParse(42).success).toBe(false); + }); +}); + +describe("debugTraceEntrySchema", () => { + it("accepts a segment entry", () => { + const entry = { + type: "segment", + segmentId: 1, + kind: "initial-load", + timestamp: 10, + meta: { url: "https://x", pending: 2 }, + }; + expect(debugTraceEntrySchema.parse(entry)).toEqual(entry); + }); + + it("accepts a rule-application entry with optional fields", () => { + const entry = { + type: "rule-application", + segmentId: 1, + ruleId: "pii-redact", + kind: "mask", + timestamp: 10, + selector: "p#x", + beforeHtml: "

a

", + afterHtml: "

", + beforeText: "a", + cssOnly: false, + }; + expect(debugTraceEntrySchema.parse(entry)).toEqual(entry); + }); + + it("rejects an unknown entry type", () => { + expect( + debugTraceEntrySchema.safeParse({ type: "wat", timestamp: 1 }).success, + ).toBe(false); + }); + + it("rejects a rule-application entry with an unknown kind", () => { + expect( + debugTraceEntrySchema.safeParse({ + type: "rule-application", + segmentId: 1, + ruleId: "r", + kind: "explode", + timestamp: 1, + selector: "s", + beforeHtml: "", + afterHtml: "", + }).success, + ).toBe(false); + }); +}); + +describe("injectTypeSchema", () => { + it("accepts the known inject kinds", () => { + for (const kind of [ + "webdriver-probe", + "checkout-checkbox-defense", + "shadow-root-probe", + ]) { + expect(injectTypeSchema.parse(kind)).toBe(kind); + } + }); + + it("rejects an unknown inject kind", () => { + expect(injectTypeSchema.safeParse("inject-everything").success).toBe(false); + }); +}); + +describe("tabIdSchema", () => { + it("accepts a non-negative integer", () => { + expect(tabIdSchema.parse(7)).toBe(7); + }); + + it("rejects negatives and non-integers", () => { + expect(tabIdSchema.safeParse(-1).success).toBe(false); + expect(tabIdSchema.safeParse(1.5).success).toBe(false); + expect(tabIdSchema.safeParse("7").success).toBe(false); + }); +}); + +describe("validatedNotification", () => { + const meta = { + trace: [{ tab: { id: 3 }, frameId: 0 }], + } as unknown as MessengerMeta; + + it("calls the handler with parsed data and the sender meta on valid input", () => { + const handler = jest.fn(); + const guarded = validatedNotification(injectTypeSchema, handler); + + guarded.call(meta, "webdriver-probe"); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith("webdriver-probe", meta); + }); + + it("drops the message (no handler call) on invalid input", () => { + const handler = jest.fn(); + const guarded = validatedNotification(injectTypeSchema, handler); + + guarded.call(meta, "not-a-real-inject"); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("hands the handler the transformed value for schemas with a transform", () => { + const handler = jest.fn(); + const guarded = validatedNotification(ruleCountsSchema, handler); + + guarded.call(meta, { [KNOWN_RULE_ID]: 2.7, junk: -4 }); + + expect(handler).toHaveBeenCalledWith({ [KNOWN_RULE_ID]: 2 }, meta); + }); +}); diff --git a/extension/src/lib/__tests__/page-world-hooks.test.ts b/extension/src/lib/__tests__/page-world-hooks.test.ts index ab2cf24..571f964 100644 --- a/extension/src/lib/__tests__/page-world-hooks.test.ts +++ b/extension/src/lib/__tests__/page-world-hooks.test.ts @@ -17,6 +17,11 @@ import { flushScriptingPromises, installScriptingRegistry, } from "../../__test-mocks__/chrome-scripting-registry"; +import type { PageWorldInjectType } from "../messenger"; + +// `debug-trace` (pulled in transitively by the hook table) emits via the +// typed messenger; mock it so the real `webext-messenger` never loads in jsdom. +jest.mock("../messenger", () => ({ reportDebugTraceEvent: jest.fn() })); // See storage.test.ts for the rationale on these mocks — the storage module // pulls in the full rule catalog transitively. @@ -41,11 +46,11 @@ interface HooksModule { scriptFile: string; logLabel: string; allFrames: boolean; - inject?: { messageType: string }; + inject?: { injectType: PageWorldInjectType }; }>; startPageWorldHooks: () => void; dispatchPageWorldInject: ( - messageType: string, + injectType: PageWorldInjectType, sender: chrome.runtime.MessageSender, ) => boolean; } @@ -96,17 +101,17 @@ describe("PAGE_WORLD_HOOKS table", () => { expect(byId["webdriver-probe-annotate-main-world"]).toMatchObject({ scriptFile: "webdriver-probe.js", allFrames: false, - inject: { messageType: "inject-webdriver-probe" }, + inject: { injectType: "webdriver-probe" }, }); expect(byId["checkout-checkbox-sanitize-main-world"]).toMatchObject({ scriptFile: "checkout-checkbox-defense.js", allFrames: true, - inject: { messageType: "inject-checkout-checkbox-defense" }, + inject: { injectType: "checkout-checkbox-defense" }, }); expect(byId["closed-shadow-root-annotate-main-world"]).toMatchObject({ scriptFile: "shadow-root-probe.js", allFrames: true, - inject: { messageType: "inject-shadow-root-probe" }, + inject: { injectType: "shadow-root-probe" }, }); // The dump-trace bridge has no on-demand inject fallback by design. expect(byId["dump-trace-bridge-main-world"]).toMatchObject({ @@ -174,7 +179,7 @@ describe("dispatchPageWorldInject", () => { it("routes a known inject message to executeScript and returns true", async () => { const module = await loadModule({}); - const handled = module.dispatchPageWorldInject("inject-shadow-root-probe", { + const handled = module.dispatchPageWorldInject("shadow-root-probe", { tab: { id: 4 }, frameId: 0, } as chrome.runtime.MessageSender); @@ -189,12 +194,17 @@ describe("dispatchPageWorldInject", () => { ); }); - it("returns false for an unrecognized message and does not inject", async () => { + it("returns false for an unrecognized inject kind and does not inject", async () => { const module = await loadModule({}); - const handled = module.dispatchPageWorldInject("rule-count", { - tab: { id: 4 }, - } as chrome.runtime.MessageSender); + // Out-of-contract value — the background validates against `injectTypeSchema` + // before calling, but the dispatcher is defensive about unknown keys. + const handled = module.dispatchPageWorldInject( + "rule-count" as PageWorldInjectType, + { + tab: { id: 4 }, + } as chrome.runtime.MessageSender, + ); expect(handled).toBe(false); expect(chrome.scripting.executeScript).not.toHaveBeenCalled(); @@ -203,9 +213,12 @@ describe("dispatchPageWorldInject", () => { it("does not expose an inject route for the dump-trace bridge", async () => { const module = await loadModule({}); - const handled = module.dispatchPageWorldInject("inject-dump-trace-bridge", { - tab: { id: 4 }, - } as chrome.runtime.MessageSender); + const handled = module.dispatchPageWorldInject( + "dump-trace-bridge" as PageWorldInjectType, + { + tab: { id: 4 }, + } as chrome.runtime.MessageSender, + ); expect(handled).toBe(false); }); diff --git a/extension/src/lib/__tests__/rule-count.test.ts b/extension/src/lib/__tests__/rule-count.test.ts index f2242df..875226c 100644 --- a/extension/src/lib/__tests__/rule-count.test.ts +++ b/extension/src/lib/__tests__/rule-count.test.ts @@ -6,13 +6,21 @@ // payload on pagehide. Throttling is covered with jest fake timers — the // 250 ms window is exercised by advancing the timer between mutations. +// The reporter emits via the typed `lib/messenger` wrappers. Mock the module +// so the suite asserts on the `reportRuleCounts(counts)` argument and so the +// real `webext-messenger` never loads in jsdom. `reportDebugTraceEvent` is also +// mocked because `installDebugTraceStub` reads it for the css-only trace +// assertions. +jest.mock("../messenger", () => ({ + reportRuleCounts: jest.fn(), + reportDebugTraceEvent: jest.fn(), +})); + import type { DebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import { installDebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; -import type { - RuleApplicationEvent, - RuleCountMessage, -} from "../detection-messages"; +import type { RuleApplicationEvent } from "../detection-messages"; import { HIDDEN_ATTR, RULE_ATTR } from "../dom-markers"; +import { reportRuleCounts } from "../messenger"; import { registerCssFirstSelectors, startRuleCountReporter, @@ -20,12 +28,11 @@ import { let stub: DebugTraceStub; -function sentMessages(): RuleCountMessage[] { - return stub.sendMessage.mock.calls - .map(([message]) => message as { type: string }) - .filter( - (message): message is RuleCountMessage => message.type === "rule-count", - ); +// The `counts` argument of each `reportRuleCounts` call, in order. +function sentCounts(): Array> { + return (reportRuleCounts as jest.Mock).mock.calls.map( + ([counts]) => counts as Record, + ); } function sentCssOnlyTraces(): RuleApplicationEvent[] { @@ -75,23 +82,19 @@ describe("startRuleCountReporter", () => { stop = startRuleCountReporter(); - const sent = sentMessages(); + const sent = sentCounts(); expect(sent).toHaveLength(1); expect(sent[0]).toEqual({ - type: "rule-count", - counts: { - "pii-redact": 2, - "cookie-banner-hide": 1, - "ads-hide": 1, - }, + "pii-redact": 2, + "cookie-banner-hide": 1, + "ads-hide": 1, }); }); it("sends an empty payload when the document has no markers", () => { stop = startRuleCountReporter(); - const sent = sentMessages(); - expect(sent).toEqual([{ type: "rule-count", counts: {} }]); + expect(sentCounts()).toEqual([{}]); }); it("dedups identical re-counts so quiet pages send one message", async () => { @@ -106,7 +109,7 @@ describe("startRuleCountReporter", () => { await Promise.resolve(); jest.advanceTimersByTime(300); - const sent = sentMessages(); + const sent = sentCounts(); expect(sent).toHaveLength(1); }); @@ -118,9 +121,9 @@ describe("startRuleCountReporter", () => { await Promise.resolve(); jest.advanceTimersByTime(300); - const sent = sentMessages(); + const sent = sentCounts(); expect(sent).toHaveLength(2); - expect(sent.at(-1)?.counts).toEqual({ "pii-redact": 2 }); + expect(sent.at(-1)).toEqual({ "pii-redact": 2 }); }); it("emits cssOnly trace events for CSS-first matches when the trace toggle is on", () => { @@ -188,7 +191,6 @@ describe("startRuleCountReporter", () => { globalThis.dispatchEvent(new Event("pagehide")); - const sent = sentMessages(); - expect(sent.at(-1)).toEqual({ type: "rule-count", counts: {} }); + expect(sentCounts().at(-1)).toEqual({}); }); }); diff --git a/extension/src/lib/__tests__/segment-tracker.test.ts b/extension/src/lib/__tests__/segment-tracker.test.ts index 5b3ca37..12d6827 100644 --- a/extension/src/lib/__tests__/segment-tracker.test.ts +++ b/extension/src/lib/__tests__/segment-tracker.test.ts @@ -6,6 +6,12 @@ // trace toggle is forced on for these tests so the recorder emits — the // trace's gating behavior itself is covered in debug-trace.test.ts. +// The recorder emits via the typed `lib/messenger` wrapper; mock it so +// `installDebugTraceStub` can read the captured entries off a jest mock. +jest.mock("../messenger", () => ({ + reportDebugTraceEvent: jest.fn(), +})); + import type { DebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import { installDebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import type { SegmentMarker } from "../detection-messages"; diff --git a/extension/src/lib/__tests__/trace-mutation.test.ts b/extension/src/lib/__tests__/trace-mutation.test.ts index 03b901d..3dab3f6 100644 --- a/extension/src/lib/__tests__/trace-mutation.test.ts +++ b/extension/src/lib/__tests__/trace-mutation.test.ts @@ -3,10 +3,17 @@ // Covers the shared `traceMutation` wrapper that ~24 rules now route their // DOM mutations through. Verifies: gate-off short-circuit (no serialization -// or sendMessage), before/after `outerHTML` reflects the mutation, the +// or emission), before/after `outerHTML` reflects the mutation, the // `captureFrom` override is honored for sibling-add patterns, and the // auto-derived selector falls back to the element's tag#id.class shape. +// The recorder emits via the typed `lib/messenger` wrapper; mock it so the +// real `webext-messenger` never loads in jsdom and `installDebugTraceStub` can +// read the captured entries. +jest.mock("../messenger", () => ({ + reportDebugTraceEvent: jest.fn(), +})); + import type { DebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import { installDebugTraceStub } from "../../__test-mocks__/debug-trace-stub"; import type { RuleApplicationEvent } from "../detection-messages"; @@ -49,7 +56,7 @@ describe("traceMutation", () => { expect(result).toBe("done"); expect(target.textContent).toBe(""); - expect(stub.sendMessage).not.toHaveBeenCalled(); + expect(stub.events).not.toHaveBeenCalled(); }); it("captures outerHTML before and after when the toggle is on", () => { diff --git a/extension/src/lib/debug-trace.ts b/extension/src/lib/debug-trace.ts index b7cf16c..76f200a 100644 --- a/extension/src/lib/debug-trace.ts +++ b/extension/src/lib/debug-trace.ts @@ -29,10 +29,10 @@ import { createChromeStorageValue } from "./chrome-storage-value"; import type { DebugTraceEntry, - DebugTraceEventMessage, RuleApplicationKind, SegmentKind, } from "./detection-messages"; +import { reportDebugTraceEvent } from "./messenger"; export const DEBUG_TRACE_ENABLED_DEFAULT = false; @@ -94,15 +94,8 @@ export function initDebugTrace(): Promise { } function send(entry: DebugTraceEntry): void { - const message: DebugTraceEventMessage = { - type: "debug-trace-event", - entry, - }; - // Service worker may be asleep or not yet ready — swallow the rejection - // exactly like `rule-count.ts:128`. - chrome.runtime.sendMessage(message).catch(() => { - // noop - }); + // Fire-and-forget; a sleeping or not-yet-ready service worker just drops it. + reportDebugTraceEvent(entry); } export function isDebugTraceEnabled(): boolean { diff --git a/extension/src/lib/detection-messages.ts b/extension/src/lib/detection-messages.ts index 9e6248d..3dc28d4 100644 --- a/extension/src/lib/detection-messages.ts +++ b/extension/src/lib/detection-messages.ts @@ -1,13 +1,15 @@ // Copyright (c) 2026 PixieBrix, Inc. // Licensed under PolyForm Shield 1.0.0 — see LICENSE. -// Shared payload + message types for surfacing rule detections to the -// popup. Rules that hide their findings in the a11y tree (sr-only -// landmarks) emit a `rule-detection` message at the same moment they -// stamp the landmark; the background keeps a per-tab record so the popup -// can render a human-visible "Detected on this page" list. Imported by -// the rule modules (content world), the background service worker, and -// the popup — keep this file dependency-free so it stays trivially +// Shared payload + response shapes for the cross-context messages defined in +// `lib/messenger.ts`. Rules that hide their findings in the a11y tree (sr-only +// landmarks) report a detection at the same moment they stamp the landmark; the +// background keeps a per-tab record so the popup can render a human-visible +// "Detected on this page" list. The message *names* and dispatch live in +// `messenger.ts`; this file is just the data carried by those calls. +// +// Imported by the rule modules (content world), the background service worker, +// and the popup — keep this file dependency-free so it stays trivially // bundleable into each of those three entry points. import type { RuleId } from "../rules/rule-metadata"; @@ -44,73 +46,21 @@ export type DetectionPayload = | WebdriverProbeDetectionPayload | ClosedShadowRootDetectionPayload; -export interface RuleDetectionMessage { - type: "rule-detection"; - payload: DetectionPayload; -} - -export interface GetTabDetectionsRequest { - type: "get-tab-detections"; - tabId: number; -} - -export interface GetTabDetectionsResponse { - detections: DetectionPayload[]; -} - -// Per-frame, per-rule footprint reported by the content script. The shape is -// a partial record so a frame with no activity sends an empty object — the -// background uses that as the cue to drop the frame's entry, mirroring the -// `count <= 0` cleanup the previous single-number reporter used. Keys are -// `RuleId` values; bad keys are filtered by the background. -export interface RuleCountMessage { - type: "rule-count"; - counts: Partial>; -} - -export interface GetTabRuleCountsRequest { - type: "get-tab-rule-counts"; - tabId: number; -} - export interface RuleCountEntry { ruleId: RuleId; count: number; } -// Combined snapshot for the popup. Folds frame-summed redaction/annotation -// counts (`entries`, sorted by count desc) with the existing one-shot -// detection payloads (`detections`) so the popup makes a single round-trip -// to render both the per-rule list and the rich "Heads up" cards. +// Combined snapshot for the popup, returned by the `getTabRuleCounts` method. +// Folds frame-summed redaction/annotation counts (`entries`, sorted by count +// desc) with the one-shot detection payloads (`detections`) so the popup makes +// a single round-trip to render both the per-rule list and the rich "Heads up" +// cards. export interface GetTabRuleCountsResponse { entries: RuleCountEntry[]; detections: DetectionPayload[]; } -// Content-script ↔ background messages for the tab-scoped enforcement pause -// (ADR-0019). The pause lives in `chrome.storage.session` keyed by tabId — -// which content scripts can't read and is keyed by a tabId they don't know — so -// the background resolves liveness for them. `get-tab-pause` is the init fetch -// a content script makes when its rule engine starts; `tab-pause-changed` is -// the background's push when the popup edits the pause, so a still-open page -// reveals (or, on manual resume, re-enforces) without waiting for a reload. -export interface GetTabPauseRequest { - type: "get-tab-pause"; -} - -export interface GetTabPauseResponse { - // Resolved liveness for the requesting frame's tab — the background applies - // the `expiresAt` check so the content side never sees the raw record. - paused: boolean; -} - -export interface TabPauseChangedMessage { - type: "tab-pause-changed"; - // New resolved liveness for the tab, carried in the message so the content - // script needn't round-trip back for the value. - paused: boolean; -} - // Dev-mode structured trace of every rule-driven mutation. Captured only // when the user enables the "Debug trace" toggle in the popup; gated at // emission by `debug-trace.ts` so a disabled toggle drops the work @@ -212,11 +162,6 @@ export type DebugTraceEntry = | ({ type: "rule-application" } & RuleApplicationEvent) | ({ type: "navigation" } & NavigationEvent); -export interface DebugTraceEventMessage { - type: "debug-trace-event"; - entry: DebugTraceEntry; -} - // Stored shape returned by `getEventsForTab`. Mirrors the `StoredEvent` // interface in `debug-trace-store.ts`; kept structurally parallel here // (instead of importing) so this messages module stays free of the IDB @@ -228,17 +173,9 @@ export interface DebugTraceStoredEntry { entry: DebugTraceEntry; } -// Page-world bridge → background round-trip for `window.__abs_dumpTrace`. -// The tabId comes from `sender.tab?.id` in the background — there is no -// cross-tab caller, so the request body is empty other than the type -// discriminator. -export interface GetTabDebugTraceRequest { - type: "get-tab-debug-trace"; -} - -// Flat on-wire shape returned to the page world. Defined in -// `lib/debug-trace-export.ts` so this messages module stays free of -// the wire-shape helper. +// Flat on-wire shape returned to the page world by the `getTabDebugTrace` +// method. Defined in `lib/debug-trace-export.ts` so this messages module stays +// free of the wire-shape helper. export interface GetTabDebugTraceResponse { entries: Array; } diff --git a/extension/src/lib/dump-trace-content-bridge.ts b/extension/src/lib/dump-trace-content-bridge.ts index 164c4b4..8619952 100644 --- a/extension/src/lib/dump-trace-content-bridge.ts +++ b/extension/src/lib/dump-trace-content-bridge.ts @@ -15,11 +15,8 @@ // // Started from `content.ts` inside the existing `isTopFrame()` block. -import type { - GetTabDebugTraceRequest, - GetTabDebugTraceResponse, -} from "./detection-messages"; import { log } from "./log"; +import { getTabDebugTrace } from "./messenger"; // Protocol constants — kept as module-scope literals here. The MAIN-world // source mirrors them inline (no module imports cross the world @@ -64,24 +61,16 @@ function postResponse( } async function forwardRequest(id: string): Promise { - const request: GetTabDebugTraceRequest = { type: "get-tab-debug-trace" }; - let response: GetTabDebugTraceResponse | undefined; try { - response = await chrome.runtime.sendMessage< - GetTabDebugTraceRequest, - GetTabDebugTraceResponse | undefined - >(request); + // The background resolves a missing `sender.tab?.id` to `{ entries: [] }`, + // so the only failure here is an unreachable worker (after the messenger's + // own retries), which throws. + const response = await getTabDebugTrace(); + postResponse(id, { entries: response.entries }); } catch (error) { log.warn("dump-trace bridge: background request failed", { error }); postResponse(id, { error: String(error) }); - return; } - // chrome.runtime.sendMessage resolves with `undefined` when the - // listener returns without calling sendResponse — that happens in - // the background's `get-tab-debug-trace` branch if sender.tab?.id is - // missing (rare, but possible for off-tab callers). Treat that as an - // empty trace rather than crashing the page-world promise. - postResponse(id, { entries: response?.entries ?? [] }); } function bridgeListener(event: MessageEvent): void { diff --git a/extension/src/lib/effective-enforcement.ts b/extension/src/lib/effective-enforcement.ts index 9c7cee0..ed695e5 100644 --- a/extension/src/lib/effective-enforcement.ts +++ b/extension/src/lib/effective-enforcement.ts @@ -24,13 +24,9 @@ // match is evaluated against the tab's top-frame URL and subframes // inherit. -import type { - GetTabPauseRequest, - GetTabPauseResponse, - TabPauseChangedMessage, -} from "./detection-messages"; import { enforcementStorage, subscribeEnforcementEnabled } from "./enforcement"; import { isTopFrame } from "./frame"; +import { getTabPause, getTabUrl, registerMethods } from "./messenger"; import { matchesDenylist, siteDenylistStorage } from "./site-denylist"; let cachedTopFrameUrl: string | null = null; @@ -89,46 +85,40 @@ async function fetchTopFrameUrl(): Promise { return null; } try { - const response: unknown = await chrome.runtime.sendMessage({ - type: "get-tab-url", - }); - if ( - response && - typeof response === "object" && - "url" in response && - typeof response.url === "string" - ) { - return response.url; - } + return await getTabUrl(); } catch { // Background may be asleep / restarting / unreachable. Fall through // to "unknown URL", which `computeEffective` interprets as fail-open. + return null; } - return null; } // Unlike the URL fetch, every frame asks — the pause applies to the whole tab, // and the background resolves it from `sender.tab.id` regardless of frame. async function fetchTabPaused(): Promise { try { - const response: unknown = await chrome.runtime.sendMessage({ - type: "get-tab-pause", - } satisfies GetTabPauseRequest); - if ( - response && - typeof response === "object" && - "paused" in response && - typeof (response as GetTabPauseResponse).paused === "boolean" - ) { - return (response as GetTabPauseResponse).paused; - } + return await getTabPause(); } catch { // Background unreachable → assume not paused (fail-open to protected), // matching the URL fetch's posture. + return false; } - return false; } +// The background pushes the tab's pause liveness to every frame on each popup +// edit (reveal/pause/snooze, or "Resume now"), so a still-open page reveals or +// re-enforces without a reload. A *timed* snooze expiring produces no push, so +// the open page stays revealed until its next navigation re-reads fresh state. +// Registered once at module load (rather than inside `initEffectiveEnforcement`) +// so a never-missed push doesn't depend on init timing and a re-init can't +// double-register. +registerMethods({ + setTabPause(paused: boolean) { + cachedTabPaused = paused; + notify(); + }, +}); + // Resolves to the current effective enforcement boolean and installs the // underlying storage subscriptions. Idempotent: callers (rule-engine) only // call this once, but a second call would re-fetch + re-subscribe without @@ -155,20 +145,8 @@ export async function initEffectiveEnforcement(): Promise { cachedDenylist = next; notify(); }); - // The background pushes the tab's pause liveness on every popup edit. Each - // frame listens independently so its own rule engine reveals / re-applies. - chrome.runtime.onMessage.addListener((message: unknown) => { - if ( - message && - typeof message === "object" && - (message as { type?: unknown }).type === "tab-pause-changed" && - typeof (message as TabPauseChangedMessage).paused === "boolean" - ) { - cachedTabPaused = (message as TabPauseChangedMessage).paused; - notify(); - } - return undefined; - }); + // The `setTabPause` push handler is registered at module load (above), so + // it's already live before this init resolves. return lastEffective; } diff --git a/extension/src/lib/message-schemas.ts b/extension/src/lib/message-schemas.ts new file mode 100644 index 0000000..35e3e3e --- /dev/null +++ b/extension/src/lib/message-schemas.ts @@ -0,0 +1,159 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Runtime validation for every message the background worker receives from a +// content script. This is the trust boundary: a content script reports data it +// derived from a hostile page (detection host/url, rule footprints, debug +// traces), so the worker decodes each payload through a `zod` schema before it +// touches the popup-facing maps or IndexedDB. A malformed or attacker-shaped +// payload is dropped, not recorded. +// +// Imported ONLY by `background.ts`. `zod` deliberately stays out of the shared +// `messenger.ts` contract so it never lands in the content-script or popup +// bundles — the senders pass typed payloads; the worker is the only context +// that validates. + +import { z } from "zod"; +import type { RuleId } from "../rules/rule-metadata"; +import { RULE_IDS } from "../rules/rule-metadata"; +import type { DetectionPayload } from "./detection-messages"; +import { log } from "./log"; +import type { MessengerMeta, PageWorldInjectType } from "./messenger"; + +const KNOWN_RULE_IDS = new Set(RULE_IDS); + +// Bidirectional `extends` ≈ structural equality — a drift guard that fails the +// build if a schema and the hand-written wire type stop agreeing. +type AssertExtends = A extends B ? true : never; + +// ── rule-detection payload (discriminated union mirrors DetectionPayload) ── +const detectionPayloadSchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("roach-motel"), + host: z.string(), + url: z.string(), + difficulty: z.enum(["hard", "very-hard", "impossible"]), + cancellationUrl: z.string().nullable(), + source: z.enum(["curated", "justdeleteme"]), + }), + z.object({ + kind: z.literal("webdriver-probe"), + host: z.string(), + url: z.string(), + }), + z.object({ + kind: z.literal("closed-shadow-root"), + host: z.string(), + url: z.string(), + }), +]); +// Fails to compile if `DetectionPayload` and the schema diverge in either +// direction (a new kind, a renamed field, a widened enum). +type _DetectionParityA = AssertExtends< + z.infer, + DetectionPayload +>; +type _DetectionParityB = AssertExtends< + DetectionPayload, + z.infer +>; + +// ── per-frame rule counts ── +// Preserves the old hand-rolled sanitization exactly: drop unknown rule ids and +// non-positive / non-finite counts, floor the rest, so a misbehaving content +// script can't poison the badge or popup. Expressed as a lenient outer decode +// (any object) plus a transform, so one bad key drops only that entry rather +// than the whole report. +export type RuleCountMap = Partial>; +const ruleCountsSchema = z + .record(z.string(), z.unknown()) + .transform((raw): RuleCountMap => { + const sanitized: RuleCountMap = {}; + for (const [key, value] of Object.entries(raw)) { + if ( + KNOWN_RULE_IDS.has(key) && + typeof value === "number" && + Number.isFinite(value) && + value > 0 + ) { + sanitized[key as RuleId] = Math.floor(value); + } + } + return sanitized; + }); + +// ── debug-trace event ── +// Dev-only (gated behind the debug-trace toggle), but still content→worker, so +// it gets the same structural decode before an IndexedDB write. +const debugTraceEntrySchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("segment"), + segmentId: z.number(), + kind: z.enum([ + "initial-load", + "route-change", + "modal-open", + "mutation-burst", + ]), + timestamp: z.number(), + meta: z.record(z.string(), z.union([z.string(), z.number()])), + }), + z.object({ + type: z.literal("rule-application"), + segmentId: z.number(), + ruleId: z.string(), + kind: z.enum(["hide", "mask", "strip", "sanitize", "flag", "embed"]), + timestamp: z.number(), + selector: z.string(), + beforeHtml: z.string(), + afterHtml: z.string(), + beforeText: z.string().optional(), + cssOnly: z.boolean().optional(), + }), + z.object({ + type: z.literal("navigation"), + url: z.string().nullable(), + timestamp: z.number(), + }), +]); + +// ── page-world inject request ── +const injectTypeSchema: z.ZodType = z.enum([ + "webdriver-probe", + "checkout-checkbox-defense", + "shadow-root-probe", +]); + +// ── popup-supplied tab id ── +// The popup is an extension page (trusted), but the round-trip still carries a +// value, so we decode it; an invalid id resolves to "no such tab" upstream. +const tabIdSchema = z.number().int().nonnegative(); + +// Wrap a notification handler in a decode step. On a schema mismatch the +// payload is logged and dropped (the old branches silently ignored malformed +// messages); on success the parsed/sanitized value and the sender metadata are +// handed to the inner handler. +function validatedNotification( + schema: Schema, + handler: (payload: z.infer, meta: MessengerMeta) => void, +): (this: MessengerMeta, raw: z.input) => void { + return function (raw) { + const result = schema.safeParse(raw); + if (!result.success) { + log.warn("dropped invalid message payload", { + issues: result.error.issues, + }); + return; + } + handler(result.data, this); + }; +} + +export { + debugTraceEntrySchema, + detectionPayloadSchema, + injectTypeSchema, + ruleCountsSchema, + tabIdSchema, + validatedNotification, +}; diff --git a/extension/src/lib/messenger.ts b/extension/src/lib/messenger.ts new file mode 100644 index 0000000..cc41a97 --- /dev/null +++ b/extension/src/lib/messenger.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Typed message router built on `webext-messenger`. Replaces the hand-rolled +// `chrome.runtime.onMessage` switch the background worker used to dispatch a +// dozen `{ type: "..." }` envelopes: every cross-context call is now a named, +// statically-typed method. A renamed or mistyped message is a compile error, +// not a silent runtime no-op. +// +// This module is the SHARED contract — imported by content scripts, the popup, +// and the background worker. It deliberately carries NO `zod` dependency so it +// stays cheap to bundle into the content script (which loads in every frame of +// every page). Runtime payload validation lives in the background-only +// `message-schemas.ts` and is applied where each handler is registered — the +// trust boundary is the page→worker hop, and the worker is where we validate. +// +// Direction conventions encoded below: +// - content/popup → background: senders target `backgroundTarget`. +// - background → content: `notifyTabPause` targets the tab's frames. + +import type { MessengerMeta, Target } from "webext-messenger"; +import { backgroundTarget, getMethod, getNotifier } from "webext-messenger"; +import type { + DebugTraceEntry, + DetectionPayload, + GetTabDebugTraceResponse, + GetTabRuleCountsResponse, +} from "./detection-messages"; + +export type { MessengerMeta } from "webext-messenger"; +// Re-exported so the background and content registration sites pull the +// messenger surface from one place rather than reaching into the library. +export { backgroundTarget, registerMethods } from "webext-messenger"; + +// The page-world `inject-*` fallbacks a content rule can request when it is +// toggled on mid-session (see `lib/page-world-hooks.ts`). Kept as bare kinds; +// the background validates the incoming value against this exact set before +// dispatching to an installer. +export type PageWorldInjectType = + | "webdriver-probe" + | "checkout-checkbox-defense" + | "shadow-root-probe"; + +// Raw per-frame rule footprint exactly as the content reporter sends it. The +// background sanitizes it against `KNOWN_RULE_IDS` (and floors / drops +// non-positive counts) before recording — see `ruleCountsSchema`. +export type RawRuleCounts = Record; + +// The method contract. Signatures keep an explicit `this: MessengerMeta` so a +// handler that reads its sender (`this.trace[0]`) assigns cleanly here; the +// library strips `this` and promisifies the return for callers. +declare global { + interface MessengerMethods { + // ── content → background (notifications; page-derived, validated in bg) ── + recordDetection: (this: MessengerMeta, payload: DetectionPayload) => void; + reportRuleCounts: (this: MessengerMeta, counts: RawRuleCounts) => void; + reportDebugTraceEvent: ( + this: MessengerMeta, + entry: DebugTraceEntry, + ) => void; + requestPageWorldInject: ( + this: MessengerMeta, + injectType: PageWorldInjectType, + ) => void; + // ── content/popup → background (request/response) ── + // Return types mirror each handler: async handlers (storage / IDB reads) + // resolve a Promise; the library promisifies the rest for callers anyway. + getTabUrl: (this: MessengerMeta) => string | null; + getTabPause: (this: MessengerMeta) => Promise; + getTabRuleCounts: ( + this: MessengerMeta, + tabId: number, + ) => GetTabRuleCountsResponse; + getTabDebugTrace: ( + this: MessengerMeta, + ) => Promise; + openOptions: (this: MessengerMeta) => Promise<{ ok: true }>; + // ── background → content (notification, broadcast to every frame) ── + setTabPause: (this: MessengerMeta, paused: boolean) => void; + } +} + +// ── content/popup → background senders ── +// Notifiers are fire-and-forget: no response is awaited, no retries, errors are +// swallowed — matching the old `sendMessage(...).catch(() => {})` posture. +export const recordDetection = getNotifier("recordDetection", backgroundTarget); +export const reportRuleCounts = getNotifier( + "reportRuleCounts", + backgroundTarget, +); +export const reportDebugTraceEvent = getNotifier( + "reportDebugTraceEvent", + backgroundTarget, +); +export const requestPageWorldInject = getNotifier( + "requestPageWorldInject", + backgroundTarget, +); +// Methods round-trip: the library retries a not-yet-awake worker for a few +// seconds, then rejects — callers already fall back to an empty state on throw. +export const getTabUrl = getMethod("getTabUrl", backgroundTarget); +export const getTabPause = getMethod("getTabPause", backgroundTarget); +export const getTabRuleCounts = getMethod("getTabRuleCounts", backgroundTarget); +export const getTabDebugTrace = getMethod("getTabDebugTrace", backgroundTarget); +export const openOptions = getMethod("openOptions", backgroundTarget); + +// ── background → content sender ── +// The background pushes a tab's recovery-pause liveness to every frame on a +// popup edit (ADR-0019). `frameId: "allFrames"` broadcasts via +// `tabs.sendMessage`; content scripts always handle messenger messages routed +// to them, so each frame's `setTabPause` handler fires without a frame-id +// round-trip. +const notifyTabPauseInternal = getNotifier("setTabPause"); +export function notifyTabPause(tabId: number, paused: boolean): void { + notifyTabPauseInternal( + { tabId, frameId: "allFrames" } satisfies Target, + paused, + ); +} diff --git a/extension/src/lib/options-badge.ts b/extension/src/lib/options-badge.ts index 80d1b40..591adcd 100644 --- a/extension/src/lib/options-badge.ts +++ b/extension/src/lib/options-badge.ts @@ -2,6 +2,7 @@ // Licensed under PolyForm Shield 1.0.0 — see LICENSE. import { log } from "./log"; +import { openOptions } from "./messenger"; import { optionsButtonStorage } from "./options-button-toggle"; const BADGE_SELECTOR = "data-abs"; @@ -52,11 +53,9 @@ function injectBadge(): void { badge.style.opacity = "0.65"; }); badge.addEventListener("click", () => { - chrome.runtime - .sendMessage({ type: "open-options" }) - .catch((error: unknown) => { - log.error("failed to open options page", error); - }); + openOptions().catch((error: unknown) => { + log.error("failed to open options page", error); + }); }); document.body.append(badge); diff --git a/extension/src/lib/page-world-hooks.ts b/extension/src/lib/page-world-hooks.ts index 397694a..a956452 100644 --- a/extension/src/lib/page-world-hooks.ts +++ b/extension/src/lib/page-world-hooks.ts @@ -23,6 +23,7 @@ import { getEnforcementEnabled, subscribeEnforcementEnabled, } from "./enforcement"; +import type { PageWorldInjectType } from "./messenger"; import type { PageWorldHookConfig } from "./page-world-hook"; import { createPageWorldHook, injectPageWorldScript } from "./page-world-hook"; import { installShadowRootProbe } from "./shadow-root-probe-source"; @@ -47,13 +48,14 @@ const RULE_SUBSCRIPTIONS = [subscribe, subscribeEnforcementEnabled]; interface PageWorldHookEntry extends PageWorldHookConfig { /** - * Optional on-demand fallback: the `inject-*` message the rule's `apply` - * sends so the already-open tab picks up the script without a reload. - * Absent for the dump-trace bridge, whose trace recorder also only starts - * collecting on the next navigation, so a reload is already implied. + * Optional on-demand fallback: the page-world inject the rule's `apply` + * requests (via `requestPageWorldInject`) so the already-open tab picks up + * the script without a reload. Absent for the dump-trace bridge, whose trace + * recorder also only starts collecting on the next navigation, so a reload is + * already implied. */ readonly inject?: { - readonly messageType: string; + readonly injectType: PageWorldInjectType; readonly installFn: (this: Window) => void; }; } @@ -71,7 +73,7 @@ export const PAGE_WORLD_HOOKS: readonly PageWorldHookEntry[] = [ shouldRegister: ruleEnabledAndEnforced("webdriver-probe-annotate"), subscribe: RULE_SUBSCRIPTIONS, inject: { - messageType: "inject-webdriver-probe", + injectType: "webdriver-probe", installFn: installProbe, }, }, @@ -89,7 +91,7 @@ export const PAGE_WORLD_HOOKS: readonly PageWorldHookEntry[] = [ shouldRegister: ruleEnabledAndEnforced("checkout-checkbox-sanitize"), subscribe: RULE_SUBSCRIPTIONS, inject: { - messageType: "inject-checkout-checkbox-defense", + injectType: "checkout-checkbox-defense", installFn: installCheckoutCheckboxDefense, }, }, @@ -105,7 +107,7 @@ export const PAGE_WORLD_HOOKS: readonly PageWorldHookEntry[] = [ shouldRegister: ruleEnabledAndEnforced("closed-shadow-root-annotate"), subscribe: RULE_SUBSCRIPTIONS, inject: { - messageType: "inject-shadow-root-probe", + injectType: "shadow-root-probe", installFn: installShadowRootProbe, }, }, @@ -128,9 +130,9 @@ export const PAGE_WORLD_HOOKS: readonly PageWorldHookEntry[] = [ const hooks = PAGE_WORLD_HOOKS.map((entry) => createPageWorldHook(entry)); -const injectorsByMessageType = new Map( +const injectorsByType = new Map( PAGE_WORLD_HOOKS.flatMap((entry) => - entry.inject ? [[entry.inject.messageType, entry.inject] as const] : [], + entry.inject ? [[entry.inject.injectType, entry.inject] as const] : [], ), ); @@ -142,17 +144,18 @@ export function startPageWorldHooks(): void { } } -// Route an `inject-*` message to its page-world installer. Returns true when -// `messageType` is a recognized inject request (and the injection was kicked -// off), false otherwise so the caller's onMessage chain continues. +// Route a validated page-world inject request to its installer. The background +// only calls this after `injectTypeSchema` has confirmed `injectType` is one of +// the known kinds, so an unrecognized value here is a programming error, not +// untrusted input — it returns false and is otherwise a no-op. export function dispatchPageWorldInject( - messageType: string, + injectType: PageWorldInjectType, sender: chrome.runtime.MessageSender, ): boolean { - const injector = injectorsByMessageType.get(messageType); + const injector = injectorsByType.get(injectType); if (!injector) { return false; } - injectPageWorldScript(sender, injector.installFn, injector.messageType); + injectPageWorldScript(sender, injector.installFn, injector.injectType); return true; } diff --git a/extension/src/lib/rule-count.ts b/extension/src/lib/rule-count.ts index a53aaf9..4aec60a 100644 --- a/extension/src/lib/rule-count.ts +++ b/extension/src/lib/rule-count.ts @@ -25,8 +25,8 @@ import throttle from "lodash/throttle"; import { isDebugTraceEnabled, recordRuleApplication } from "./debug-trace"; -import type { RuleCountMessage } from "./detection-messages"; import { HIDDEN_ATTR, RULE_ATTR } from "./dom-markers"; +import { reportRuleCounts } from "./messenger"; const COUNT_SELECTOR = `[${RULE_ATTR}], [${HIDDEN_ATTR}]`; const REPORT_THROTTLE_MS = 250; @@ -154,16 +154,9 @@ function shallowEqual( } function send(counts: Record): void { - const message: RuleCountMessage = { - type: "rule-count", - counts, - }; - // Service worker may be asleep / receiver not yet ready — swallow the - // resulting "Receiving end does not exist" rejection so it doesn't surface - // as an unhandled promise warning on every page load. - chrome.runtime.sendMessage(message).catch(() => { - // noop - }); + // Fire-and-forget; a sleeping service worker (or a receiver not yet ready) + // just drops it — no "Receiving end does not exist" rejection to swallow. + reportRuleCounts(counts); } export function startRuleCountReporter(): () => void { diff --git a/extension/src/popup/use-tab-detections.ts b/extension/src/popup/use-tab-detections.ts index 979eb26..a2ef5ed 100644 --- a/extension/src/popup/use-tab-detections.ts +++ b/extension/src/popup/use-tab-detections.ts @@ -4,10 +4,9 @@ import { useEffect, useState } from "react"; import type { DetectionPayload, - GetTabRuleCountsRequest, - GetTabRuleCountsResponse, RuleCountEntry, } from "../lib/detection-messages"; +import { getTabRuleCounts } from "../lib/messenger"; export interface TabActivity { entries: RuleCountEntry[]; @@ -51,16 +50,9 @@ async function fetchActivity(): Promise { if (typeof tab?.id !== "number") { return { entries: [], detections: [] }; } - const request: GetTabRuleCountsRequest = { - type: "get-tab-rule-counts", - tabId: tab.id, - }; - const response = await chrome.runtime.sendMessage< - GetTabRuleCountsRequest, - GetTabRuleCountsResponse | undefined - >(request); + const response = await getTabRuleCounts(tab.id); return { - entries: response?.entries ?? [], - detections: response?.detections ?? [], + entries: response.entries, + detections: response.detections, }; } diff --git a/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts b/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts index 48c5a6e..1a06cbe 100644 --- a/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts +++ b/extension/src/rules/__tests__/checkout-checkbox-sanitize.test.ts @@ -2,11 +2,22 @@ * @jest-environment jsdom * @jest-environment-options {"url": "https://shop.example.com/checkout"} */ +// The rule requests page-world injection via the typed `lib/messenger` wrapper; +// mock the module so the suite asserts on +// `requestPageWorldInject("checkout-checkbox-defense")` and the real +// `webext-messenger` never loads in jsdom. +jest.mock("../../lib/messenger", () => ({ + requestPageWorldInject: jest.fn(), +})); + import { installCheckoutCheckboxDefense } from "../../lib/checkout-checkbox-defense-source"; import { isCheckoutUrl } from "../../lib/checkout-url"; import { CHECKOUT_CHECKBOX_CLEARED_ATTR as CLEARED_ATTR } from "../../lib/dom-markers"; +import { requestPageWorldInject } from "../../lib/messenger"; import { checkoutCheckboxSanitizeRule } from "../checkout-checkbox-sanitize"; +const requestInjectMock = requestPageWorldInject as jest.Mock; + const MUTATION_THROTTLE_MS = 250; async function flushMutations(): Promise { @@ -116,24 +127,16 @@ describe("checkoutCheckboxSanitizeRule.apply", () => { expect(text.value).toBe("hello"); }); - it("requests page-world defense injection via chrome.runtime.sendMessage", () => { + it("requests page-world defense injection on apply", () => { document.body.innerHTML = ``; - const sendMessage = chrome.runtime.sendMessage as unknown as jest.Mock; - sendMessage.mockReset(); - sendMessage.mockResolvedValue(undefined); checkoutCheckboxSanitizeRule.apply(document.body); - expect(sendMessage).toHaveBeenCalledWith({ - type: "inject-checkout-checkbox-defense", - }); + expect(requestInjectMock).toHaveBeenCalledWith("checkout-checkbox-defense"); }); - it("swallows sendMessage rejections so the rule still scans", async () => { + it("still scans even though the inject request is fire-and-forget", async () => { document.body.innerHTML = ``; - const sendMessage = chrome.runtime.sendMessage as unknown as jest.Mock; - sendMessage.mockReset(); - sendMessage.mockRejectedValue(new Error("no receiver")); checkoutCheckboxSanitizeRule.apply(document.body); await flushMutations(); diff --git a/extension/src/rules/__tests__/closed-shadow-root-annotate.test.ts b/extension/src/rules/__tests__/closed-shadow-root-annotate.test.ts index 958eb47..15ca893 100644 --- a/extension/src/rules/__tests__/closed-shadow-root-annotate.test.ts +++ b/extension/src/rules/__tests__/closed-shadow-root-annotate.test.ts @@ -1,22 +1,23 @@ /** * @jest-environment jsdom */ +// The rule reports via the typed `lib/messenger` wrappers; mock the module so +// the suites assert on `requestPageWorldInject("shadow-root-probe")` and +// `recordDetection({...})` rather than a wire envelope, and so the real +// `webext-messenger` never loads in jsdom. +jest.mock("../../lib/messenger", () => ({ + recordDetection: jest.fn(), + requestPageWorldInject: jest.fn(), +})); + +import { recordDetection, requestPageWorldInject } from "../../lib/messenger"; import { closedShadowRootAnnotateRule } from "../closed-shadow-root-annotate"; const LANDMARK_SELECTOR = 'section[data-abs-rule="closed-shadow-root-annotate"]'; -// chrome.runtime.sendMessage is installed as jest.fn() on globalThis by -// jest-webextension-mock. -const sendMessageMock = chrome.runtime.sendMessage as unknown as jest.Mock; - -function detectionCalls(): unknown[] { - return sendMessageMock.mock.calls - .map(([message]: [unknown]) => message) - .filter( - (message) => (message as { type?: string }).type === "rule-detection", - ); -} +const recordDetectionMock = recordDetection as jest.Mock; +const requestInjectMock = requestPageWorldInject as jest.Mock; // A unique tag name per test avoids the global customElements registry // colliding across cases (defining the same name twice throws). @@ -68,8 +69,6 @@ function mockZeroRect(element: Element): void { beforeEach(() => { document.body.innerHTML = ""; - sendMessageMock.mockReset(); - sendMessageMock.mockResolvedValue(undefined); }); afterEach(() => { @@ -310,11 +309,7 @@ describe("closedShadowRootAnnotateRule main-world probe integration", () => { it("requests probe injection on apply (background fallback for the active tab)", () => { closedShadowRootAnnotateRule.apply(document.body); - const requested = sendMessageMock.mock.calls.some( - ([message]: [unknown]) => - (message as { type?: string }).type === "inject-shadow-root-probe", - ); - expect(requested).toBe(true); + expect(requestInjectMock).toHaveBeenCalledWith("shadow-root-probe"); }); it("does not re-stamp on subsequent probe events (per-document dedupe)", () => { @@ -336,21 +331,18 @@ describe("closedShadowRootAnnotateRule main-world probe integration", () => { expect(document.querySelector(LANDMARK_SELECTOR)).toBeNull(); }); - it("emits exactly one rule-detection when the probe is the trigger", () => { + it("records exactly one detection when the probe is the trigger", () => { document.body.innerHTML = "

plain

"; closedShadowRootAnnotateRule.apply(document.body); - sendMessageMock.mockClear(); + recordDetectionMock.mockClear(); document.dispatchEvent(new CustomEvent("abs:closed-shadow-attached")); - expect(detectionCalls()).toHaveLength(1); - expect(detectionCalls()[0]).toEqual({ - type: "rule-detection", - payload: { - kind: "closed-shadow-root", - host: globalThis.location.hostname, - url: globalThis.location.href, - }, + expect(recordDetectionMock).toHaveBeenCalledTimes(1); + expect(recordDetectionMock).toHaveBeenCalledWith({ + kind: "closed-shadow-root", + host: globalThis.location.hostname, + url: globalThis.location.href, }); }); }); @@ -359,10 +351,10 @@ describe("closedShadowRootAnnotateRule rule-detection emission", () => { it("does not emit on apply when no hosts match", () => { document.body.innerHTML = "

plain content

"; closedShadowRootAnnotateRule.apply(document.body); - expect(detectionCalls()).toHaveLength(0); + expect(recordDetectionMock).not.toHaveBeenCalled(); }); - it("emits exactly one detection on first match", () => { + it("records exactly one detection on first match", () => { const tag = nextTagName(); defineClosedShadowElement(tag); document.body.append(document.createElement(tag)); @@ -370,18 +362,15 @@ describe("closedShadowRootAnnotateRule rule-detection emission", () => { closedShadowRootAnnotateRule.apply(document.body); - expect(detectionCalls()).toHaveLength(1); - expect(detectionCalls()[0]).toEqual({ - type: "rule-detection", - payload: { - kind: "closed-shadow-root", - host: globalThis.location.hostname, - url: globalThis.location.href, - }, + expect(recordDetectionMock).toHaveBeenCalledTimes(1); + expect(recordDetectionMock).toHaveBeenCalledWith({ + kind: "closed-shadow-root", + host: globalThis.location.hostname, + url: globalThis.location.href, }); }); - it("does not re-emit on subsequent apply calls (landmark short-circuit)", () => { + it("does not re-record on subsequent apply calls (landmark short-circuit)", () => { const tag = nextTagName(); defineClosedShadowElement(tag); document.body.append(document.createElement(tag)); @@ -389,11 +378,10 @@ describe("closedShadowRootAnnotateRule rule-detection emission", () => { closedShadowRootAnnotateRule.apply(document.body); closedShadowRootAnnotateRule.apply(document.body); - expect(detectionCalls()).toHaveLength(1); + expect(recordDetectionMock).toHaveBeenCalledTimes(1); }); - it("swallows sendMessage rejections", () => { - sendMessageMock.mockRejectedValueOnce(new Error("no receiver")); + it("does not throw when reporting a detection", () => { const tag = nextTagName(); defineClosedShadowElement(tag); document.body.append(document.createElement(tag)); diff --git a/extension/src/rules/__tests__/roach-motel-annotate.test.ts b/extension/src/rules/__tests__/roach-motel-annotate.test.ts index 25fa38e..cd7eb98 100644 --- a/extension/src/rules/__tests__/roach-motel-annotate.test.ts +++ b/extension/src/rules/__tests__/roach-motel-annotate.test.ts @@ -2,22 +2,23 @@ * @jest-environment jsdom * @jest-environment-options {"url": "https://www.nytimes.com/subscription/all-access"} */ +// Rule's `apply` reports a detection via the typed `lib/messenger` wrapper. +// Mock the module so the suite asserts on the semantic `recordDetection({...})` +// call and the real `webext-messenger` never loads in jsdom. +jest.mock("../../lib/messenger", () => ({ + recordDetection: jest.fn(), +})); + +import { recordDetection } from "../../lib/messenger"; import { hiddenTextStripRule } from "../hidden-text-strip"; import { findWarning, roachMotelAnnotateRule } from "../roach-motel-annotate"; const LANDMARK_SELECTOR = 'section[data-abs-rule="roach-motel-annotate"]'; -// Rule's `apply` sends a `rule-detection` runtime message after landing -// the landmark. jest-webextension-mock provides chrome.runtime.sendMessage -// as a jest.fn() on globalThis.chrome; cast to jest.Mock to expose the -// mock-control surface (assertion against typeof's overloaded signature -// resolves to `never`-arg, which blocks .mockResolvedValue). -const sendMessageMock = chrome.runtime.sendMessage as unknown as jest.Mock; +const recordDetectionMock = recordDetection as jest.Mock; beforeEach(() => { document.body.innerHTML = ""; - sendMessageMock.mockReset(); - sendMessageMock.mockResolvedValue(undefined); }); afterEach(() => { @@ -119,30 +120,27 @@ describe("roachMotelAnnotateRule.apply (on nytimes.com/subscription/all-access)" expect(document.querySelectorAll(LANDMARK_SELECTOR)).toHaveLength(1); }); - it("emits a rule-detection runtime message with the matched payload", () => { + it("records a detection with the matched payload", () => { roachMotelAnnotateRule.apply(document.body); - expect(sendMessageMock).toHaveBeenCalledTimes(1); - expect(sendMessageMock).toHaveBeenCalledWith({ - type: "rule-detection", - payload: { - kind: "roach-motel", - host: "www.nytimes.com", - url: "https://www.nytimes.com/subscription/all-access", - difficulty: "hard", - cancellationUrl: - "https://help.nytimes.com/hc/en-us/articles/115014679508", - source: "curated", - }, + expect(recordDetectionMock).toHaveBeenCalledTimes(1); + expect(recordDetectionMock).toHaveBeenCalledWith({ + kind: "roach-motel", + host: "www.nytimes.com", + url: "https://www.nytimes.com/subscription/all-access", + difficulty: "hard", + cancellationUrl: + "https://help.nytimes.com/hc/en-us/articles/115014679508", + source: "curated", }); }); - it("does not re-emit the runtime message on repeated apply", () => { + it("does not re-record on repeated apply", () => { roachMotelAnnotateRule.apply(document.body); roachMotelAnnotateRule.apply(document.body); roachMotelAnnotateRule.apply(document.body); - expect(sendMessageMock).toHaveBeenCalledTimes(1); + expect(recordDetectionMock).toHaveBeenCalledTimes(1); }); it("teardown removes the landmark", () => { diff --git a/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts b/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts index d177b1f..82c3828 100644 --- a/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts +++ b/extension/src/rules/__tests__/webdriver-probe-annotate.test.ts @@ -1,11 +1,21 @@ /** * @jest-environment jsdom */ +// The rule now reports via the typed `lib/messenger` wrappers instead of raw +// `chrome.runtime.sendMessage`. Mock the module so the suites assert on the +// semantic call (`requestPageWorldInject("webdriver-probe")`, +// `recordDetection({...})`) rather than a wire envelope — and so the real +// `webext-messenger` never loads in jsdom. +jest.mock("../../lib/messenger", () => ({ + recordDetection: jest.fn(), + requestPageWorldInject: jest.fn(), +})); + +import { recordDetection, requestPageWorldInject } from "../../lib/messenger"; import { installProbe } from "../../lib/webdriver-probe-source"; import { hiddenTextStripRule } from "../hidden-text-strip"; import { EVENT_NAME, - INJECT_PROBE_MESSAGE, webdriverProbeAnnotateRule, } from "../webdriver-probe-annotate"; @@ -15,33 +25,15 @@ function dispatchProbe(): void { document.dispatchEvent(new CustomEvent(EVENT_NAME)); } -// chrome.runtime.sendMessage is installed as jest.fn() on globalThis by -// jest-webextension-mock. Cast to jest.Mock for the control surface. -const sendMessageMock = chrome.runtime.sendMessage as unknown as jest.Mock; - -// The rule fires two distinct sendMessage flows: `inject-webdriver-probe` -// on every apply (background-side executeScript fallback) and -// `rule-detection` after a landmark is stamped (popup detections panel). -// Helpers below filter the mock's call log by type so the suites can -// assert on each flow independently. -function callsOfType(type: string): unknown[] { - return sendMessageMock.mock.calls - .map(([message]: [unknown]) => message) - .filter((message) => (message as { type?: string }).type === type); -} - -function injectCalls(): unknown[] { - return callsOfType("inject-webdriver-probe"); -} - -function detectionCalls(): unknown[] { - return callsOfType("rule-detection"); -} +// The rule drives two distinct flows: `requestPageWorldInject` on every apply +// (background-side executeScript fallback) and `recordDetection` after a +// landmark is stamped (popup detections panel). `clearMocks: true` resets both +// between tests. +const requestInjectMock = requestPageWorldInject as jest.Mock; +const recordDetectionMock = recordDetection as jest.Mock; beforeEach(() => { document.body.innerHTML = ""; - sendMessageMock.mockReset(); - sendMessageMock.mockResolvedValue(undefined); }); afterEach(() => { @@ -127,15 +119,15 @@ describe("webdriverProbeAnnotateRule.apply", () => { }); }); -describe("webdriverProbeAnnotateRule inject-probe message", () => { +describe("webdriverProbeAnnotateRule inject-probe request", () => { // The rule's apply asks the background worker to run installProbe on - // the active frame via chrome.scripting.executeScript. The message is + // the active frame via chrome.scripting.executeScript. The request is // the only thing the rule controls; the actual injection is asserted // by inspection at the background-side handler. it("asks the background worker to inject the probe on apply", () => { webdriverProbeAnnotateRule.apply(document.body); - expect(injectCalls()).toHaveLength(1); - expect(injectCalls()[0]).toEqual(INJECT_PROBE_MESSAGE); + expect(requestInjectMock).toHaveBeenCalledTimes(1); + expect(requestInjectMock).toHaveBeenCalledWith("webdriver-probe"); }); // Re-apply after teardown should ask again — the page-world probe is @@ -147,40 +139,34 @@ describe("webdriverProbeAnnotateRule inject-probe message", () => { webdriverProbeAnnotateRule.teardown(); webdriverProbeAnnotateRule.apply(document.body); - expect(injectCalls()).toHaveLength(2); + expect(requestInjectMock).toHaveBeenCalledTimes(2); }); - // The service worker may be asleep when apply runs and reject with - // "Receiving end does not exist". The rule must swallow that so it - // doesn't surface as an unhandled-promise warning. - it("swallows sendMessage rejections", async () => { - sendMessageMock.mockRejectedValueOnce(new Error("no receiver")); + // Resilience to a sleeping service worker now lives in the messenger's + // notifier (fire-and-forget, no rejection), so apply can't throw from the + // request path. + it("does not throw when requesting injection", () => { expect(() => { webdriverProbeAnnotateRule.apply(document.body); }).not.toThrow(); - // Let the microtask flush so an unhandled rejection would surface. - await Promise.resolve(); }); }); describe("webdriverProbeAnnotateRule rule-detection emission", () => { it("does not emit on apply alone — only on observed reads", () => { webdriverProbeAnnotateRule.apply(document.body); - expect(detectionCalls()).toHaveLength(0); + expect(recordDetectionMock).not.toHaveBeenCalled(); }); - it("sends a rule-detection on the first probe event", () => { + it("records a detection on the first probe event", () => { webdriverProbeAnnotateRule.apply(document.body); dispatchProbe(); - expect(detectionCalls()).toHaveLength(1); - expect(detectionCalls()[0]).toEqual({ - type: "rule-detection", - payload: { - kind: "webdriver-probe", - host: globalThis.location.hostname, - url: globalThis.location.href, - }, + expect(recordDetectionMock).toHaveBeenCalledTimes(1); + expect(recordDetectionMock).toHaveBeenCalledWith({ + kind: "webdriver-probe", + host: globalThis.location.hostname, + url: globalThis.location.href, }); }); @@ -190,19 +176,19 @@ describe("webdriverProbeAnnotateRule rule-detection emission", () => { dispatchProbe(); dispatchProbe(); - expect(detectionCalls()).toHaveLength(1); + expect(recordDetectionMock).toHaveBeenCalledTimes(1); }); it("re-emits after teardown + re-apply on the same document", () => { webdriverProbeAnnotateRule.apply(document.body); dispatchProbe(); - expect(detectionCalls()).toHaveLength(1); + expect(recordDetectionMock).toHaveBeenCalledTimes(1); webdriverProbeAnnotateRule.teardown(); webdriverProbeAnnotateRule.apply(document.body); dispatchProbe(); - expect(detectionCalls()).toHaveLength(2); + expect(recordDetectionMock).toHaveBeenCalledTimes(2); }); }); diff --git a/extension/src/rules/checkout-checkbox-sanitize.ts b/extension/src/rules/checkout-checkbox-sanitize.ts index 9304e5f..e33a9eb 100644 --- a/extension/src/rules/checkout-checkbox-sanitize.ts +++ b/extension/src/rules/checkout-checkbox-sanitize.ts @@ -27,6 +27,7 @@ import { isCheckoutUrl } from "../lib/checkout-url"; import { CHECKOUT_CHECKBOX_CLEARED_ATTR as CLEARED_ATTR } from "../lib/dom-markers"; import { createRuleLogger } from "../lib/log"; +import { requestPageWorldInject } from "../lib/messenger"; import { createSubtreeWatcher } from "../lib/subtree-watcher"; import { traceMutation } from "../lib/trace-mutation"; import type { Rule } from "./types"; @@ -34,10 +35,6 @@ import type { Rule } from "./types"; const RULE_ID = "checkout-checkbox-sanitize" as const; const log = createRuleLogger(RULE_ID); -const INJECT_DEFENSE_MESSAGE = { - type: "inject-checkout-checkbox-defense", -} as const; - // React/Vue track checked state internally; setting `.checked` directly skips // their value-tracker, so onChange handlers never fire and totals don't // recompute. Going through the prototype's native setter lets the framework @@ -117,13 +114,10 @@ const watcher = createSubtreeWatcher({ }); function requestDefenseInjection(): void { - // Service worker may be asleep / receiver not yet ready; swallow rejection - // so unhandled-promise warnings don't surface on every page load. The - // defense itself short-circuits on `__abs_checkout_checkbox_defense_installed`, - // so re-requests on the same document are no-ops in the page world. - chrome.runtime.sendMessage(INJECT_DEFENSE_MESSAGE).catch(() => { - // noop - }); + // Fire-and-forget; a sleeping service worker just drops it. The defense + // itself short-circuits on `__abs_checkout_checkbox_defense_installed`, so + // re-requests on the same document are no-ops in the page world. + requestPageWorldInject("checkout-checkbox-defense"); } function apply(root: ParentNode): void { @@ -142,5 +136,3 @@ export const checkoutCheckboxSanitizeRule = { watcher.stop(); }, } satisfies Rule; - -export { INJECT_DEFENSE_MESSAGE }; diff --git a/extension/src/rules/closed-shadow-root-annotate.ts b/extension/src/rules/closed-shadow-root-annotate.ts index 5f5d4e0..e3cbb94 100644 --- a/extension/src/rules/closed-shadow-root-annotate.ts +++ b/extension/src/rules/closed-shadow-root-annotate.ts @@ -85,9 +85,9 @@ // and `shadow-root-probe-source.ts` (page world) register any // open shadow materialized post-parse. -import type { RuleDetectionMessage } from "../lib/detection-messages"; import { RULE_ATTR } from "../lib/dom-markers"; import { createRuleLogger } from "../lib/log"; +import { recordDetection, requestPageWorldInject } from "../lib/messenger"; import { SR_ONLY_INLINE_STYLE } from "../lib/sr-only"; import { createSubtreeWatcher } from "../lib/subtree-watcher"; import { traceMutation } from "../lib/trace-mutation"; @@ -103,8 +103,6 @@ const LANDMARK_TEXT = const PROBE_EVENT = "abs:closed-shadow-attached"; -const INJECT_PROBE_MESSAGE = { type: "inject-shadow-root-probe" } as const; - let probeListenerAttached = false; function hasLightContent(element: Element): boolean { @@ -197,18 +195,13 @@ function ensureLandmark(): void { host: globalThis.location.hostname, }); // Per-document dedupe: the landmark short-circuit above ensures we only - // get here once, no matter how many hosts the page mounts. - const message: RuleDetectionMessage = { - type: "rule-detection", - payload: { - kind: "closed-shadow-root", - host: globalThis.location.hostname, - url: globalThis.location.href, - }, - }; - chrome.runtime.sendMessage(message).catch(() => { - // noop — service worker may be asleep; the landmark is the load-bearing - // signal and survives a missed detection emit. + // get here once, no matter how many hosts the page mounts. Fire-and-forget — + // the landmark is the load-bearing signal and survives a missed emit to a + // sleeping service worker. + recordDetection({ + kind: "closed-shadow-root", + host: globalThis.location.hostname, + url: globalThis.location.href, }); } @@ -243,13 +236,10 @@ function onClosedShadowAttached(): void { } function requestProbeInjection(): void { - // Service worker may be asleep / receiver not yet ready; swallow - // rejection so unhandled-promise warnings don't surface on every page - // load. installShadowRootProbe is idempotent via its FLAG sentinel, so - // re-requests on the same document are no-ops in the page world. - chrome.runtime.sendMessage(INJECT_PROBE_MESSAGE).catch(() => { - // noop - }); + // Fire-and-forget; a sleeping service worker just drops it. + // installShadowRootProbe is idempotent via its FLAG sentinel, so re-requests + // on the same document are no-ops in the page world. + requestPageWorldInject("shadow-root-probe"); } function apply(root: ParentNode): void { @@ -295,4 +285,4 @@ export const closedShadowRootAnnotateRule = { teardown, } satisfies Rule; -export { INJECT_PROBE_MESSAGE, PROBE_EVENT }; +export { PROBE_EVENT }; diff --git a/extension/src/rules/roach-motel-annotate.ts b/extension/src/rules/roach-motel-annotate.ts index 48accfc..81f2cbc 100644 --- a/extension/src/rules/roach-motel-annotate.ts +++ b/extension/src/rules/roach-motel-annotate.ts @@ -27,9 +27,9 @@ // sr-only class allowlist plus the 1×1 + overflow:hidden inline envelope. import { URLPattern } from "urlpattern-polyfill"; -import type { RuleDetectionMessage } from "../lib/detection-messages"; import { RULE_ATTR } from "../lib/dom-markers"; import { createRuleLogger } from "../lib/log"; +import { recordDetection } from "../lib/messenger"; import { SR_ONLY_INLINE_STYLE } from "../lib/sr-only"; import { traceMutation } from "../lib/trace-mutation"; import type { JustDeleteMeEntry } from "./justdeleteme.generated"; @@ -215,21 +215,15 @@ function apply(_root: ParentNode): void { }); // Tell the background so the popup can render a human-visible entry // for this tab. The line-181 landmark short-circuit already guarantees - // we only get here once per document. Service worker may be asleep — - // swallow the rejection per the rule-count.ts pattern. - const message: RuleDetectionMessage = { - type: "rule-detection", - payload: { - kind: "roach-motel", - host: globalThis.location.hostname, - url: globalThis.location.href, - difficulty: warning.difficulty, - cancellationUrl: warning.cancellationUrl, - source: warning.source, - }, - }; - chrome.runtime.sendMessage(message).catch(() => { - // noop + // we only get here once per document. Fire-and-forget — a sleeping + // service worker just drops it. + recordDetection({ + kind: "roach-motel", + host: globalThis.location.hostname, + url: globalThis.location.href, + difficulty: warning.difficulty, + cancellationUrl: warning.cancellationUrl, + source: warning.source, }); } diff --git a/extension/src/rules/webdriver-probe-annotate.ts b/extension/src/rules/webdriver-probe-annotate.ts index 30e5f92..71cf662 100644 --- a/extension/src/rules/webdriver-probe-annotate.ts +++ b/extension/src/rules/webdriver-probe-annotate.ts @@ -43,9 +43,9 @@ // rule's `teardown` only removes the landmark and the isolated-world // listener so re-enabling on the same page still picks up later reads. -import type { RuleDetectionMessage } from "../lib/detection-messages"; import { RULE_ATTR } from "../lib/dom-markers"; import { createRuleLogger } from "../lib/log"; +import { recordDetection, requestPageWorldInject } from "../lib/messenger"; import { SR_ONLY_INLINE_STYLE } from "../lib/sr-only"; import { traceMutation } from "../lib/trace-mutation"; import type { Rule } from "./types"; @@ -60,8 +60,6 @@ const LANDMARK_SELECTOR = `section[${RULE_ATTR}="${RULE_ID}"]`; const LANDMARK_TEXT = "This page read navigator.webdriver. The site can distinguish AI-agent traffic from human traffic and may serve different content to agents than to people."; -const INJECT_PROBE_MESSAGE = { type: "inject-webdriver-probe" } as const; - let listenerAttached = false; function buildLandmark(): HTMLElement { @@ -103,16 +101,10 @@ function ensureLandmark(): void { // The line-88 landmark short-circuit above doubles as a per-document // dedupe latch — we only get here once per document, no matter how // many times navigator.webdriver gets read. - const message: RuleDetectionMessage = { - type: "rule-detection", - payload: { - kind: "webdriver-probe", - host: globalThis.location.hostname, - url: globalThis.location.href, - }, - }; - chrome.runtime.sendMessage(message).catch(() => { - // noop + recordDetection({ + kind: "webdriver-probe", + host: globalThis.location.hostname, + url: globalThis.location.href, }); } @@ -121,13 +113,10 @@ function onProbed(): void { } function requestProbeInjection(): void { - // Service worker may be asleep / receiver not yet ready; swallow rejection - // so unhandled-promise warnings don't surface on every page load. The - // probe itself short-circuits on `__abs_webdriver_probe_installed`, so - // re-requests on the same document are no-ops in the page world. - chrome.runtime.sendMessage(INJECT_PROBE_MESSAGE).catch(() => { - // noop - }); + // Fire-and-forget; a sleeping service worker just drops it. The probe itself + // short-circuits on `__abs_webdriver_probe_installed`, so re-requests on the + // same document are no-ops in the page world. + requestPageWorldInject("webdriver-probe"); } function apply(_root: ParentNode): void { @@ -169,4 +158,4 @@ export const webdriverProbeAnnotateRule = { teardown, } satisfies Rule; -export { EVENT_NAME, INJECT_PROBE_MESSAGE }; +export { EVENT_NAME };