From 6be26bea7169c6199f8936fe47a2b1b9987bddaf Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:18:03 +0800 Subject: [PATCH 001/181] fix(privacy): fail closed for masked screenshots --- apps/extension/src/sw/index.ts | 10 ++++-- packages/recorder/src/index.test.ts | 16 +++++++++ packages/recorder/src/recorder.ts | 2 +- .../src/lite-capture-agent.test.ts | 23 ++++++++++++ .../webblackbox/src/lite-capture-agent.ts | 2 +- .../webblackbox/src/lite-materializer.test.ts | 35 ++++++++++++++++++- packages/webblackbox/src/lite-materializer.ts | 4 +++ 7 files changed, 86 insertions(+), 6 deletions(-) diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index c9d317b..a437307 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -1213,7 +1213,7 @@ function resolveSessionExportPolicy(runtime: SessionRuntime, policy: ExportPolic return { ...policy, - includeScreenshots: categories.screenshots !== "off", + includeScreenshots: categories.screenshots === "allow", includeScreenRecordings: categories.screenRecordings === "allow" }; } @@ -1379,6 +1379,10 @@ async function materializeLiteScreenshot( runtime: SessionRuntime, rawEvent: RawRecorderEvent ): Promise { + if (runtime.config.capturePolicy?.categories.screenshots !== "allow") { + return null; + } + const payload = asRecord(rawEvent.payload); const dataUrl = asString(payload?.dataUrl); @@ -1662,7 +1666,7 @@ function shouldCaptureActionScreenshot( return false; } - if (runtime.config.capturePolicy?.categories.screenshots === "off") { + if (runtime.config.capturePolicy?.categories.screenshots !== "allow") { return false; } @@ -2638,7 +2642,7 @@ async function captureScreenshot(runtime: SessionRuntime, reason: string): Promi return; } - if (runtime.config.capturePolicy?.categories.screenshots === "off") { + if (runtime.config.capturePolicy?.categories.screenshots !== "allow") { return; } diff --git a/packages/recorder/src/index.test.ts b/packages/recorder/src/index.test.ts index 99c39ef..ed33747 100644 --- a/packages/recorder/src/index.test.ts +++ b/packages/recorder/src/index.test.ts @@ -815,6 +815,22 @@ describe("recorder", () => { reason: "screenshots-disabled", blockedType: "screen.screenshot" }, + { + raw: createRawEvent({ + rawType: "screenshot", + payload: { + contentHash: "unmasked-shot-hash" + } + }), + policy: createPolicy({ + categories: { + ...TEST_CAPTURE_POLICY.categories, + screenshots: "masked" + } + }), + reason: "screenshots-disabled", + blockedType: "screen.screenshot" + }, { raw: createRawEvent({ source: "system", diff --git a/packages/recorder/src/recorder.ts b/packages/recorder/src/recorder.ts index 8a8d4ee..4ab8dc0 100644 --- a/packages/recorder/src/recorder.ts +++ b/packages/recorder/src/recorder.ts @@ -261,7 +261,7 @@ function findPolicyViolationReason( payload: unknown, policy: CapturePolicy ): string | null { - if (eventType === "screen.screenshot" && policy.categories.screenshots === "off") { + if (eventType === "screen.screenshot" && policy.categories.screenshots !== "allow") { return "screenshots-disabled"; } diff --git a/packages/webblackbox/src/lite-capture-agent.test.ts b/packages/webblackbox/src/lite-capture-agent.test.ts index 4e2e19b..283d789 100644 --- a/packages/webblackbox/src/lite-capture-agent.test.ts +++ b/packages/webblackbox/src/lite-capture-agent.test.ts @@ -26,6 +26,14 @@ const SCREENSHOT_TEST_CAPTURE_POLICY: CapturePolicy = { } }; +const MASKED_SCREENSHOT_TEST_CAPTURE_POLICY: CapturePolicy = { + ...SCREENSHOT_TEST_CAPTURE_POLICY, + categories: { + ...SCREENSHOT_TEST_CAPTURE_POLICY.categories, + screenshots: "masked" + } +}; + function createAgent( state: Partial = {}, options: Partial = {} @@ -254,6 +262,21 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("does not capture raw pixels when screenshot policy is masked", async () => { + const { agent } = createAgent({ + capturePolicy: MASKED_SCREENSHOT_TEST_CAPTURE_POLICY, + sampling: { + screenshotIdleMs: 1_000 + } + }); + + await vi.advanceTimersByTimeAsync(5_000); + + expect(snapdomToBlobMock).not.toHaveBeenCalled(); + + agent.dispose(); + }); + it("releases screenshot capture state when snapdom does not settle", async () => { const { agent } = createAgent({ capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY, diff --git a/packages/webblackbox/src/lite-capture-agent.ts b/packages/webblackbox/src/lite-capture-agent.ts index 3744285..1d9d0d9 100644 --- a/packages/webblackbox/src/lite-capture-agent.ts +++ b/packages/webblackbox/src/lite-capture-agent.ts @@ -824,7 +824,7 @@ export class LiteCaptureAgent { this.mode !== "full" && this.isTopLevelFrame && this.sampling.screenshotIdleMs > 0 && - this.capturePolicy.categories.screenshots !== "off" + this.capturePolicy.categories.screenshots === "allow" ); } diff --git a/packages/webblackbox/src/lite-materializer.test.ts b/packages/webblackbox/src/lite-materializer.test.ts index 5948baf..96e6f6a 100644 --- a/packages/webblackbox/src/lite-materializer.test.ts +++ b/packages/webblackbox/src/lite-materializer.test.ts @@ -73,6 +73,14 @@ describe("lite-materializer", () => { it("materializes screenshot data-url payloads into blob references", async () => { const putBlobCalls: Array<{ mime: string; bytes: Uint8Array }> = []; + const config = cloneConfig(); + config.capturePolicy = { + ...config.capturePolicy!, + categories: { + ...config.capturePolicy!.categories, + screenshots: "allow" + } + }; const rawEvent = createRawEvent("screenshot", { dataUrl: `data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}`, @@ -82,7 +90,7 @@ describe("lite-materializer", () => { }); const result = await materializeLiteRawEvent(rawEvent, { - config: cloneConfig(), + config, putBlob: async (mime, bytes) => { putBlobCalls.push({ mime, bytes }); return "hash-shot"; @@ -102,6 +110,31 @@ describe("lite-materializer", () => { }); }); + it("does not persist screenshot bytes for masked policy", async () => { + const config = cloneConfig(); + config.capturePolicy = { + ...config.capturePolicy!, + categories: { + ...config.capturePolicy!.categories, + screenshots: "masked" + } + }; + const putBlob = vi.fn(async () => "unexpected-hash"); + + const result = await materializeLiteRawEvent( + createRawEvent("screenshot", { + dataUrl: `data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}` + }), + { + config, + putBlob + } + ); + + expect(result).toBeNull(); + expect(putBlob).not.toHaveBeenCalled(); + }); + it("materializes network bodies with redaction and byte caps", async () => { const config = cloneConfig(); config.sampling.bodyCaptureMaxBytes = 4 * 1024; diff --git a/packages/webblackbox/src/lite-materializer.ts b/packages/webblackbox/src/lite-materializer.ts index 78221dc..058be8c 100644 --- a/packages/webblackbox/src/lite-materializer.ts +++ b/packages/webblackbox/src/lite-materializer.ts @@ -101,6 +101,10 @@ async function materializeLiteScreenshot( rawEvent: RawRecorderEvent, context: LiteMaterializerContext ): Promise { + if (context.config.capturePolicy?.categories.screenshots !== "allow") { + return null; + } + const payload = asRecord(rawEvent.payload); const dataUrl = asString(payload?.dataUrl); const maxDataUrlLength = From 1b36dd08a1d3ae0319bd6df97e5dbec7593d9f92 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:20:04 +0800 Subject: [PATCH 002/181] fix(privacy): reject network bodies without MIME --- .../webblackbox/src/injected-hooks.test.ts | 21 +++++++++++++++++ packages/webblackbox/src/injected-hooks.ts | 2 +- .../webblackbox/src/lite-materializer.test.ts | 23 +++++++++++++++++++ packages/webblackbox/src/lite-materializer.ts | 2 +- 4 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/webblackbox/src/injected-hooks.test.ts b/packages/webblackbox/src/injected-hooks.test.ts index a903e6e..e20bd6d 100644 --- a/packages/webblackbox/src/injected-hooks.test.ts +++ b/packages/webblackbox/src/injected-hooks.test.ts @@ -186,6 +186,27 @@ describe("injected-hooks", () => { expect(String((networkBody?.payload as { body?: unknown }).body ?? "")).toContain('"ok":true'); }); + it("does not sample fetch bodies without an allowed content type", async () => { + const flag = "__WB_TEST_INJECTED_FETCH_MISSING_MIME__"; + + window.fetch = vi.fn(async () => { + return new Response(new Uint8Array([0, 1, 2, 3]), { + status: 200 + }); + }) as typeof fetch; + + installInjectedLiteCaptureHooks({ + flag, + bodyCaptureMaxBytes: 128 * 1024, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + }); + + await window.fetch("https://example.test/api/binary"); + await delay(20); + + expect(captured.some((message) => message.rawType === "networkBody")).toBe(false); + }); + it("skips response-body sampling until capture config enables it", async () => { const flag = "__WB_TEST_INJECTED_FETCH_CONFIG__"; diff --git a/packages/webblackbox/src/injected-hooks.ts b/packages/webblackbox/src/injected-hooks.ts index 37157be..03ed4cf 100644 --- a/packages/webblackbox/src/injected-hooks.ts +++ b/packages/webblackbox/src/injected-hooks.ts @@ -1194,7 +1194,7 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = function isBodyCaptureMimeAllowed(mimeType: string | undefined): boolean { if (!mimeType) { - return true; + return false; } const normalized = mimeType.toLowerCase(); diff --git a/packages/webblackbox/src/lite-materializer.test.ts b/packages/webblackbox/src/lite-materializer.test.ts index 96e6f6a..e2c2e96 100644 --- a/packages/webblackbox/src/lite-materializer.test.ts +++ b/packages/webblackbox/src/lite-materializer.test.ts @@ -180,6 +180,29 @@ describe("lite-materializer", () => { }); }); + it("does not persist network bodies without an allowed MIME type", async () => { + const config = cloneConfig(); + config.sampling.bodyCaptureMaxBytes = 4 * 1024; + const putBlob = vi.fn(async () => "unexpected-hash"); + + const result = await materializeLiteRawEvent( + createRawEvent("networkBody", { + reqId: "R-missing-mime", + url: "https://example.test/api/binary", + encoding: "base64", + body: Buffer.from([0, 1, 2, 3]).toString("base64"), + size: 4 + }), + { + config, + putBlob + } + ); + + expect(result).toBeNull(); + expect(putBlob).not.toHaveBeenCalled(); + }); + it("respects site policy deny rules for body capture", async () => { const config = cloneConfig(); config.sitePolicies = [ diff --git a/packages/webblackbox/src/lite-materializer.ts b/packages/webblackbox/src/lite-materializer.ts index 058be8c..b03128e 100644 --- a/packages/webblackbox/src/lite-materializer.ts +++ b/packages/webblackbox/src/lite-materializer.ts @@ -452,7 +452,7 @@ function wildcardMatch(value: string, pattern: string): boolean { function isMimeAllowed(allowlist: string[], mimeType: string | undefined): boolean { if (!mimeType) { - return true; + return false; } const normalizedMime = mimeType.toLowerCase(); From e8282a07d2495e2730e0de6c11ae943e74cd1c02 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:20:53 +0800 Subject: [PATCH 003/181] fix(extension): package store-safe Chrome releases --- .github/workflows/release-assets.yml | 7 +- CONTRIBUTING.md | 2 + apps/extension/README.md | 18 ++++- apps/extension/package.json | 5 +- apps/extension/scripts/build-extension.mjs | 60 +---------------- .../scripts/lib/extension-build-cli.mjs | 59 +++++++++++++++++ .../extension/scripts/lib/extension-build.mjs | 52 +++++++++++++-- .../src/shared/extension-build.test.mjs | 65 +++++++++++++++++++ 8 files changed, 200 insertions(+), 68 deletions(-) create mode 100644 apps/extension/scripts/lib/extension-build-cli.mjs diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index a93001d..1cae2db 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -38,13 +38,16 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Package Chrome extension + - name: Package and verify store-safe Chrome extension run: pnpm --filter @webblackbox/extension package:chrome - name: Resolve Chrome extension archive id: archive run: | - archive_path=$(ls -t apps/extension/dist/*-chrome.zip | head -n 1) + archive_path=$(find apps/extension/dist -maxdepth 1 -type f \ + -name 'webblackbox-*-chrome.zip' \ + ! -name '*-enterprise-chrome.zip' \ + -print | head -n 1) if [ -z "$archive_path" ]; then echo "Could not find packaged Chrome extension archive." >&2 exit 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64eb82b..c36a391 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,8 @@ pnpm --filter @webblackbox/extension e2e:perf:lite pnpm --filter @webblackbox/extension package:chrome ``` +`package:chrome` is the release, store-safe Chrome Web Store artifact. Use the explicitly separate `package:chrome:enterprise` command only when a broader dev/enterprise permission package is required; never upload that archive to the Web Store. + ## Working on the Player ```bash diff --git a/apps/extension/README.md b/apps/extension/README.md index 84de8b7..498719e 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -88,6 +88,19 @@ The extension consists of multiple main components: ## Permissions +The Chrome Web Store package uses the `store-safe` profile: + +| Permission | Purpose | +| ------------ | ------------------------------------------------------ | +| `activeTab` | Temporary page access after the user starts recording | +| `scripting` | Programmatic capture injection after that user gesture | +| `storage` | Extension settings and session data | +| `offscreen` | Pipeline processing in the background | +| `tabCapture` | User-initiated tab media capture | +| `downloads` | Archive file download | + +The separately packaged development/enterprise profile adds the following broader capabilities and is not the Web Store artifact: + | Permission | Purpose | | ------------ | ------------------------------------------------ | | `debugger` | CDP access for network, runtime, and page events | @@ -115,10 +128,13 @@ cd apps/extension pnpm build pnpm build:release pnpm package:chrome +pnpm package:chrome:enterprise pnpm verify ``` -The build output is in the `build/` directory. The manifest is generated from `apps/extension/package.json` during the build, so there is no source `public/manifest.json` to keep in sync. Local `pnpm build` runs keep the stable development `key`, while `pnpm build:release` generates an unpacked release build without that `key` so you can do store-parity checks before upload. `pnpm package:chrome` rebuilds the extension once, validates the generated manifest, and creates a Chrome Web Store upload ZIP in `dist/` with the release manifest. Packaging is pure Node.js, so it does not depend on a system `zip` binary being installed. `pnpm verify` runs the extension's lint, typecheck, test, and packaging pipeline in one command. You can override the ZIP path with `node scripts/build-extension.mjs --package --output ./dist/custom-name.zip`. +The build output is in the `build/` directory. The manifest is generated from `apps/extension/package.json` during the build, so there is no source `public/manifest.json` to keep in sync. Local `pnpm build` runs keep the stable development `key`, while `pnpm build:release` generates an unpacked, keyless `store-safe` build for store-parity checks. `pnpm package:chrome` always rebuilds with `--release --profile store-safe`, writes `dist/webblackbox--chrome.zip`, then reopens the ZIP and validates its release manifest. That Web Store artifact contains no `debugger`, `tabs`, or `webRequest` permission, no persistent host permissions, and no static content script registration. + +The broader profile is an explicit opt-in: `pnpm package:chrome:enterprise` writes `dist/webblackbox--enterprise-chrome.zip` with `--profile dev`. Do not upload that archive to the Chrome Web Store. A raw `node scripts/build-extension.mjs --package` invocation also defaults to a release `store-safe` package; producing the broader archive requires `--profile dev`. Packaging is pure Node.js, so it does not depend on a system `zip` binary. `pnpm verify` runs lint, typecheck, tests, and the store-safe packaging pipeline. You can override the ZIP path with `node scripts/build-extension.mjs --package --output ./dist/custom-name.zip`. Build entries: diff --git a/apps/extension/package.json b/apps/extension/package.json index b9fe49d..6533653 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -7,8 +7,9 @@ "scripts": { "dev": "tsup --config tsup.config.ts --watch src --watch public --watch scripts --onSuccess \"node scripts/build-extension.mjs\"", "build": "tsup --config tsup.config.ts --clean && node scripts/build-extension.mjs", - "build:release": "tsup --config tsup.config.ts --clean && node scripts/build-extension.mjs --release", - "package:chrome": "tsup --config tsup.config.ts --clean && node scripts/build-extension.mjs --package", + "build:release": "tsup --config tsup.config.ts --clean && node scripts/build-extension.mjs --release --profile store-safe", + "package:chrome": "tsup --config tsup.config.ts --clean && node scripts/build-extension.mjs --package --release --profile store-safe", + "package:chrome:enterprise": "tsup --config tsup.config.ts --clean && node scripts/build-extension.mjs --package --release --profile dev", "verify": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run package:chrome", "e2e:check": "node scripts/e2e-extension-check.mjs", "e2e:fullchain": "node scripts/e2e-fullchain-demo.mjs", diff --git a/apps/extension/scripts/build-extension.mjs b/apps/extension/scripts/build-extension.mjs index d62dbbd..8bb4557 100644 --- a/apps/extension/scripts/build-extension.mjs +++ b/apps/extension/scripts/build-extension.mjs @@ -1,8 +1,9 @@ import { resolve } from "node:path"; +import { parseExtensionBuildArgs } from "./lib/extension-build-cli.mjs"; import { buildDir, createChromeArchive, prepareBuildOutput } from "./lib/extension-build.mjs"; -const options = parseArgs(process.argv.slice(2)); +const options = parseExtensionBuildArgs(process.argv.slice(2)); const outputDir = options.outputDir ? resolve(process.cwd(), options.outputDir) : buildDir; const manifest = await prepareBuildOutput({ @@ -34,60 +35,3 @@ if (options.packageArchive) { } console.info(JSON.stringify(result, null, 2)); - -function parseArgs(args) { - const knownFlags = new Set(["--package", "--profile", "--release", "--output", "--output-dir"]); - const release = args.includes("--release"); - const packageArchive = args.includes("--package"); - const outputDir = readFlagValue(args, "--output-dir"); - const outputPath = readFlagValue(args, "--output"); - const profile = readFlagValue(args, "--profile") ?? "dev"; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - - if ( - arg.startsWith("--output=") || - arg.startsWith("--output-dir=") || - arg.startsWith("--profile=") - ) { - continue; - } - - if (!knownFlags.has(arg)) { - throw new Error(`Unknown argument: ${arg}`); - } - - if ( - (arg === "--output" || arg === "--output-dir" || arg === "--profile") && - !args[index + 1]?.startsWith("--") - ) { - index += 1; - } - } - - return { - release, - packageArchive, - profile, - outputDir, - outputPath - }; -} - -function readFlagValue(args, flagName) { - const inline = args.find((entry) => entry.startsWith(`${flagName}=`)); - - if (inline) { - return inline.slice(flagName.length + 1); - } - - const index = args.indexOf(flagName); - - if (index === -1) { - return null; - } - - const value = args[index + 1]; - return value && !value.startsWith("--") ? value : null; -} diff --git a/apps/extension/scripts/lib/extension-build-cli.mjs b/apps/extension/scripts/lib/extension-build-cli.mjs new file mode 100644 index 0000000..454265c --- /dev/null +++ b/apps/extension/scripts/lib/extension-build-cli.mjs @@ -0,0 +1,59 @@ +export function parseExtensionBuildArgs(args) { + const knownFlags = new Set(["--package", "--profile", "--release", "--output", "--output-dir"]); + const packageArchive = args.includes("--package"); + const outputDir = readFlagValue(args, "--output-dir"); + const outputPath = readFlagValue(args, "--output"); + const requestedProfile = readFlagValue(args, "--profile"); + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if ( + arg.startsWith("--output=") || + arg.startsWith("--output-dir=") || + arg.startsWith("--profile=") + ) { + continue; + } + + if (!knownFlags.has(arg)) { + throw new Error(`Unknown argument: ${arg}`); + } + + if ( + (arg === "--output" || arg === "--output-dir" || arg === "--profile") && + !args[index + 1]?.startsWith("--") + ) { + index += 1; + } + } + + return { + // Packaging is a release operation by default. Development builds retain + // their stable key unless callers explicitly request --release. + release: packageArchive || args.includes("--release"), + packageArchive, + // The safe profile is the only implicit packaging profile. Producing an + // enterprise archive requires an explicit --profile dev opt-in. + profile: requestedProfile ?? (packageArchive ? "store-safe" : "dev"), + outputDir, + outputPath + }; +} + +function readFlagValue(args, flagName) { + const inline = args.find((entry) => entry.startsWith(`${flagName}=`)); + + if (inline) { + return inline.slice(flagName.length + 1); + } + + const index = args.indexOf(flagName); + + if (index === -1) { + return null; + } + + const value = args[index + 1]; + return value && !value.startsWith("--") ? value : null; +} diff --git a/apps/extension/scripts/lib/extension-build.mjs b/apps/extension/scripts/lib/extension-build.mjs index 47d4bb2..060fcc7 100644 --- a/apps/extension/scripts/lib/extension-build.mjs +++ b/apps/extension/scripts/lib/extension-build.mjs @@ -325,15 +325,16 @@ export async function createChromeArchive({ sourceDir = buildDir, outputPath } = } const archiveSlug = slugify(EXTENSION_ARCHIVE_SLUG); + const archiveVariant = sourceProfile === "store-safe" ? "chrome" : "enterprise-chrome"; const resolvedOutputPath = outputPath ? resolve(process.cwd(), outputPath) - : resolve(distDir, `${archiveSlug}-${version}-chrome.zip`); + : resolve(distDir, `${archiveSlug}-${version}-${archiveVariant}.zip`); await mkdir(dirname(resolvedOutputPath), { recursive: true }); await mkdir(distDir, { recursive: true }); if (!outputPath) { - await removeStaleArchives(distDir, archiveSlug, resolvedOutputPath); + await removeStaleArchives(distDir, archiveSlug, archiveVariant, resolvedOutputPath); } await rm(resolvedOutputPath, { force: true }); @@ -365,6 +366,11 @@ export async function createChromeArchive({ sourceDir = buildDir, outputPath } = await writeFile(resolvedOutputPath, archiveBytes); + await assertChromeArchive(resolvedOutputPath, { + version, + profile: sourceProfile + }); + const archiveStat = await stat(resolvedOutputPath); return { @@ -372,12 +378,36 @@ export async function createChromeArchive({ sourceDir = buildDir, outputPath } = bytes: archiveStat.size, manifest: { name: EXTENSION_ARCHIVE_NAME, - version: releaseManifest.version + version: releaseManifest.version, + profile: sourceProfile }, strippedKeys: Object.hasOwn(sourceManifest ?? {}, "key") ? ["key"] : [] }; } +export async function assertChromeArchive(archivePath, { version, profile = "store-safe" } = {}) { + const archiveBytes = await readFile(archivePath); + const zip = await JSZip.loadAsync(archiveBytes); + const manifestEntry = zip.file("manifest.json"); + + if (!manifestEntry) { + throw new Error(`Chrome archive is missing manifest.json: ${archivePath}`); + } + + const manifest = JSON.parse(await manifestEntry.async("text")); + const issues = validateExtensionManifest(manifest, { + version, + release: true, + profile + }); + + if (issues.length > 0) { + throw new Error(`Chrome archive manifest is invalid:\n- ${issues.join("\n- ")}`); + } + + return manifest; +} + function validateUniqueStringArray(value, fieldName, issues) { if (!Array.isArray(value)) { issues.push(`${fieldName} must be an array.`); @@ -478,12 +508,12 @@ function inferManifestProfile(manifest) { return "dev"; } -async function removeStaleArchives(directory, slug, keepPath) { +async function removeStaleArchives(directory, slug, variant, keepPath) { const entries = await readdir(directory).catch(() => []); await Promise.all( entries - .filter((entry) => entry.startsWith(`${slug}-`) && entry.endsWith("-chrome.zip")) + .filter((entry) => isArchiveForVariant(entry, slug, variant)) .map(async (entry) => { const candidatePath = resolve(directory, entry); @@ -496,6 +526,18 @@ async function removeStaleArchives(directory, slug, keepPath) { ); } +function isArchiveForVariant(entry, slug, variant) { + if (!entry.startsWith(`${slug}-`)) { + return false; + } + + if (variant === "enterprise-chrome") { + return entry.endsWith("-enterprise-chrome.zip"); + } + + return entry.endsWith("-chrome.zip") && !entry.endsWith("-enterprise-chrome.zip"); +} + async function listPackagedFiles(rootDir, currentDir = rootDir) { const entries = await readdir(currentDir, { withFileTypes: true }); const files = []; diff --git a/apps/extension/src/shared/extension-build.test.mjs b/apps/extension/src/shared/extension-build.test.mjs index 9f938ad..66ff5a2 100644 --- a/apps/extension/src/shared/extension-build.test.mjs +++ b/apps/extension/src/shared/extension-build.test.mjs @@ -5,12 +5,40 @@ import { dirname, resolve } from "node:path"; import JSZip from "jszip"; import { describe, expect, it } from "vitest"; +import { parseExtensionBuildArgs } from "../../scripts/lib/extension-build-cli.mjs"; import { + assertChromeArchive, createChromeArchive, createExtensionManifest, validateExtensionManifest } from "../../scripts/lib/extension-build.mjs"; +describe("extension build CLI", () => { + it("defaults package builds to release store-safe artifacts", () => { + expect(parseExtensionBuildArgs(["--package"])).toMatchObject({ + release: true, + packageArchive: true, + profile: "store-safe" + }); + }); + + it("requires an explicit dev profile for enterprise packages", () => { + expect(parseExtensionBuildArgs(["--package", "--profile", "dev"])).toMatchObject({ + release: true, + packageArchive: true, + profile: "dev" + }); + }); + + it("keeps ordinary unpacked builds in development mode", () => { + expect(parseExtensionBuildArgs([])).toMatchObject({ + release: false, + packageArchive: false, + profile: "dev" + }); + }); +}); + describe("extension build manifest", () => { it("creates a development manifest with explicit CSP", () => { const manifest = createExtensionManifest({ version: "1.2.3" }); @@ -129,9 +157,46 @@ describe("extension build manifest", () => { expect(packagedManifest).not.toHaveProperty("key"); expect(packagedManifest.permissions).toContain("tabCapture"); expect(packagedManifest.permissions).not.toContain("debugger"); + expect(packagedManifest.permissions).not.toContain("tabs"); expect(packagedManifest.permissions).not.toContain("webRequest"); expect(packagedManifest).not.toHaveProperty("host_permissions"); expect(packagedManifest).not.toHaveProperty("content_scripts"); + await expect( + assertChromeArchive(archive.path, { + version: "1.2.3", + profile: "store-safe" + }) + ).resolves.toMatchObject({ version: "1.2.3" }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a dev archive when a store-safe artifact is required", async () => { + const root = await mkdtemp(resolve(tmpdir(), "webblackbox-extension-unsafe-store-test-")); + const sourceDir = resolve(root, "build"); + const archivePath = resolve(root, "archive.zip"); + + try { + await writeBuildFixture( + sourceDir, + createExtensionManifest({ + version: "1.2.3", + profile: "dev" + }) + ); + + const archive = await createChromeArchive({ + sourceDir, + outputPath: archivePath + }); + + await expect( + assertChromeArchive(archive.path, { + version: "1.2.3", + profile: "store-safe" + }) + ).rejects.toThrow("Store-safe manifest must not request the debugger permission"); } finally { await rm(root, { recursive: true, force: true }); } From 3c8df4bf4e6f04f8dc40d92ff6336d9997529922 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:24:23 +0800 Subject: [PATCH 004/181] fix(privacy): hide IndexedDB names in counts-only mode --- packages/recorder/src/index.test.ts | 17 +++++++ packages/recorder/src/recorder.ts | 2 + .../webblackbox/src/injected-hooks.test.ts | 46 +++++++++++++++++++ packages/webblackbox/src/injected-hooks.ts | 21 ++++++--- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/packages/recorder/src/index.test.ts b/packages/recorder/src/index.test.ts index ed33747..1cfc8f3 100644 --- a/packages/recorder/src/index.test.ts +++ b/packages/recorder/src/index.test.ts @@ -994,6 +994,23 @@ describe("recorder", () => { reason: "storage-detail-disabled", blockedType: "storage.idb.snapshot" }, + { + raw: createRawEvent({ + rawType: "indexedDbOp", + payload: { + op: "open", + name: "customer-secret-db" + } + }), + policy: createPolicy({ + categories: { + ...TEST_CAPTURE_POLICY.categories, + indexedDb: "counts-only" + } + }), + reason: "storage-detail-disabled", + blockedType: "storage.idb.op" + }, { raw: createRawEvent({ rawType: "cookieSnapshot", diff --git a/packages/recorder/src/recorder.ts b/packages/recorder/src/recorder.ts index 4ab8dc0..1d9e089 100644 --- a/packages/recorder/src/recorder.ts +++ b/packages/recorder/src/recorder.ts @@ -519,6 +519,8 @@ function hasStorageDetail(payload: unknown): boolean { return ( hasBlobReference(row) || typeof row.key === "string" || + typeof row.name === "string" || + typeof row.databaseName === "string" || Array.isArray(row.names) || Array.isArray(row.databaseNames) || asRecord(row.entries) !== null diff --git a/packages/webblackbox/src/injected-hooks.test.ts b/packages/webblackbox/src/injected-hooks.test.ts index e20bd6d..ec18ece 100644 --- a/packages/webblackbox/src/injected-hooks.test.ts +++ b/packages/webblackbox/src/injected-hooks.test.ts @@ -70,11 +70,57 @@ describe("injected-hooks", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); window.fetch = originalFetch; localStorage.clear(); sessionStorage.clear(); }); + it("omits IndexedDB names when policy is counts-only", async () => { + const open = vi.fn(() => ({}) as IDBOpenDBRequest); + vi.stubGlobal("indexedDB", { open }); + + installInjectedLiteCaptureHooks({ + flag: "__WB_TEST_INJECTED_IDB_COUNTS_ONLY__", + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + }); + + indexedDB.open("customer-secret-db", 7); + await delay(10); + + const event = captured.find((message) => message.rawType === "indexedDbOp"); + expect(event?.payload).toEqual({ op: "open" }); + expect(JSON.stringify(event)).not.toContain("customer-secret-db"); + expect(open).toHaveBeenCalledWith("customer-secret-db", 7); + }); + + it("retains IndexedDB names only when names-only policy explicitly allows them", async () => { + const open = vi.fn(() => ({}) as IDBOpenDBRequest); + vi.stubGlobal("indexedDB", { open }); + const capturePolicy: CapturePolicy = { + ...DETAILED_TEST_CAPTURE_POLICY, + categories: { + ...DETAILED_TEST_CAPTURE_POLICY.categories, + indexedDb: "names-only" + } + }; + + installInjectedLiteCaptureHooks({ + flag: "__WB_TEST_INJECTED_IDB_NAMES_ONLY__", + capturePolicy + }); + + indexedDB.open("allowed-database-name", 3); + await delay(10); + + const event = captured.find((message) => message.rawType === "indexedDbOp"); + expect(event?.payload).toEqual({ + op: "open", + name: "allowed-database-name", + version: 3 + }); + }); + it("is idempotent for the same flag and emits ready + console events", async () => { const flag = "__WB_TEST_INJECTED_CONSOLE__"; diff --git a/packages/webblackbox/src/injected-hooks.ts b/packages/webblackbox/src/injected-hooks.ts index 03ed4cf..ee7c8df 100644 --- a/packages/webblackbox/src/injected-hooks.ts +++ b/packages/webblackbox/src/injected-hooks.ts @@ -1397,12 +1397,21 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = const open = indexedDB.open.bind(indexedDB); indexedDB.open = (name: string, version?: number) => { - if (captureActive) { - emit("indexedDbOp", { - op: "open", - name, - version - }); + const indexedDbMode = capturePolicy.categories.indexedDb; + + if (captureActive && indexedDbMode !== "off") { + emit( + "indexedDbOp", + indexedDbMode === "names-only" + ? { + op: "open", + name, + version + } + : { + op: "open" + } + ); } return open(name, version); From 8663a1749eb3945927cd4a21b401550ad3e6df81 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:25:28 +0800 Subject: [PATCH 005/181] fix(privacy): sanitize editable keydown capture --- packages/recorder/src/index.test.ts | 212 +++++++++++++++++ packages/recorder/src/recorder.ts | 183 ++++++++++++++- .../src/lite-capture-agent.test.ts | 215 +++++++++++++++++- .../webblackbox/src/lite-capture-agent.ts | 143 ++++++++++-- 4 files changed, 730 insertions(+), 23 deletions(-) diff --git a/packages/recorder/src/index.test.ts b/packages/recorder/src/index.test.ts index 1cfc8f3..bc63814 100644 --- a/packages/recorder/src/index.test.ts +++ b/packages/recorder/src/index.test.ts @@ -792,6 +792,218 @@ describe("recorder", () => { }); }); + it("classifies editable keydowns as high-sensitivity input data", () => { + const recorder = new WebBlackboxRecorder(TEST_CONFIG); + const result = recorder.ingest( + createRawEvent({ + rawType: "keydown", + payload: { + inputContext: "input", + key: "A", + code: "KeyA", + target: { + tag: "INPUT" + } + } + }) + ); + + expect(result.event?.type).toBe("user.keydown"); + expect(result.event?.privacy).toEqual({ + category: "inputs", + sensitivity: "high", + redacted: false + }); + }); + + it("blocks raw editable text keys when the input policy is length-only", () => { + const recorder = new WebBlackboxRecorder({ + ...TEST_CONFIG, + capturePolicy: createPolicy({ + categories: { + ...TEST_CAPTURE_POLICY.categories, + inputs: "length-only" + } + }) + }); + const result = recorder.ingest( + createRawEvent({ + rawType: "keydown", + payload: { + key: "A", + code: "KeyA", + target: { + tag: "INPUT" + } + } + }) + ); + + expect(result.event?.type).toBe("privacy.violation"); + expect(result.event?.data).toMatchObject({ + blockedType: "user.keydown", + category: "inputs", + sensitivity: "high", + reason: "raw-input-value-disabled" + }); + }); + + it("accepts sanitized editable keys and retains non-text shortcuts under length-only", () => { + const recorder = new WebBlackboxRecorder({ + ...TEST_CONFIG, + capturePolicy: createPolicy({ + categories: { + ...TEST_CAPTURE_POLICY.categories, + inputs: "length-only" + } + }) + }); + const sanitized = recorder.ingest( + createRawEvent({ + rawType: "keydown", + payload: { + inputContext: "textarea", + keyRedacted: true, + keyKind: "printable", + target: { + tag: "TEXTAREA" + } + } + }) + ); + const navigation = recorder.ingest( + createRawEvent({ + rawType: "keydown", + mono: 2, + payload: { + inputContext: "contenteditable", + key: "Enter", + code: "Enter", + target: { + tag: "DIV", + contentEditable: true + } + } + }) + ); + + expect(sanitized.event?.type).toBe("user.keydown"); + expect(sanitized.event?.privacy).toEqual({ + category: "inputs", + sensitivity: "high", + redacted: true + }); + expect(navigation.event?.type).toBe("user.keydown"); + expect(navigation.event?.data).toMatchObject({ + key: "Enter", + code: "Enter" + }); + expect(navigation.event?.privacy).toEqual({ + category: "inputs", + sensitivity: "high", + redacted: true + }); + }); + + it("blocks editable keydowns when input capture is disabled", () => { + const recorder = new WebBlackboxRecorder({ + ...TEST_CONFIG, + capturePolicy: createPolicy({ + categories: { + ...TEST_CAPTURE_POLICY.categories, + inputs: "none" + } + }) + }); + const result = recorder.ingest( + createRawEvent({ + rawType: "keydown", + payload: { + inputContext: "contenteditable", + key: "Enter", + code: "Enter" + } + }) + ); + + expect(result.event?.type).toBe("privacy.violation"); + expect(result.event?.data).toMatchObject({ + blockedType: "user.keydown", + category: "inputs", + sensitivity: "high", + reason: "inputs-disabled" + }); + }); + + it("blocks every password key identity even when input capture is allowed", () => { + const recorder = new WebBlackboxRecorder(TEST_CONFIG); + const result = recorder.ingest( + createRawEvent({ + rawType: "keydown", + payload: { + inputContext: "password", + key: "Enter", + code: "Enter", + target: { + tag: "INPUT", + inputType: "password" + } + } + }) + ); + const sanitized = recorder.ingest( + createRawEvent({ + rawType: "keydown", + mono: 2, + payload: { + inputContext: "protected", + keyRedacted: true, + keyKind: "protected", + target: { + tag: "INPUT" + } + } + }) + ); + + expect(result.event?.type).toBe("privacy.violation"); + expect(result.event?.data).toMatchObject({ + blockedType: "user.keydown", + category: "inputs", + sensitivity: "high", + reason: "password-key-data-disabled" + }); + expect(sanitized.event?.type).toBe("user.keydown"); + expect(sanitized.event?.privacy).toEqual({ + category: "inputs", + sensitivity: "high", + redacted: true + }); + }); + + it("keeps non-editable keydowns in the actions privacy category", () => { + const recorder = new WebBlackboxRecorder(TEST_CONFIG); + const result = recorder.ingest( + createRawEvent({ + rawType: "keydown", + payload: { + key: "A", + code: "KeyA", + target: { + tag: "DIV" + } + } + }) + ); + + expect(result.event?.type).toBe("user.keydown"); + expect(result.event?.privacy).toEqual({ + category: "actions", + sensitivity: "low", + redacted: false + }); + }); + it("maps capture policy category gates to redacted violations", () => { const cases: Array<{ raw: RawRecorderEvent; diff --git a/packages/recorder/src/recorder.ts b/packages/recorder/src/recorder.ts index 1d9e089..1dd08b4 100644 --- a/packages/recorder/src/recorder.ts +++ b/packages/recorder/src/recorder.ts @@ -17,6 +17,59 @@ import { redactPayload } from "./redaction.js"; import { EventRingBuffer } from "./ring-buffer.js"; import type { EventNormalizer, RawRecorderEvent, RecorderIngestResult } from "./types.js"; +const NON_TEXT_KEYBOARD_KEYS = new Set([ + "Alt", + "AltGraph", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "Backspace", + "CapsLock", + "ContextMenu", + "Control", + "Delete", + "End", + "Enter", + "Escape", + "Fn", + "FnLock", + "Home", + "Hyper", + "Insert", + "Meta", + "NumLock", + "NumpadEnter", + "PageDown", + "PageUp", + "Pause", + "PrintScreen", + "ScrollLock", + "Shift", + "Super", + "Symbol", + "SymbolLock", + "Tab" +]); + +const TEXT_PRODUCING_KEYBOARD_CODES = new Set([ + "Backquote", + "Backslash", + "BracketLeft", + "BracketRight", + "Comma", + "Equal", + "IntlBackslash", + "IntlRo", + "IntlYen", + "Minus", + "Period", + "Quote", + "Semicolon", + "Slash", + "Space" +]); + export type RecorderHooks = { onEvent?: (event: WebBlackboxEvent) => void; onFreeze?: (reason: FreezeReason, event: WebBlackboxEvent) => void; @@ -63,12 +116,12 @@ export class WebBlackboxRecorder { const redactedPayload = redactPayload(normalized.payload, this.config.redaction); const privacy = classifyPrivacy( normalized.eventType, - redactedPayload, + normalized.payload, this.config.capturePolicy ); const violation = evaluateCapturePolicy( normalized.eventType, - redactedPayload, + normalized.payload, privacy, this.config.capturePolicy ); @@ -206,8 +259,8 @@ function classifyPrivacy( policy: CapturePolicy | undefined ): PrivacyClassification { const effectivePolicy = policy ?? DEFAULT_CAPTURE_POLICY; - const category = classifyCategory(eventType); - const sensitivity = classifySensitivity(eventType); + const category = classifyCategory(eventType, payload); + const sensitivity = classifySensitivity(eventType, payload); return { category, @@ -287,6 +340,28 @@ function findPolicyViolationReason( return "dom-disabled"; } + const editableKeydownContext = readEditableKeydownContext(eventType, payload); + + if (editableKeydownContext) { + if (policy.categories.inputs === "none") { + return "inputs-disabled"; + } + + if ( + editableKeydownContext === "protected" && + (hasAnyKeyboardIdentity(payload) || hasRawInputValue(payload)) + ) { + return "password-key-data-disabled"; + } + + if ( + policy.categories.inputs !== "allow" && + (hasSensitiveKeyboardIdentity(payload) || hasRawInputValue(payload)) + ) { + return "raw-input-value-disabled"; + } + } + if (eventType === "user.input") { if (policy.categories.inputs === "none") { return "inputs-disabled"; @@ -334,8 +409,11 @@ function findPolicyViolationReason( return null; } -function classifyCategory(eventType: WebBlackboxEventType): PrivacyClassification["category"] { - if (eventType === "user.input") { +function classifyCategory( + eventType: WebBlackboxEventType, + payload: unknown +): PrivacyClassification["category"] { + if (eventType === "user.input" || readEditableKeydownContext(eventType, payload)) { return "inputs"; } @@ -375,7 +453,8 @@ function classifyCategory(eventType: WebBlackboxEventType): PrivacyClassificatio } function classifySensitivity( - eventType: WebBlackboxEventType + eventType: WebBlackboxEventType, + payload: unknown ): PrivacyClassification["sensitivity"] { if (eventType === "privacy.violation") { return "medium"; @@ -383,6 +462,7 @@ function classifySensitivity( if ( eventType === "user.input" || + readEditableKeydownContext(eventType, payload) !== null || eventType === "dom.snapshot" || eventType === "network.body" || eventType === "screen.screenshot" || @@ -492,6 +572,94 @@ function hasRawInputValue(payload: unknown): boolean { return typeof row.value === "string" || typeof row.text === "string"; } +type EditableKeydownContext = "input" | "protected" | "textarea" | "contenteditable"; + +function readEditableKeydownContext( + eventType: WebBlackboxEventType, + payload: unknown +): EditableKeydownContext | null { + if (eventType !== "user.keydown") { + return null; + } + + const row = asRecord(payload); + const declaredContext = row?.inputContext; + + if (declaredContext === "password") { + return "protected"; + } + + if ( + declaredContext === "input" || + declaredContext === "protected" || + declaredContext === "textarea" || + declaredContext === "contenteditable" + ) { + return declaredContext; + } + + const target = asRecord(row?.target); + const targetTag = typeof target?.tag === "string" ? target.tag.toUpperCase() : ""; + const targetType = + typeof target?.inputType === "string" + ? target.inputType + : typeof target?.type === "string" + ? target.type + : ""; + + if (targetTag === "INPUT") { + return targetType.toLowerCase() === "password" ? "protected" : "input"; + } + + if (targetTag === "TEXTAREA") { + return "textarea"; + } + + if (target?.contentEditable === true || target?.isContentEditable === true) { + return "contenteditable"; + } + + return null; +} + +function hasAnyKeyboardIdentity(payload: unknown): boolean { + const row = asRecord(payload); + return typeof row?.key === "string" || typeof row?.code === "string"; +} + +function hasSensitiveKeyboardIdentity(payload: unknown): boolean { + const row = asRecord(payload); + + if (!row) { + return false; + } + + const key = typeof row.key === "string" ? row.key : undefined; + const code = typeof row.code === "string" ? row.code : undefined; + + return ( + (key !== undefined && !isNonTextKeyboardKey(key, code ?? "")) || + (code !== undefined && isTextProducingKeyboardCode(code)) + ); +} + +function isNonTextKeyboardKey(key: string, code: string): boolean { + if (NON_TEXT_KEYBOARD_KEYS.has(key)) { + return !isTextProducingKeyboardCode(code); + } + + return /^F(?:[1-9]|1\d|2[0-4])$/.test(key) && /^F(?:[1-9]|1\d|2[0-4])$/.test(code); +} + +function isTextProducingKeyboardCode(code: string): boolean { + return ( + /^Key[A-Z]$/.test(code) || + /^Digit\d$/.test(code) || + /^Numpad(?:\d|Add|Comma|Decimal|Divide|Equal|Multiply|Subtract)$/.test(code) || + TEXT_PRODUCING_KEYBOARD_CODES.has(code) + ); +} + function hasConsoleTextPayload(payload: unknown): boolean { const row = asRecord(payload); @@ -539,6 +707,7 @@ function hasRedactionSignal(payload: unknown): boolean { return ( row.redacted === true || row.valueRedacted === true || + row.keyRedacted === true || row.selectorRedacted === true || (target !== null && typeof target === "object" && diff --git a/packages/webblackbox/src/lite-capture-agent.test.ts b/packages/webblackbox/src/lite-capture-agent.test.ts index 283d789..b4d04f3 100644 --- a/packages/webblackbox/src/lite-capture-agent.test.ts +++ b/packages/webblackbox/src/lite-capture-agent.test.ts @@ -34,6 +34,16 @@ const MASKED_SCREENSHOT_TEST_CAPTURE_POLICY: CapturePolicy = { } }; +function capturePolicyWithInputs(inputs: CapturePolicy["categories"]["inputs"]): CapturePolicy { + return { + ...DEFAULT_CAPTURE_POLICY, + categories: { + ...DEFAULT_CAPTURE_POLICY.categories, + inputs + } + }; +} + function createAgent( state: Partial = {}, options: Partial = {} @@ -185,13 +195,22 @@ function countEmittedEvents(emitBatch: ReturnType): number { }, 0); } -function emittedRawTypes(emitBatch: ReturnType): string[] { +type EmittedRawEvent = { + rawType?: string; + payload?: Record; +}; + +function emittedEvents(emitBatch: ReturnType): EmittedRawEvent[] { return emitBatch.mock.calls.flatMap((call) => { - const [events] = call as [Array<{ rawType?: string }>]; - return events.map((event) => event.rawType ?? ""); + const [events] = call as [EmittedRawEvent[]]; + return events; }); } +function emittedRawTypes(emitBatch: ReturnType): string[] { + return emittedEvents(emitBatch).map((event) => event.rawType ?? ""); +} + describe("LiteCaptureAgent", () => { beforeEach(() => { vi.useFakeTimers(); @@ -1150,6 +1169,196 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("retains editable printable key identity only when input capture is allowed", () => { + const { agent, emitBatch } = createAgent({ + capturePolicy: capturePolicyWithInputs("allow") + }); + const field = inputTarget(); + + field.dispatchEvent( + new KeyboardEvent("keydown", { + key: "A", + code: "KeyA", + bubbles: true + }) + ); + agent.flush(); + + const keydown = emittedEvents(emitBatch).find((event) => event.rawType === "keydown"); + + expect(keydown?.payload).toMatchObject({ + inputContext: "input", + key: "A", + code: "KeyA" + }); + expect(keydown?.payload).not.toHaveProperty("keyRedacted"); + + agent.dispose(); + }); + + it.each(["length-only", "masked"] as const)( + "redacts editable printable key identity under the %s input policy", + (inputs) => { + const { agent, emitBatch } = createAgent({ + capturePolicy: capturePolicyWithInputs(inputs) + }); + const field = inputTarget(); + + field.dispatchEvent( + new KeyboardEvent("keydown", { + key: "A", + code: "KeyA", + bubbles: true + }) + ); + agent.flush(); + + const keydown = emittedEvents(emitBatch).find((event) => event.rawType === "keydown"); + + expect(keydown?.payload).toMatchObject({ + inputContext: "input", + keyRedacted: true, + keyKind: "printable" + }); + expect(keydown?.payload).not.toHaveProperty("key"); + expect(keydown?.payload).not.toHaveProperty("code"); + + agent.dispose(); + } + ); + + it("omits editable keydown events when input capture is disabled", () => { + const { agent, emitBatch } = createAgent({ + capturePolicy: capturePolicyWithInputs("none") + }); + + inputTarget().dispatchEvent( + new KeyboardEvent("keydown", { + key: "A", + code: "KeyA", + bubbles: true + }) + ); + agent.flush(); + + expect(emittedRawTypes(emitBatch)).not.toContain("keydown"); + + agent.dispose(); + }); + + it("never retains password key identity even when input capture is allowed", () => { + const { agent, emitBatch } = createAgent({ + capturePolicy: capturePolicyWithInputs("allow") + }); + const field = inputTarget(); + field.type = "password"; + + field.dispatchEvent( + new KeyboardEvent("keydown", { + key: "A", + code: "KeyA", + bubbles: true + }) + ); + agent.flush(); + + const keydown = emittedEvents(emitBatch).find((event) => event.rawType === "keydown"); + + expect(keydown?.payload).toMatchObject({ + inputContext: "protected", + keyRedacted: true, + keyKind: "protected" + }); + expect(keydown?.payload).not.toHaveProperty("key"); + expect(keydown?.payload).not.toHaveProperty("code"); + + agent.dispose(); + }); + + it("sanitizes textarea printable keys under length-only input capture", () => { + document.body.insertAdjacentHTML("beforeend", ''); + const textarea = document.querySelector("#notes-keydown"); + + if (!textarea) { + throw new Error("missing textarea target"); + } + + const { agent, emitBatch } = createAgent({ + capturePolicy: capturePolicyWithInputs("length-only") + }); + + textarea.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Z", + code: "KeyZ", + bubbles: true + }) + ); + agent.flush(); + + const keydown = emittedEvents(emitBatch).find((event) => event.rawType === "keydown"); + + expect(keydown?.payload).toMatchObject({ + inputContext: "textarea", + keyRedacted: true, + keyKind: "printable" + }); + expect(keydown?.payload).not.toHaveProperty("key"); + expect(keydown?.payload).not.toHaveProperty("code"); + + agent.dispose(); + }); + + it("sanitizes contenteditable text keys while retaining non-text navigation keys", () => { + document.body.insertAdjacentHTML( + "beforeend", + '
editable
' + ); + const editor = document.querySelector("#editor-keydown"); + + if (!editor) { + throw new Error("missing contenteditable target"); + } + + const { agent, emitBatch } = createAgent({ + capturePolicy: capturePolicyWithInputs("length-only") + }); + + editor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "A", + code: "KeyA", + bubbles: true + }) + ); + editor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + bubbles: true + }) + ); + agent.flush(); + + const keydowns = emittedEvents(emitBatch).filter((event) => event.rawType === "keydown"); + + expect(keydowns).toHaveLength(2); + expect(keydowns[0]?.payload).toMatchObject({ + inputContext: "contenteditable", + keyRedacted: true, + keyKind: "printable" + }); + expect(keydowns[0]?.payload).not.toHaveProperty("key"); + expect(keydowns[0]?.payload).not.toHaveProperty("code"); + expect(keydowns[1]?.payload).toMatchObject({ + inputContext: "contenteditable", + key: "Enter", + code: "Enter" + }); + + agent.dispose(); + }); + it("enriches input selectors after the hot path", async () => { const { agent, emitBatch } = createAgent(); const field = inputTarget(); diff --git a/packages/webblackbox/src/lite-capture-agent.ts b/packages/webblackbox/src/lite-capture-agent.ts index 1d9d0d9..4ed8547 100644 --- a/packages/webblackbox/src/lite-capture-agent.ts +++ b/packages/webblackbox/src/lite-capture-agent.ts @@ -102,6 +102,41 @@ const FULL_MODE_SKIPPED_RAW_TYPES = new Set([ "cookieSnapshot" ]); +const NON_TEXT_KEYBOARD_KEYS = new Set([ + "Alt", + "AltGraph", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "ArrowUp", + "Backspace", + "CapsLock", + "ContextMenu", + "Control", + "Delete", + "End", + "Enter", + "Escape", + "Fn", + "FnLock", + "Home", + "Hyper", + "Insert", + "Meta", + "NumLock", + "NumpadEnter", + "PageDown", + "PageUp", + "Pause", + "PrintScreen", + "ScrollLock", + "Shift", + "Super", + "Symbol", + "SymbolLock", + "Tab" +]); + const DEFAULT_SAMPLING: LiteCaptureSampling = { mousemoveHz: 20, scrollHz: 15, @@ -134,6 +169,7 @@ type MutationBatchSummary = { type TargetPayloadDetail = "action" | "input" | "fast" | "navigation"; type CapturePressureStage = "none" | "soft" | "hard" | "critical"; +type EditableKeydownContext = "input" | "protected" | "textarea" | "contenteditable"; /** * Browser-side event capture agent used by `WebBlackboxLiteSdk`. @@ -453,16 +489,11 @@ export class LiteCaptureAgent { this.emitMarker("Keyboard marker"); } - this.queueEvent("keydown", { - key: event.key, - code: event.code, - repeat: event.repeat, - altKey: event.altKey, - ctrlKey: event.ctrlKey, - shiftKey: event.shiftKey, - metaKey: event.metaKey, - target: this.resolveTargetPayload(event.target, "fast") - }); + const payload = this.createKeydownPayload(event); + + if (payload) { + this.queueEvent("keydown", payload); + } }, INPUT_OPTIONS_TRUE ); @@ -1856,6 +1887,53 @@ export class LiteCaptureAgent { }; } + private createKeydownPayload(event: KeyboardEvent): Record | null { + const inputContext = resolveEditableKeydownContext(event.target); + + if (inputContext && this.capturePolicy.categories.inputs === "none") { + return null; + } + + const payload: Record = { + repeat: event.repeat, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + isComposing: event.isComposing, + target: this.resolveTargetPayload(event.target, "fast") + }; + + if (!inputContext) { + payload.key = event.key; + payload.code = event.code; + return payload; + } + + payload.inputContext = inputContext; + + const canRetainKeyIdentity = + inputContext !== "protected" && + (this.capturePolicy.categories.inputs === "allow" || + isNonTextKeyboardKey(event.key, event.code)); + + if (canRetainKeyIdentity) { + payload.key = event.key; + payload.code = event.code; + return payload; + } + + payload.keyRedacted = true; + payload.keyKind = + inputContext === "protected" + ? "protected" + : event.isComposing || event.key === "Dead" || event.key === "Process" + ? "composition" + : "printable"; + + return payload; + } + private resolveTargetPayload( target: EventTarget | null, detail: TargetPayloadDetail @@ -2251,11 +2329,50 @@ function buildDomSnapshotSummaryHtml(options: { } function isEditableInteractionTarget(target: EventTarget | null): boolean { - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { - return true; + return resolveEditableKeydownContext(target) !== null; +} + +function resolveEditableKeydownContext(target: EventTarget | null): EditableKeydownContext | null { + if (target instanceof HTMLInputElement) { + return target.type.toLowerCase() === "password" ? "protected" : "input"; } - return isRichTextEditableTarget(target); + if (target instanceof HTMLTextAreaElement) { + return "textarea"; + } + + return isRichTextEditableTarget(target) ? "contenteditable" : null; +} + +function isNonTextKeyboardKey(key: string, code: string): boolean { + if (NON_TEXT_KEYBOARD_KEYS.has(key)) { + return !isTextProducingKeyboardCode(code); + } + + return /^F(?:[1-9]|1\d|2[0-4])$/.test(key) && /^F(?:[1-9]|1\d|2[0-4])$/.test(code); +} + +function isTextProducingKeyboardCode(code: string): boolean { + return ( + /^Key[A-Z]$/.test(code) || + /^Digit\d$/.test(code) || + /^Numpad(?:\d|Add|Comma|Decimal|Divide|Equal|Multiply|Subtract)$/.test(code) || + code === "Space" || + code === "Quote" || + code === "Backquote" || + code === "Comma" || + code === "Period" || + code === "Slash" || + code === "Semicolon" || + code === "Equal" || + code === "Minus" || + code === "BracketLeft" || + code === "BracketRight" || + code === "Backslash" || + code === "IntlBackslash" || + code === "IntlRo" || + code === "IntlYen" + ); } function resolveNavigationTarget(target: EventTarget | null): HTMLAnchorElement | null { From b564aab1b17d0cc7f9a6c759de271495d05cc669 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:28:04 +0800 Subject: [PATCH 006/181] fix(sdk): surface capture queue failures --- packages/webblackbox/src/lite-sdk.test.ts | 69 ++++++++++++++++++++++- packages/webblackbox/src/lite-sdk.ts | 65 +++++++++++++++++---- 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/packages/webblackbox/src/lite-sdk.test.ts b/packages/webblackbox/src/lite-sdk.test.ts index 4d5e108..75e1c40 100644 --- a/packages/webblackbox/src/lite-sdk.test.ts +++ b/packages/webblackbox/src/lite-sdk.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { readWebBlackboxArchive } from "@webblackbox/pipeline"; +import { MemoryPipelineStorage, readWebBlackboxArchive } from "@webblackbox/pipeline"; import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; import type { RawRecorderEvent } from "@webblackbox/recorder"; @@ -429,6 +429,73 @@ describe("WebBlackboxLiteSdk", () => { await sdk.dispose(); }); + it("surfaces raw materialization failures through flush and export", async () => { + const storage = new MemoryPipelineStorage(); + const putBlob = vi + .spyOn(storage, "putBlob") + .mockRejectedValue(new Error("simulated blob persistence failure")); + const sdk = new WebBlackboxLiteSdk({ + sid: "S-sdk-raw-queue-failure", + injectHooks: false, + useDefaultPlugins: false, + pipelineStorage: storage, + config: { + capturePolicy: HIGH_FIDELITY_TEST_POLICY + } + }); + + await sdk.start(); + sdk.ingestRawEvent( + createRawEvent("screenshot", { + dataUrl: `data:image/png;base64,${Buffer.from([1, 2, 3, 4]).toString("base64")}` + }) + ); + + await expect(sdk.flush()).rejects.toThrow( + /raw event ingestion failed: simulated blob persistence failure/i + ); + await expect(sdk.export({ stopCapture: false })).rejects.toThrow(/raw event ingestion failed/i); + expect(putBlob).toHaveBeenCalledTimes(1); + await expect(sdk.dispose()).rejects.toThrow(/raw event ingestion failed/i); + expect(mockRuntime.instances.at(-1)?.disposeCalls).toBe(1); + }); + + it("retains unacknowledged normalized events and rejects flush on persistence failure", async () => { + const storage = new MemoryPipelineStorage(); + const putChunk = vi + .spyOn(storage, "putChunk") + .mockRejectedValue(new Error("simulated chunk persistence failure")); + const sdk = new WebBlackboxLiteSdk({ + sid: "S-sdk-pipeline-queue-failure", + injectHooks: false, + useDefaultPlugins: false, + pipelineStorage: storage, + maxChunkBytes: 1, + config: { + capturePolicy: LOCAL_DEBUG_TEST_POLICY + } + }); + + await sdk.start(); + sdk.ingestRawEvent( + createRawEvent("marker", { + message: "must-not-be-silently-lost" + }) + ); + + await expect(sdk.flush()).rejects.toThrow( + /normalized event persistence failed: simulated chunk persistence failure/i + ); + expect(putChunk).toHaveBeenCalledTimes(1); + expect((sdk as unknown as { pipelineEventBuffer: unknown[] }).pipelineEventBuffer).toHaveLength( + 1 + ); + await expect(sdk.export({ stopCapture: false })).rejects.toThrow( + /normalized event persistence failed/i + ); + await expect(sdk.dispose()).rejects.toThrow(/normalized event persistence failed/i); + }); + it("respects explicit perf-freeze overrides", async () => { const sdk = new WebBlackboxLiteSdk({ sid: "S-sdk-freeze-override", diff --git a/packages/webblackbox/src/lite-sdk.ts b/packages/webblackbox/src/lite-sdk.ts index 93d3701..fe8ff67 100644 --- a/packages/webblackbox/src/lite-sdk.ts +++ b/packages/webblackbox/src/lite-sdk.ts @@ -69,6 +69,12 @@ export class WebBlackboxLiteSdk { private pipelineFlushScheduled = false; + private queueFailure: Error | null = null; + + private rawQueueFailed = false; + + private pipelineQueueFailed = false; + private started = false; private recording = false; @@ -235,7 +241,7 @@ export class WebBlackboxLiteSdk { public ingestRawEvents(rawEvents: RawRecorderEvent[]): void { this.assertNotDisposed(); - if (!this.started || rawEvents.length === 0) { + if (!this.started || rawEvents.length === 0 || this.rawQueueFailed) { return; } @@ -248,7 +254,7 @@ export class WebBlackboxLiteSdk { } }) .catch((error) => { - console.warn("[WebBlackboxLiteSdk] failed to ingest raw event batch", error); + this.recordQueueFailure("raw event ingestion", error); }); } @@ -333,12 +339,14 @@ export class WebBlackboxLiteSdk { return; } - if (this.started) { - await this.stop(); + try { + if (this.started) { + await this.stop(); + } + } finally { + this.captureAgent.dispose(); + this.disposed = true; } - - this.captureAgent.dispose(); - this.disposed = true; } private async ingestOne(rawEvent: RawRecorderEvent): Promise { @@ -359,6 +367,10 @@ export class WebBlackboxLiteSdk { } private enqueuePipelineIngest(event: WebBlackboxEvent): void { + if (this.pipelineQueueFailed) { + return; + } + this.pipelineEventBuffer.push(event); if (this.pipelineEventBuffer.length >= PIPELINE_BATCH_MAX_EVENTS) { @@ -382,7 +394,11 @@ export class WebBlackboxLiteSdk { this.pipelineFlushTimer = null; } - if (this.pipelineFlushScheduled || this.pipelineEventBuffer.length === 0) { + if ( + this.pipelineQueueFailed || + this.pipelineFlushScheduled || + this.pipelineEventBuffer.length === 0 + ) { return; } @@ -391,13 +407,14 @@ export class WebBlackboxLiteSdk { this.pipelineQueue = this.pipelineQueue .then(async () => { while (this.pipelineEventBuffer.length > 0) { - const batch = this.pipelineEventBuffer.splice(0, PIPELINE_BATCH_DRAIN_CHUNK_EVENTS); + const batch = this.pipelineEventBuffer.slice(0, PIPELINE_BATCH_DRAIN_CHUNK_EVENTS); if (batch.length === 0) { break; } await this.pipeline.ingestBatch(batch); + this.pipelineEventBuffer.splice(0, batch.length); if (this.pipelineEventBuffer.length > 0) { await waitForNextTick(); @@ -405,12 +422,12 @@ export class WebBlackboxLiteSdk { } }) .catch((error) => { - console.warn("[WebBlackboxLiteSdk] failed to ingest normalized event batch", error); + this.recordQueueFailure("normalized event persistence", error); }) .finally(() => { this.pipelineFlushScheduled = false; - if (this.pipelineEventBuffer.length > 0) { + if (!this.pipelineQueueFailed && this.pipelineEventBuffer.length > 0) { this.flushPipelineBufferIntoQueue(); } }); @@ -426,6 +443,32 @@ export class WebBlackboxLiteSdk { this.flushPipelineBufferIntoQueue(); await this.pipelineQueue; + this.assertQueuesHealthy(); + } + + private recordQueueFailure( + stage: "raw event ingestion" | "normalized event persistence", + error: unknown + ): void { + if (stage === "raw event ingestion") { + this.rawQueueFailed = true; + } else { + this.pipelineQueueFailed = true; + } + + const message = error instanceof Error ? error.message : String(error); + + if (!this.queueFailure) { + this.queueFailure = new Error(`WebBlackbox Lite SDK ${stage} failed: ${message}`); + } + + console.warn(`[WebBlackboxLiteSdk] ${stage} failed`, error); + } + + private assertQueuesHealthy(): void { + if (this.queueFailure) { + throw this.queueFailure; + } } private assertNotDisposed(): void { From 4ed5ef4e36e0b14874bba572228c77c0dff87284 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:28:00 +0800 Subject: [PATCH 007/181] fix: enforce archive export encryption policy --- apps/extension/CHANGELOG.md | 2 + apps/extension/src/offscreen/index.ts | 4 +- apps/extension/src/popup/index.test.ts | 27 +++- apps/extension/src/popup/index.ts | 16 +- apps/extension/src/sessions/index.test.ts | 16 +- apps/extension/src/sessions/index.ts | 16 +- apps/extension/src/shared/i18n.ts | 12 +- apps/extension/src/sw/index.ts | 8 +- packages/pipeline/CHANGELOG.md | 3 + packages/pipeline/README.md | 11 +- packages/pipeline/src/index.test.ts | 171 +++++++++++++++------- packages/pipeline/src/pipeline.ts | 43 +++--- 12 files changed, 213 insertions(+), 116 deletions(-) diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index 2f13afe..76b5970 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -11,6 +11,8 @@ ### Patch Changes +- Required an export passphrase in the popup and sessions UI and removed the automatic plaintext + local-export override from the service-worker/offscreen pipeline bridge. - Updated dependencies - @webblackbox/cdp-router@0.6.0 - @webblackbox/pipeline@0.6.0 diff --git a/apps/extension/src/offscreen/index.ts b/apps/extension/src/offscreen/index.ts index ab7851a..60becbb 100644 --- a/apps/extension/src/offscreen/index.ts +++ b/apps/extension/src/offscreen/index.ts @@ -37,7 +37,6 @@ type OffscreenPipelineRequest = { includeScreenRecordings?: boolean; maxArchiveBytes?: number; recentWindowMs?: number; - allowPlaintextLocalExport?: boolean; purge?: boolean; recordingId?: string; streamId?: string; @@ -276,8 +275,7 @@ async function processPipelineRequest(message: OffscreenPipelineRequest): Promis includeScreenshots: message.includeScreenshots, includeScreenRecordings: message.includeScreenRecordings, maxArchiveBytes: message.maxArchiveBytes, - recentWindowMs: message.recentWindowMs, - allowPlaintextLocalExport: message.allowPlaintextLocalExport + recentWindowMs: message.recentWindowMs }); return downloadExportedBundle( exported.fileName, diff --git a/apps/extension/src/popup/index.test.ts b/apps/extension/src/popup/index.test.ts index ef7d88b..00ef7ce 100644 --- a/apps/extension/src/popup/index.test.ts +++ b/apps/extension/src/popup/index.test.ts @@ -659,7 +659,7 @@ describe("popup export policy form", () => { expect(getExportButton().disabled).toBe(false); }); - it("exports without encryption when the passphrase prompt is left empty", async () => { + it("requires an export passphrase before posting the request", async () => { const port = new FakePort(); installChromeStub(port); @@ -679,15 +679,31 @@ describe("popup export policy form", () => { }); await flushPopup(); + port.postMessage.mockClear(); getExportButton().click(); await flushPopup(); getPassphraseSubmitButton().click(); await flushPopup(); + const passphraseInput = document.querySelector("#wb-passphrase-input"); + + if (!passphraseInput) { + throw new Error("missing passphrase input"); + } + + expect(port.postMessage).not.toHaveBeenCalled(); + expect(passphraseInput.validationMessage).toBe("A passphrase is required for this export."); + + passphraseInput.value = " export-secret "; + passphraseInput.dispatchEvent(new Event("input", { bubbles: true })); + getPassphraseSubmitButton().click(); + await flushPopup(); + expect(port.postMessage).toHaveBeenCalledWith({ kind: "ui.export", sid: "sid-empty-passphrase", + passphrase: " export-secret ", saveAs: false, policy: { includeScreenshots: false, @@ -1021,12 +1037,21 @@ describe("popup export policy form", () => { getExportButton().click(); await flushPopup(); + const passphraseInput = document.querySelector("#wb-passphrase-input"); + + if (!passphraseInput) { + throw new Error("missing passphrase input"); + } + + passphraseInput.value = "export-secret"; + passphraseInput.dispatchEvent(new Event("input", { bubbles: true })); getPassphraseSubmitButton().click(); await flushPopup(); expect(port.postMessage).toHaveBeenCalledWith({ kind: "ui.export", sid: "sid-none-export", + passphrase: "export-secret", saveAs: false, policy: { includeScreenshots: false, diff --git a/apps/extension/src/popup/index.ts b/apps/extension/src/popup/index.ts index 5eb1192..4ed4ac9 100644 --- a/apps/extension/src/popup/index.ts +++ b/apps/extension/src/popup/index.ts @@ -517,6 +517,7 @@ function openPassphraseDialog(): Promise { input.type = "password"; input.className = "wb-input wb-prompt-field"; input.autocomplete = "off"; + input.required = true; const actions = document.createElement("div"); actions.className = "wb-confirm-actions"; @@ -553,7 +554,14 @@ function openPassphraseDialog(): Promise { const submitPassphrase = (): void => { const passphrase = input.value; - finish(passphrase.trim().length > 0 ? passphrase : ""); + if (passphrase.trim().length === 0) { + input.setCustomValidity(t("popupPassphraseRequired")); + input.reportValidity(); + input.focus(); + return; + } + + finish(passphrase); }; const onKeydown = (event: KeyboardEvent): void => { @@ -700,7 +708,7 @@ async function exportSessionFromPopup( sendUiMessage({ kind: "ui.export", sid, - ...(hasDialogPassphrase(passphrase) ? { passphrase } : {}), + passphrase, saveAs: false, policy }) @@ -728,10 +736,6 @@ async function exportSessionFromPopup( } } -function hasDialogPassphrase(passphrase: string): boolean { - return passphrase.length > 0; -} - function isSuccessfulExportResponse(value: unknown): value is { ok: true; fileName?: string; diff --git a/apps/extension/src/sessions/index.test.ts b/apps/extension/src/sessions/index.test.ts index 8188b35..b711e14 100644 --- a/apps/extension/src/sessions/index.test.ts +++ b/apps/extension/src/sessions/index.test.ts @@ -114,7 +114,7 @@ describe("sessions page rendering", () => { expect(document.getElementById("pwned")).toBeNull(); }); - it("exports without encryption when the passphrase prompt is left empty", async () => { + it("requires an export passphrase before posting the request", async () => { const port = new FakePort(); installChromeStub(port); @@ -134,28 +134,22 @@ describe("sessions page rendering", () => { }); await flushSessions(); + port.postMessage.mockClear(); document.querySelector("button[data-export]")?.click(); await flushSessions(); document.querySelector("[data-passphrase-submit]")?.click(); await flushSessions(); - expect(port.postMessage).toHaveBeenCalledWith({ - kind: "ui.export", - sid: "sid-export", - saveAs: false - }); - - port.postMessage.mockClear(); - document.querySelector("button[data-export]")?.click(); - await flushSessions(); - const passphraseInput = document.querySelector("#wb-passphrase-input"); if (!passphraseInput) { throw new Error("missing passphrase input"); } + expect(port.postMessage).not.toHaveBeenCalled(); + expect(passphraseInput.validationMessage).toBe("A passphrase is required for this export."); + passphraseInput.value = " session-secret "; passphraseInput.dispatchEvent(new Event("input", { bubbles: true })); document.querySelector("[data-passphrase-submit]")?.click(); diff --git a/apps/extension/src/sessions/index.ts b/apps/extension/src/sessions/index.ts index aec2d24..80f2298 100644 --- a/apps/extension/src/sessions/index.ts +++ b/apps/extension/src/sessions/index.ts @@ -338,7 +338,7 @@ function bindActions(container: HTMLElement): void { postUiMessage({ kind: "ui.export", sid, - ...(hasDialogPassphrase(passphrase) ? { passphrase } : {}), + passphrase, saveAs: false }); }); @@ -438,6 +438,7 @@ function openPassphraseDialog(sid: string): Promise { input.type = "password"; input.className = "wb-input wb-prompt-field"; input.autocomplete = "off"; + input.required = true; const actions = document.createElement("div"); actions.className = "wb-confirm-actions"; @@ -474,7 +475,14 @@ function openPassphraseDialog(sid: string): Promise { const submitPassphrase = (): void => { const passphrase = input.value; - finish(passphrase.trim().length > 0 ? passphrase : ""); + if (passphrase.trim().length === 0) { + input.setCustomValidity(t("popupPassphraseRequired")); + input.reportValidity(); + input.focus(); + return; + } + + finish(passphrase); }; const onKeydown = (event: KeyboardEvent): void => { @@ -511,10 +519,6 @@ function openPassphraseDialog(sid: string): Promise { }); } -function hasDialogPassphrase(passphrase: string): boolean { - return passphrase.length > 0; -} - function openConfirmDialog(message: string): Promise { return new Promise((resolve) => { const overlay = document.createElement("div"); diff --git a/apps/extension/src/shared/i18n.ts b/apps/extension/src/shared/i18n.ts index 31b6110..f99fba3 100644 --- a/apps/extension/src/shared/i18n.ts +++ b/apps/extension/src/shared/i18n.ts @@ -56,9 +56,9 @@ const EXTENSION_MESSAGES = { popupMarkerHint: "Marker: Ctrl/Cmd + Shift + M", popupExportPassphraseTitle: "Export Passphrase", popupExportPassphraseBody: - "Add an AES-GCM passphrase to encrypt this export. Leave blank to export without encryption.", + "Enter an AES-GCM passphrase. Real-user exports cannot be created without encryption.", popupPassphraseLabel: "Passphrase", - popupPassphraseRequired: "Enter a passphrase to encrypt this export.", + popupPassphraseRequired: "A passphrase is required for this export.", popupCancel: "Cancel", popupExporting: "Exporting...", popupExported: "Exported: {name}", @@ -150,7 +150,7 @@ const EXTENSION_MESSAGES = { sessionsDeletePrompt: "Delete session {sid}? This removes local archive data.", sessionsExportDialogTitle: "Export Session", sessionsExportDialogBody: - "Add an AES-GCM passphrase to encrypt this export. Leave blank to export without encryption.", + "Enter an AES-GCM passphrase. Real-user exports cannot be created without encryption.", sessionsConfirmDeleteTitle: "Confirm Delete", sessionsTitle: "Sessions" }, @@ -203,9 +203,9 @@ const EXTENSION_MESSAGES = { popupOptions: "设置", popupMarkerHint: "标记快捷键:Ctrl/Cmd + Shift + M", popupExportPassphraseTitle: "导出口令", - popupExportPassphraseBody: "填写 AES-GCM 口令可加密导出;留空则不加密导出。", + popupExportPassphraseBody: "请输入 AES-GCM 口令。真实用户会话必须加密后才能导出。", popupPassphraseLabel: "口令", - popupPassphraseRequired: "填写口令将加密导出。", + popupPassphraseRequired: "此次导出必须填写口令。", popupCancel: "取消", popupExporting: "正在导出...", popupExported: "已导出:{name}", @@ -291,7 +291,7 @@ const EXTENSION_MESSAGES = { sessionsFallbackTab: "标签页 {tabId}", sessionsDeletePrompt: "删除会话 {sid}?这会移除本地归档数据。", sessionsExportDialogTitle: "导出会话", - sessionsExportDialogBody: "填写 AES-GCM 口令可加密导出;留空则不加密导出。", + sessionsExportDialogBody: "请输入 AES-GCM 口令。真实用户会话必须加密后才能导出。", sessionsConfirmDeleteTitle: "确认删除", sessionsTitle: "会话" } diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index a437307..dc08fe1 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -218,7 +218,6 @@ type SessionPipelineClient = { includeScreenRecordings?: boolean; maxArchiveBytes?: number; recentWindowMs?: number; - allowPlaintextLocalExport?: boolean; }) => Promise; close: (options?: { purge?: boolean }) => Promise; }; @@ -249,7 +248,6 @@ type OffscreenPipelineRequest = { includeScreenRecordings?: boolean; maxArchiveBytes?: number; recentWindowMs?: number; - allowPlaintextLocalExport?: boolean; purge?: boolean; recordingId?: string; streamId?: string; @@ -1133,8 +1131,7 @@ async function exportSession( includeScreenshots: effectivePolicy.includeScreenshots, includeScreenRecordings: effectivePolicy.includeScreenRecordings, maxArchiveBytes: effectivePolicy.maxArchiveBytes, - recentWindowMs: effectivePolicy.recentWindowMs, - allowPlaintextLocalExport: !encrypted + recentWindowMs: effectivePolicy.recentWindowMs }); }); @@ -1943,8 +1940,7 @@ function createOffscreenPipelineClient(sid: string): SessionPipelineClient { includeScreenshots: options.includeScreenshots, includeScreenRecordings: options.includeScreenRecordings, maxArchiveBytes: options.maxArchiveBytes, - recentWindowMs: options.recentWindowMs, - allowPlaintextLocalExport: options.allowPlaintextLocalExport + recentWindowMs: options.recentWindowMs }); return normalizePipelineExportDownloadResult(exported); diff --git a/packages/pipeline/CHANGELOG.md b/packages/pipeline/CHANGELOG.md index 98e2930..9ecd986 100644 --- a/packages/pipeline/CHANGELOG.md +++ b/packages/pipeline/CHANGELOG.md @@ -9,6 +9,9 @@ ### Patch Changes +- Closed the legacy `allowPlaintextLocalExport` bypass: real-user and required-policy exports now + require a passphrase, while plaintext remains available only to trusted synthetic/local-debug + exemptions. - Updated dependencies - @webblackbox/protocol@0.6.0 diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md index 1e0e26d..27e1584 100644 --- a/packages/pipeline/README.md +++ b/packages/pipeline/README.md @@ -24,7 +24,7 @@ The event processing pipeline for WebBlackbox. Handles chunking, indexing, blob - **EventChunker** — Groups events into size-bounded chunks with codec support - **EventIndexer** — Builds time-based, request-based, and inverted text search indexes on demand from stored chunks - **Codec** — NDJSON chunk codec support for `none`, `gzip`, `br`, and `zst` -- **Archive Export** — Creates `.webblackbox` ZIP archives with optional AES-GCM encryption +- **Archive Export** — Creates policy-gated `.webblackbox` ZIP archives with AES-GCM encryption - **PipelineStorage** — Abstract storage interface with in-memory implementation - **IndexedDB Quota Recovery** — Indexed storage evicts oldest sessions on quota pressure (best-effort) @@ -68,7 +68,7 @@ const indexes = await pipeline.finalizeIndexes(); // Export as archive const result = await pipeline.exportBundle({ - passphrase: "optional-encryption-key", + passphrase: "archive-encryption-key", includeScreenshots: true, maxArchiveBytes: 100 * 1024 * 1024, recentWindowMs: 20 * 60 * 1000 @@ -79,6 +79,13 @@ console.log(`Exported: ${result.fileName} (${result.bytes.length} bytes)`); `includeScreenshots`, `maxArchiveBytes`, and `recentWindowMs` are optional export filters. If omitted, export includes the full retained session. +An export passphrase is required for real-user sessions, policies with `archive: "required"`, +and callers that do not supply a capture policy. Plaintext export is limited to synthetic or +local-debug policies that explicitly grant an exemption and whose `captureContextEvidenceRef` +is present in `trustedPlaintextExemptionEvidenceRefs`. The deprecated +`allowPlaintextLocalExport` option is retained for source compatibility but cannot bypass these +checks. + ### Optional At-Rest Storage Encryption `EncryptedPipelineStorage` encrypts chunk/blob cache payload bytes before persistence (for example when using IndexedDB storage). diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index 85c8edc..52d99b7 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest"; import JSZip from "jszip"; import { + type CapturePolicy, DEFAULT_CAPTURE_POLICY, type SessionMetadata, type WebBlackboxEvent } from "@webblackbox/protocol"; import { readWebBlackboxArchive } from "./exporter.js"; -import { FlightRecorderPipeline } from "./pipeline.js"; +import { FlightRecorderPipeline, type FlightRecorderPipelineOptions } from "./pipeline.js"; import { derivePipelineStorageKey, EncryptedPipelineStorage, @@ -31,6 +32,24 @@ const FULL_EXPORT_OPTIONS = { } as const; const TRUSTED_SYNTHETIC_EVIDENCE_REF = "synthetic-fixture:pipeline-export-0001"; const TRUSTED_LOCAL_DEBUG_EVIDENCE_REF = "local-attestation:low-risk-override-0001"; +const TRUSTED_PLAINTEXT_TEST_POLICY = { + ...DEFAULT_CAPTURE_POLICY, + captureContext: "synthetic", + captureContextEvidenceRef: TRUSTED_SYNTHETIC_EVIDENCE_REF, + encryption: { + localAtRest: "required", + archive: "synthetic-local-debug-exempt", + archiveKeyEnvelope: "none" + } +} satisfies CapturePolicy; + +function createTestPipeline(options: FlightRecorderPipelineOptions): FlightRecorderPipeline { + return new FlightRecorderPipeline({ + capturePolicy: TRUSTED_PLAINTEXT_TEST_POLICY, + trustedPlaintextExemptionEvidenceRefs: [TRUSTED_SYNTHETIC_EVIDENCE_REF], + ...options + }); +} function createEvent( id: string, @@ -95,7 +114,7 @@ function createNoisyPayload(size: number, seed: number): string { describe("pipeline", () => { it("rejects events without privacy classification", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 100 @@ -110,7 +129,7 @@ describe("pipeline", () => { it("chunks events and builds request index", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 100 @@ -131,7 +150,7 @@ describe("pipeline", () => { it("indexes request ids from nested request payloads", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-nested-request-id" @@ -164,7 +183,7 @@ describe("pipeline", () => { it("encodes and decodes chunk codecs when runtime support is available", async () => { for (const codec of ["gzip", "br", "zst"] as const) { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: `S-codec-${codec}` @@ -200,7 +219,7 @@ describe("pipeline", () => { it("sanitizes session URLs in export manifests", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-manifest-url-privacy", @@ -235,7 +254,7 @@ describe("pipeline", () => { it("applies the default export policy when no options are passed", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-default-export-policy" @@ -271,7 +290,7 @@ describe("pipeline", () => { it("allows local export and records scanner findings when raw secrets remain", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-scanner-warning" @@ -304,7 +323,7 @@ describe("pipeline", () => { it("blocks scanner findings when strict privacy scanning is requested", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-scanner-strict" @@ -325,9 +344,9 @@ describe("pipeline", () => { ); }); - it("requires encryption for real-user capture policies", async () => { + it("requires encryption for real-user capture policies even when the legacy plaintext flag is set", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-real-user-encryption" @@ -340,37 +359,53 @@ describe("pipeline", () => { await pipeline.start(); await pipeline.ingest(createEvent("E-real-user", "user.click", Date.now())); - await expect(pipeline.exportBundle()).rejects.toThrow(/encryption is required/i); + await expect(pipeline.exportBundle({ allowPlaintextLocalExport: true })).rejects.toThrow( + /real-user archives must be encrypted/i + ); }); - it("allows explicit plaintext local exports for real-user capture policies", async () => { + it("requires encryption when no capture policy can prove a trusted plaintext exemption", async () => { const storage = new MemoryPipelineStorage(); const pipeline = new FlightRecorderPipeline({ session: { ...SESSION, - sid: "S-real-user-plaintext-local" + sid: "S-missing-export-policy" }, storage, - maxChunkBytes: 512, - capturePolicy: DEFAULT_CAPTURE_POLICY + maxChunkBytes: 512 }); await pipeline.start(); - await pipeline.ingest(createEvent("E-real-user-plaintext", "user.click", Date.now())); + await pipeline.ingest(createEvent("E-missing-export-policy", "user.click", Date.now())); - const exported = await pipeline.exportBundle({ - allowPlaintextLocalExport: true - }); - const parsed = await readWebBlackboxArchive(exported.bytes); + await expect(pipeline.exportBundle({ allowPlaintextLocalExport: true })).rejects.toThrow( + /trusted synthetic or local-debug plaintext exemption/i + ); + }); - expect(parsed.manifest.encryption).toBeUndefined(); - expect(parsed.privacyManifest?.encryption.archive).toBe("plaintext"); - expect(parsed.privacyManifest?.transfer).toMatchObject({ - destination: "local-download", - archiveKeyEnvelope: "none", - encrypted: false, - shareEligible: false + it("requires encryption when a synthetic capture policy marks archives as required", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ + session: { + ...SESSION, + sid: "S-synthetic-encryption-required" + }, + storage, + maxChunkBytes: 512, + trustedPlaintextExemptionEvidenceRefs: [TRUSTED_SYNTHETIC_EVIDENCE_REF], + capturePolicy: { + ...DEFAULT_CAPTURE_POLICY, + captureContext: "synthetic", + captureContextEvidenceRef: TRUSTED_SYNTHETIC_EVIDENCE_REF + } }); + + await pipeline.start(); + await pipeline.ingest(createEvent("E-synthetic-required", "user.click", Date.now())); + + await expect(pipeline.exportBundle({ allowPlaintextLocalExport: true })).rejects.toThrow( + /encryption is required by the active capture policy/i + ); }); it("rejects plaintext capture-context exemptions without trusted evidence", async () => { @@ -380,7 +415,7 @@ describe("pipeline", () => { "local-attestation:forged-local-debug-0001" ]) { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: `S-plaintext-evidence-${evidenceRef ?? "missing"}` @@ -408,7 +443,7 @@ describe("pipeline", () => { it("allows plaintext synthetic exemptions with trusted evidence", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-trusted-synthetic-exemption" @@ -438,9 +473,41 @@ describe("pipeline", () => { expect(parsed.privacyManifest?.transfer?.archiveKeyEnvelope).toBe("none"); }); + it("allows plaintext local-debug exemptions with trusted evidence", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ + session: { + ...SESSION, + sid: "S-trusted-local-debug-exemption" + }, + storage, + maxChunkBytes: 512, + trustedPlaintextExemptionEvidenceRefs: [TRUSTED_LOCAL_DEBUG_EVIDENCE_REF], + capturePolicy: { + ...DEFAULT_CAPTURE_POLICY, + captureContext: "local-debug", + captureContextEvidenceRef: TRUSTED_LOCAL_DEBUG_EVIDENCE_REF, + encryption: { + localAtRest: "required", + archive: "synthetic-local-debug-exempt", + archiveKeyEnvelope: "none" + } + } + }); + + await pipeline.start(); + await pipeline.ingest(createEvent("E-trusted-local-debug", "user.click", Date.now())); + + const exported = await pipeline.exportBundle(); + const parsed = await readWebBlackboxArchive(exported.bytes); + + expect(parsed.manifest.encryption).toBeUndefined(); + expect(parsed.privacyManifest?.transfer?.archiveKeyEnvelope).toBe("none"); + }); + it("rejects explicit low-risk overrides when high-risk artifacts are present", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-low-risk-override-high-risk" @@ -475,7 +542,7 @@ describe("pipeline", () => { it("ingests batches without losing index coverage", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 120 @@ -499,7 +566,7 @@ describe("pipeline", () => { it("skips oversized hash/base64-like terms in inverted index", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 256 @@ -529,7 +596,7 @@ describe("pipeline", () => { it("deduplicates blobs by sha256", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 512 @@ -551,7 +618,7 @@ describe("pipeline", () => { ...SESSION, sid: "S-recovery" }; - const initialPipeline = new FlightRecorderPipeline({ + const initialPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -574,7 +641,7 @@ describe("pipeline", () => { ); await initialPipeline.flush(); - const recoveredPipeline = new FlightRecorderPipeline({ + const recoveredPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -600,7 +667,7 @@ describe("pipeline", () => { ...SESSION, sid: "S-recovery-seq" }; - const initialPipeline = new FlightRecorderPipeline({ + const initialPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -615,7 +682,7 @@ describe("pipeline", () => { ); await initialPipeline.flush(); - const recoveredPipeline = new FlightRecorderPipeline({ + const recoveredPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -638,7 +705,7 @@ describe("pipeline", () => { it("exports only blobs referenced by retained events", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -666,7 +733,7 @@ describe("pipeline", () => { it("exports and reads .webblackbox archive", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -693,7 +760,7 @@ describe("pipeline", () => { it("reads plain archives without global Web Crypto when Node crypto is available", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -725,7 +792,7 @@ describe("pipeline", () => { it("rejects archives with integrity mismatches on read", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -757,7 +824,7 @@ describe("pipeline", () => { it("rejects archives with undeclared event chunks on read", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -791,7 +858,7 @@ describe("pipeline", () => { it("writes provided redaction profile into export manifest", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128, @@ -879,7 +946,7 @@ describe("pipeline", () => { const storage = new EncryptedPipelineStorage(baseStorage, { key: key.key }); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -915,7 +982,7 @@ describe("pipeline", () => { it("supports export filtering by screenshot and recent time window", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -951,7 +1018,7 @@ describe("pipeline", () => { it("supports independent export filtering for screen recordings", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 128 @@ -1023,7 +1090,7 @@ describe("pipeline", () => { startedAt: sessionEnd - 60 * 60 * 1000, endedAt: sessionEnd }; - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -1052,7 +1119,7 @@ describe("pipeline", () => { sid: "S-export-live-anchor", startedAt: now - 60 * 60 * 1000 }; - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -1072,7 +1139,7 @@ describe("pipeline", () => { it("limits exported archive size to recent suffix of chunks", async () => { const storage = new MemoryPipelineStorage(); - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 512 @@ -1112,12 +1179,12 @@ describe("pipeline", () => { ...SESSION, sid: "S-purge-b" }; - const pipelineA = new FlightRecorderPipeline({ + const pipelineA = createTestPipeline({ session: sessionA, storage, maxChunkBytes: 128 }); - const pipelineB = new FlightRecorderPipeline({ + const pipelineB = createTestPipeline({ session: sessionB, storage, maxChunkBytes: 128 diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index 3469072..17e086d 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -44,6 +44,11 @@ export type ExportBundleOptions = { maxArchiveBytes?: number | null; recentWindowMs?: number | null; strictPrivacyScanner?: boolean; + /** + * @deprecated Plaintext export eligibility is determined exclusively by the active capture + * policy and trusted capture-context evidence. This flag is retained for source compatibility + * and cannot bypass encryption requirements. + */ allowPlaintextLocalExport?: boolean; }; @@ -308,40 +313,32 @@ export class FlightRecorderPipeline { } private assertExportEncryptionPolicy(options: ExportBundleOptions): void { - const policy = this.options.capturePolicy; - - if (!policy) { - return; - } - const hasPassphrase = typeof options.passphrase === "string" && options.passphrase.length > 0; - if (!hasPassphrase && options.allowPlaintextLocalExport === true) { + if (hasPassphrase) { return; } - if (policy.encryption.archive === "required" && !hasPassphrase) { - throw new Error("Export encryption is required by the active capture policy."); - } + const policy = this.options.capturePolicy; - if ( - !hasPassphrase && - (policy.encryption.archive === "synthetic-local-debug-exempt" || - policy.encryption.archive === "explicit-low-risk-override") - ) { - assertTrustedPlaintextExemptionEvidence( - policy, - this.options.trustedPlaintextExemptionEvidenceRefs + if (!policy) { + throw new Error( + "Export encryption is required unless the active capture policy grants a trusted synthetic or local-debug plaintext exemption." ); } - if ( - policy.captureContext === "real-user" && - policy.encryption.archive !== "synthetic-local-debug-exempt" && - !hasPassphrase - ) { + if (policy.captureContext === "real-user") { throw new Error("Real-user archives must be encrypted before export or share."); } + + if (policy.encryption.archive === "required") { + throw new Error("Export encryption is required by the active capture policy."); + } + + assertTrustedPlaintextExemptionEvidence( + policy, + this.options.trustedPlaintextExemptionEvidenceRefs + ); } private async listSessionBlobs(): Promise { From 342b83ff6d340321791d5f33cf9c8463188517ed Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:31:19 +0800 Subject: [PATCH 008/181] fix(share): reject invalid API key scopes --- apps/share-server/README.md | 2 +- apps/share-server/src/auth-config.test.ts | 47 ++++++++++ apps/share-server/src/auth-config.ts | 103 ++++++++++++++++++++++ apps/share-server/src/index.ts | 79 +---------------- 4 files changed, 153 insertions(+), 78 deletions(-) create mode 100644 apps/share-server/src/auth-config.test.ts create mode 100644 apps/share-server/src/auth-config.ts diff --git a/apps/share-server/README.md b/apps/share-server/README.md index bc8d307..cd76ba5 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -26,7 +26,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_API_KEY`: API key for `/api/share/*` and `/share/*` routes. If unset, protected routes are limited to loopback clients (`127.0.0.1` / `::1`). When set, clients can authenticate with either: - `x-webblackbox-api-key: `, or - `authorization: Bearer ` -- `WEBBLACKBOX_SHARE_API_KEYS`: semicolon-separated scoped keys for rotation and least privilege. Format: `secret:scope,scope;next-secret:scope`. Supported scopes are `upload`, `read`, `list`, `revoke`, and `admin`. `admin` covers all scopes. Keep an old key and a new key configured during rotation, then remove the old key after clients are updated. +- `WEBBLACKBOX_SHARE_API_KEYS`: semicolon-separated scoped keys for rotation and least privilege. Format: `secret:scope,scope;next-secret:scope`. Supported scopes are `upload`, `read`, `list`, `revoke`, and `admin`. `admin` covers all scopes. Unknown or empty scopes and duplicate secrets fail startup instead of falling back to `admin`. A key without `:scope` retains the legacy explicit-admin behavior. Keep an old key and a new key configured during rotation, then remove the old key after clients are updated. - `WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY`: optional browser bootstrap for `GET /share/:id?key=`. Keep this disabled in production unless the key is short-lived; when enabled, the server redirects to a clean URL and uses a short HttpOnly read-session cookie for page links. - `WEBBLACKBOX_SHARE_BIND_HOST`: bind host for the HTTP server (default `127.0.0.1`). - `WEBBLACKBOX_SHARE_ALLOWED_ORIGIN`: CORS allow origin. Defaults to `same-origin`. Use `*` only for trusted environments. diff --git a/apps/share-server/src/auth-config.test.ts b/apps/share-server/src/auth-config.test.ts new file mode 100644 index 0000000..be3d0d3 --- /dev/null +++ b/apps/share-server/src/auth-config.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { parseShareApiCredentials } from "./auth-config.js"; + +describe("share API credential configuration", () => { + it("parses explicit least-privilege scopes", () => { + const credentials = parseShareApiCredentials( + "upload-key:upload;reader-key:read,list;ops-key:revoke", + null + ); + + expect(credentials.map(({ secret, scopes }) => [secret, [...scopes]])).toEqual([ + ["upload-key", ["upload"]], + ["reader-key", ["read", "list"]], + ["ops-key", ["revoke"]] + ]); + }); + + it("preserves explicit legacy admin keys without a scope suffix", () => { + const credentials = parseShareApiCredentials("legacy-admin", "legacy-env-admin"); + + expect(credentials.map(({ secret, scopes }) => [secret, [...scopes]])).toEqual([ + ["legacy-env-admin", ["admin"]], + ["legacy-admin", ["admin"]] + ]); + }); + + it.each([ + ["unknown scope", "key:uplod", "unknown scope 'uplod'"], + ["empty scope", "key:", "must declare at least one scope"], + ["mixed valid and invalid scopes", "key:read,typo", "unknown scope 'typo'"], + ["empty scope in a list", "key:read,", "unknown scope '(empty)'"], + ["empty secret", ":read", "secret cannot be empty"] + ])("rejects %s", (_label, value, expectedMessage) => { + expect(() => parseShareApiCredentials(value, null)).toThrow(expectedMessage); + }); + + it("rejects duplicate secrets across legacy and scoped configuration", () => { + expect(() => parseShareApiCredentials("duplicate:read", "duplicate")).toThrow( + /secrets must be unique/i + ); + }); + + it("rejects a non-empty credential list containing only separators", () => { + expect(() => parseShareApiCredentials(";;;", null)).toThrow(/at least one credential/i); + }); +}); diff --git a/apps/share-server/src/auth-config.ts b/apps/share-server/src/auth-config.ts new file mode 100644 index 0000000..82989d8 --- /dev/null +++ b/apps/share-server/src/auth-config.ts @@ -0,0 +1,103 @@ +export type ShareApiScope = "upload" | "read" | "list" | "revoke" | "admin"; + +export type ShareApiCredential = { + secret: string; + scopes: Set; +}; + +export function parseShareApiCredentials( + rawValue: string | undefined, + legacyAdminKey: string | null +): ShareApiCredential[] { + const credentials: ShareApiCredential[] = []; + const configuredSecrets = new Set(); + + if (legacyAdminKey) { + addCredential(credentials, configuredSecrets, legacyAdminKey, new Set(["admin"])); + } + + if (typeof rawValue !== "string" || rawValue.trim().length === 0) { + return credentials; + } + + const initialCredentialCount = credentials.length; + + for (const entry of rawValue.split(";")) { + const trimmed = entry.trim(); + + if (!trimmed) { + continue; + } + + const separatorIndex = trimmed.indexOf(":"); + const secret = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed; + + if (!secret) { + throw new Error("Share API credential secret cannot be empty."); + } + + if (separatorIndex < 0) { + addCredential(credentials, configuredSecrets, secret, new Set(["admin"])); + continue; + } + + const rawScopes = trimmed.slice(separatorIndex + 1).trim(); + + if (!rawScopes) { + throw new Error(`Share API credential '${secret}' must declare at least one scope.`); + } + + const scopes = new Set(); + + for (const rawScope of rawScopes.split(",")) { + const scope = rawScope.trim(); + const normalized = normalizeShareApiScope(scope); + + if (!normalized) { + throw new Error( + `Share API credential '${secret}' has unknown scope '${scope || "(empty)"}'.` + ); + } + + scopes.add(normalized); + } + + addCredential(credentials, configuredSecrets, secret, scopes); + } + + if (credentials.length === initialCredentialCount) { + throw new Error("WEBBLACKBOX_SHARE_API_KEYS must contain at least one credential."); + } + + return credentials; +} + +function addCredential( + credentials: ShareApiCredential[], + configuredSecrets: Set, + secret: string, + scopes: Set +): void { + if (configuredSecrets.has(secret)) { + throw new Error("Share API credential secrets must be unique."); + } + + configuredSecrets.add(secret); + credentials.push({ secret, scopes }); +} + +function normalizeShareApiScope(value: string): ShareApiScope | null { + const normalized = value.toLowerCase(); + + if ( + normalized === "upload" || + normalized === "read" || + normalized === "list" || + normalized === "revoke" || + normalized === "admin" + ) { + return normalized; + } + + return null; +} diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index baef230..a96916b 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -6,6 +6,8 @@ import { join, resolve } from "node:path"; import JSZip from "jszip"; import { WebBlackboxPlayer } from "@webblackbox/player-sdk"; +import { parseShareApiCredentials, type ShareApiScope } from "./auth-config.js"; + type ShareRecord = { id: string; createdAt: number; @@ -20,12 +22,6 @@ type ShareRecord = { type ShareAuditAction = "upload" | "list" | "metadata" | "download" | "page" | "revoke"; type ShareAuditOutcome = "ok" | "not-found" | "expired" | "revoked" | "blocked" | "error"; -type ShareApiScope = "upload" | "read" | "list" | "revoke" | "admin"; - -type ShareApiCredential = { - secret: string; - scopes: Set; -}; type ShareAuthorizationSource = "loopback" | "token" | "query" | "read-session"; type ShareAuthorizationResult = { authorized: boolean; @@ -1821,77 +1817,6 @@ function readOptionalSecret(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } -function parseShareApiCredentials( - rawValue: string | undefined, - legacyAdminKey: string | null -): ShareApiCredential[] { - const credentials: ShareApiCredential[] = []; - - if (legacyAdminKey) { - credentials.push({ - secret: legacyAdminKey, - scopes: new Set(["admin"]) - }); - } - - if (typeof rawValue !== "string" || rawValue.trim().length === 0) { - return credentials; - } - - for (const entry of rawValue.split(";")) { - const trimmed = entry.trim(); - - if (!trimmed) { - continue; - } - - const separatorIndex = trimmed.indexOf(":"); - const secret = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed; - const rawScopes = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "admin"; - - if (!secret) { - continue; - } - - const scopes = new Set(); - - for (const scope of rawScopes.split(",")) { - const normalized = normalizeShareApiScope(scope); - - if (normalized) { - scopes.add(normalized); - } - } - - if (scopes.size === 0) { - scopes.add("admin"); - } - - credentials.push({ - secret, - scopes - }); - } - - return credentials; -} - -function normalizeShareApiScope(value: string): ShareApiScope | null { - const normalized = value.trim().toLowerCase(); - - if ( - normalized === "upload" || - normalized === "read" || - normalized === "list" || - normalized === "revoke" || - normalized === "admin" - ) { - return normalized; - } - - return null; -} - function parseRateLimitCount(value: string | undefined, fallback: number): number { return parsePositiveInteger(value, fallback); } From a301d5fd76089da48743226f727a1e15f692abd6 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:32:30 +0800 Subject: [PATCH 009/181] fix(share): isolate audit sink failures --- apps/share-server/README.md | 1 + apps/share-server/src/index.test.ts | 23 ++++++++++++++++++++++- apps/share-server/src/index.ts | 18 +++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index cd76ba5..2ea5f90 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -113,3 +113,4 @@ Each share writes: - `audit/share-access.jsonl` (action, outcome, share id, timestamp, and client hash only) Audit logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. +Audit append failures are reported through operational logs but do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 9744815..70f89cb 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; @@ -359,6 +359,27 @@ describe("share-server", () => { expect(auditLog).not.toContain("webblackbox-share-"); }); + it("keeps committed operations successful when the audit sink is unavailable", async () => { + const server = await startShareServer(); + const auditPath = resolve(server.dataDir, "audit/share-access.jsonl"); + await rm(auditPath, { force: true }); + await mkdir(auditPath); + + const uploadPayload = await uploadEncryptedFixture(server); + const metadataResponse = await fetch( + `${server.baseUrl}/api/share/${uploadPayload.shareId}/meta`, + { + headers: { + "x-webblackbox-api-key": apiKey + } + } + ); + + expect(metadataResponse.status).toBe(200); + expect(server.child.exitCode).toBeNull(); + expect(server.logs.join("")).toContain("[share-server] audit append failed"); + }); + it("enforces scoped API keys", async () => { const uploadKey = "upload-scope-key"; const readKey = "read-scope-key"; diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index a96916b..66d6b1d 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -157,6 +157,14 @@ async function startShareServer(): Promise { const server = createServer((request, response) => { void routeRequest(request, response).catch((error) => { console.warn("[share-server] request failed", error); + + if (response.headersSent || response.writableEnded) { + if (!response.writableEnded) { + response.destroy(error instanceof Error ? error : undefined); + } + return; + } + respondJson(response, 500, { error: "Internal server error." }); @@ -1335,7 +1343,15 @@ async function writeShareAuditEvent( details: input.details }; - await appendFile(SHARE_AUDIT_LOG_PATH, `${JSON.stringify(event)}\n`, "utf8"); + try { + await appendFile(SHARE_AUDIT_LOG_PATH, `${JSON.stringify(event)}\n`, "utf8"); + } catch (error) { + console.warn("[share-server] audit append failed", { + action: input.action, + shareId: input.shareId, + error: redactText(error instanceof Error ? error.message : String(error), 240) + }); + } } function hashAuditValue(value: string): string { From f0ccdb88f886967397b9be8509a20c6e525d67b5 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:33:48 +0800 Subject: [PATCH 010/181] fix(protocol): validate event payloads in batches --- packages/protocol/src/index.test.ts | 47 +++++++++++++++++++++++++++++ packages/protocol/src/messages.ts | 6 ++-- packages/protocol/src/schemas.ts | 21 +++++++------ 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index 60dfb99..a2b9701 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -63,6 +63,53 @@ describe("protocol", () => { expect(result.success).toBe(true); }); + it("validates specialized event payloads inside batch messages", () => { + const validEvent = { + v: WEBBLACKBOX_PROTOCOL_VERSION, + sid: "S-batch", + tab: 3, + t: Date.now(), + mono: 42, + type: "network.request", + id: "E-valid", + data: { + reqId: "R-valid", + url: "https://example.com/api", + method: "GET" + } + }; + + expect( + validateMessage({ + t: "EVT.BATCH", + sid: "S-batch", + tabId: 3, + seq: 1, + events: [validEvent] + }).success + ).toBe(true); + + const invalid = validateMessage({ + t: "EVT.BATCH", + sid: "S-batch", + tabId: 3, + seq: 2, + events: [ + { + ...validEvent, + id: "E-invalid", + data: {} + } + ] + }); + + expect(invalid.success).toBe(false); + + if (!invalid.success) { + expect(invalid.error.issues.some((issue) => issue.path[0] === "events")).toBe(true); + } + }); + it("ships product-safe redaction defaults", () => { expect(DEFAULT_RECORDER_CONFIG.redaction.redactHeaders).toEqual( expect.arrayContaining(["authorization", "x-api-key", "x-csrf-token"]) diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1162dd5..5284e38 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { chunkCodecSchema, - eventEnvelopeSchema, freezeReasonSchema, - recorderConfigSchema + recorderConfigSchema, + webBlackboxEventSchema } from "./schemas.js"; const arrayBufferSchema = z.custom((value) => value instanceof ArrayBuffer, { @@ -53,7 +53,7 @@ export const eventBatchMessageSchema = z sid: z.string().min(1), tabId: z.number().int().nonnegative(), seq: z.number().int().nonnegative(), - events: z.array(eventEnvelopeSchema) + events: z.array(webBlackboxEventSchema) }) .strict(); diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 6f0cc9e..9da263d 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -623,18 +623,21 @@ export function validateEventData( return getEventPayloadSchema(type).safeParse(payload); } -export function validateEvent(event: unknown) { - const envelopeResult = eventEnvelopeSchema.safeParse(event); +export const webBlackboxEventSchema = eventEnvelopeSchema.superRefine((event, context) => { + const payloadResult = validateEventData(event.type, event.data); - if (!envelopeResult.success) { - return envelopeResult; + if (payloadResult.success) { + return; } - const payloadResult = validateEventData(envelopeResult.data.type, envelopeResult.data.data); - - if (!payloadResult.success) { - return payloadResult; + for (const issue of payloadResult.error.issues) { + context.addIssue({ + ...issue, + path: ["data", ...issue.path] + }); } +}); - return envelopeResult; +export function validateEvent(event: unknown) { + return webBlackboxEventSchema.safeParse(event); } From 82eacb88eba5b33571ab0782efd66cf35e7c54b3 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:39:02 +0800 Subject: [PATCH 011/181] fix(player): escape generated Playwright code --- apps/player/src/lib/playwright-script.test.ts | 40 +++++++++++++++++++ apps/player/src/lib/playwright-script.ts | 23 ++++++++++- packages/player-sdk/src/index.test.ts | 27 +++++++++++++ packages/player-sdk/src/index.ts | 25 ++++++++++-- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/apps/player/src/lib/playwright-script.test.ts b/apps/player/src/lib/playwright-script.test.ts index f26710f..3c90e66 100644 --- a/apps/player/src/lib/playwright-script.test.ts +++ b/apps/player/src/lib/playwright-script.test.ts @@ -1,4 +1,5 @@ import type { WebBlackboxEvent } from "@webblackbox/protocol"; +import { DiagnosticCategory, ModuleKind, ScriptTarget, transpileModule } from "typescript"; import { describe, expect, it } from "vitest"; import { generatePlaywrightScriptFromEvents } from "./playwright-script.js"; @@ -21,6 +22,21 @@ function event( }; } +function expectValidTypeScript(source: string): void { + const result = transpileModule(source, { + compilerOptions: { + module: ModuleKind.ESNext, + target: ScriptTarget.ES2022 + }, + reportDiagnostics: true + }); + const errors = result.diagnostics?.filter( + (diagnostic) => diagnostic.category === DiagnosticCategory.Error + ); + + expect(errors).toEqual([]); +} + describe("generatePlaywrightScriptFromEvents", () => { it("generates Playwright actions from already filtered playback events", () => { const script = generatePlaywrightScriptFromEvents( @@ -71,4 +87,28 @@ describe("generatePlaywrightScriptFromEvents", () => { expect(script).toContain("button.first"); expect(script).not.toContain("button.second"); }); + + it("escapes untrusted names and keeps masked selectors inside one comment line", () => { + const script = generatePlaywrightScriptFromEvents( + [ + event("E-malicious", 100, "user.input", { + target: { + selector: 'input[name=password]\nawait page.goto("https://attacker.invalid")' + }, + value: "[MASKED]" + }) + ], + { + name: "replay');\nthrow new Error('injected')\u2028//", + includeHarReplay: false + } + ); + + expect(script).toContain(`test("replay');\\nthrow new Error('injected')\\u2028//"`); + expect(script).toContain( + '// input on input[name=password] await page.goto("https://attacker.invalid") was masked in capture' + ); + expect(script).not.toContain('\nawait page.goto("https://attacker.invalid")'); + expectValidTypeScript(script); + }); }); diff --git a/apps/player/src/lib/playwright-script.ts b/apps/player/src/lib/playwright-script.ts index 1c195af..ac3a2bf 100644 --- a/apps/player/src/lib/playwright-script.ts +++ b/apps/player/src/lib/playwright-script.ts @@ -27,7 +27,7 @@ export function generatePlaywrightScriptFromEvents( const lines = [ "import { test } from '@playwright/test';", "", - `test('${name}', async ({ browser }) => {`, + `test(${toJavaScriptStringLiteral(name)}, async ({ browser }) => {`, " const context = await browser.newContext();", includeHarReplay ? " await context.routeFromHAR('./session.har', { notFound: 'fallback' });" @@ -73,7 +73,7 @@ function toPlaywrightLines(event: WebBlackboxEvent): string[] { } if (!value || value === "[MASKED]") { - return [` // input on ${selector} was masked in capture`]; + return [` // input on ${toSingleLineCommentText(selector)} was masked in capture`]; } return [` await page.fill(${JSON.stringify(selector)}, ${JSON.stringify(value)});`]; @@ -105,6 +105,25 @@ function toPlaywrightLines(event: WebBlackboxEvent): string[] { return []; } +function toJavaScriptStringLiteral(value: string): string { + return JSON.stringify(value).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"); +} + +function toSingleLineCommentText(value: string): string { + const withoutControls = [...value] + .map((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && + (codePoint < 32 || codePoint === 127 || codePoint === 0x2028 || codePoint === 0x2029) + ? " " + : character; + }) + .join(""); + const sanitized = withoutControls.replace(/\s+/g, " ").trim().slice(0, 240); + + return sanitized || "[redacted selector]"; +} + function readSelector(event: WebBlackboxEvent): string | null { const payload = asRecord(event.data); const target = asRecord(payload?.target); diff --git a/packages/player-sdk/src/index.test.ts b/packages/player-sdk/src/index.test.ts index 479e58f..3419ce2 100644 --- a/packages/player-sdk/src/index.test.ts +++ b/packages/player-sdk/src/index.test.ts @@ -1,11 +1,27 @@ import * as zlib from "node:zlib"; import JSZip from "jszip"; +import { DiagnosticCategory, ModuleKind, ScriptTarget, transpileModule } from "typescript"; import { describe, expect, it, vi } from "vitest"; import type { ChunkTimeIndexEntry, ExportManifest, WebBlackboxEvent } from "@webblackbox/protocol"; import { WebBlackboxPlayer } from "./index.js"; +function expectValidTypeScript(source: string): void { + const result = transpileModule(source, { + compilerOptions: { + module: ModuleKind.ESNext, + target: ScriptTarget.ES2022 + }, + reportDiagnostics: true + }); + const errors = result.diagnostics?.filter( + (diagnostic) => diagnostic.category === DiagnosticCategory.Error + ); + + expect(errors).toEqual([]); +} + describe("WebBlackboxPlayer", () => { it("opens archive and supports query/search/getBlob", async () => { const bytes = await createFixtureArchive(); @@ -586,6 +602,17 @@ describe("WebBlackboxPlayer", () => { expect(mockScript).toContain("context.route("); expect(mockScript).toContain("route.fulfill"); + const untrustedName = "replay');\nthrow new Error('injected')\u2029//"; + const namedScript = player.generatePlaywrightScript({ name: untrustedName }); + const namedMockScript = await player.generatePlaywrightMockScript({ + name: untrustedName, + maxMocks: 5 + }); + expect(namedScript).toContain(`test("replay');\\nthrow new Error('injected')\\u2029//"`); + expect(namedMockScript).toContain(`test("replay');\\nthrow new Error('injected')\\u2029//"`); + expectValidTypeScript(namedScript); + expectValidTypeScript(namedMockScript); + const domSnapshots = player.getDomSnapshots(); expect(domSnapshots).toHaveLength(2); expect(domSnapshots[0]?.contentHash).toBe("dom-hash-1"); diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index d6d17ad..da5c94f 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -1670,7 +1670,7 @@ export class WebBlackboxPlayer { const lines = [ "import { test } from '@playwright/test';", "", - `test('${name}', async ({ browser }) => {`, + `test(${toJavaScriptStringLiteral(name)}, async ({ browser }) => {`, " const context = await browser.newContext();", includeHarReplay ? " await context.routeFromHAR('./session.har', { notFound: 'fallback' });" @@ -1709,7 +1709,7 @@ export class WebBlackboxPlayer { const lines = [ "import { test } from '@playwright/test';", "", - `test('${name}', async ({ browser }) => {`, + `test(${toJavaScriptStringLiteral(name)}, async ({ browser }) => {`, " const context = await browser.newContext();" ]; @@ -2375,7 +2375,7 @@ function toPlaywrightLines(event: WebBlackboxEvent): string[] { } if (!value || value === "[MASKED]") { - return [` // input on ${selector} was masked in capture`]; + return [` // input on ${toSingleLineCommentText(selector)} was masked in capture`]; } return [` await page.fill(${JSON.stringify(selector)}, ${JSON.stringify(value)});`]; @@ -2407,6 +2407,25 @@ function toPlaywrightLines(event: WebBlackboxEvent): string[] { return []; } +function toJavaScriptStringLiteral(value: string): string { + return JSON.stringify(value).replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029"); +} + +function toSingleLineCommentText(value: string): string { + const withoutControls = [...value] + .map((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && + (codePoint < 32 || codePoint === 127 || codePoint === 0x2028 || codePoint === 0x2029) + ? " " + : character; + }) + .join(""); + const sanitized = withoutControls.replace(/\s+/g, " ").trim().slice(0, 240); + + return sanitized || "[redacted selector]"; +} + function readSelector(event: WebBlackboxEvent): string | null { const payload = asRecord(event.data); const target = asRecord(payload?.target); From e1f4c1f0bd2ade53dba6674bdfda2643328dc1b3 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:36:55 +0800 Subject: [PATCH 012/181] fix(privacy): redact structured response body values --- .../src/sw/body-capture-utils.test.ts | 158 ++++++- apps/extension/src/sw/body-capture-utils.ts | 91 +--- apps/extension/src/sw/index.ts | 56 +-- .../webblackbox/src/lite-materializer.test.ts | 86 +++- packages/webblackbox/src/lite-materializer.ts | 78 +--- .../src/response-body-redaction.ts | 402 ++++++++++++++++++ 6 files changed, 679 insertions(+), 192 deletions(-) create mode 100644 packages/webblackbox/src/response-body-redaction.ts diff --git a/apps/extension/src/sw/body-capture-utils.test.ts b/apps/extension/src/sw/body-capture-utils.test.ts index 90c0661..7b413ba 100644 --- a/apps/extension/src/sw/body-capture-utils.test.ts +++ b/apps/extension/src/sw/body-capture-utils.test.ts @@ -95,37 +95,179 @@ describe("body-capture utils", () => { it("redacts and truncates utf8 response bodies for capture", () => { const transformed = transformResponseBodyForCapture({ - body: `token=secret-123&${"x".repeat(5_000)}`, + body: `token=secret-123&message=${"界".repeat(5_000)}`, base64Encoded: false, + mimeType: "application/x-www-form-urlencoded; charset=utf-8", redactPatterns: ["secret-123"], maxBytes: 4_096, decodeBase64: decodeBase64ForTest }); + expect(transformed).not.toBeNull(); + + if (!transformed) { + throw new Error("Expected response body transformation to succeed"); + } + const sampledText = new TextDecoder().decode(transformed.sampledBytes); expect(transformed.redacted).toBe(true); expect(transformed.truncated).toBe(true); - expect(sampledText).toContain("[REDACTED]"); + expect(sampledText).toContain("%5BREDACTED%5D"); expect(transformed.sampledBytes.byteLength).toBe(4_096); + expect(() => + new TextDecoder("utf-8", { fatal: true }).decode(transformed.sampledBytes) + ).not.toThrow(); expect(transformed.originalBytes.byteLength).toBeGreaterThan( transformed.sampledBytes.byteLength ); }); - it("does not redact base64 response bodies", () => { + it("decodes and redacts textual base64 response bodies", () => { + const body = JSON.stringify({ profile: { password: "plain-secret" }, status: "ok" }); const transformed = transformResponseBodyForCapture({ - body: Buffer.from("plain-secret", "utf8").toString("base64"), + body: Buffer.from(body, "utf8").toString("base64"), base64Encoded: true, - redactPatterns: ["secret"], + mimeType: "application/json", + redactPatterns: ["password", "secret"], maxBytes: 64 * 1024, decodeBase64: decodeBase64ForTest }); - const sampledText = new TextDecoder().decode(transformed.sampledBytes); + expect(transformed).not.toBeNull(); + + if (!transformed) { + throw new Error("Expected response body transformation to succeed"); + } + + const sampled = JSON.parse(new TextDecoder().decode(transformed.sampledBytes)) as { + profile: { password: string }; + status: string; + }; - expect(transformed.redacted).toBe(false); + expect(transformed.redacted).toBe(true); expect(transformed.truncated).toBe(false); - expect(sampledText).toBe("plain-secret"); + expect(sampled).toEqual({ profile: { password: "[REDACTED]" }, status: "ok" }); + expect(JSON.stringify(sampled)).not.toContain("plain-secret"); + }); + + it("redacts nested JSON field values including UTF-8 secrets", () => { + const body = JSON.stringify({ + account: { + password: "密碼-不可保留", + nested: [{ apiKey: "key-123" }] + }, + displayName: "使用者" + }); + const transformed = transformResponseBodyForCapture({ + body, + base64Encoded: false, + mimeType: "application/problem+json", + redactPatterns: ["password", "apikey"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }); + + expect(transformed).not.toBeNull(); + + if (!transformed) { + throw new Error("Expected response body transformation to succeed"); + } + + const sampled = JSON.parse(new TextDecoder().decode(transformed.sampledBytes)) as { + account: { password: string; nested: Array<{ apiKey: string }> }; + displayName: string; + }; + + expect(sampled.account.password).toBe("[REDACTED]"); + expect(sampled.account.nested[0]?.apiKey).toBe("[REDACTED]"); + expect(sampled.displayName).toBe("使用者"); + expect(new TextDecoder().decode(transformed.sampledBytes)).not.toContain("不可保留"); + expect(new TextDecoder().decode(transformed.sampledBytes)).not.toContain("key-123"); + }); + + it("redacts sensitive form field values while retaining safe fields", () => { + const transformed = transformResponseBodyForCapture({ + body: "username=alice&password=hunter2&refresh_token=token-value&locale=zh-CN", + base64Encoded: false, + mimeType: "application/x-www-form-urlencoded", + redactPatterns: ["password", "token"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }); + + expect(transformed).not.toBeNull(); + + if (!transformed) { + throw new Error("Expected response body transformation to succeed"); + } + + const sampled = new URLSearchParams(new TextDecoder().decode(transformed.sampledBytes)); + + expect(sampled.get("username")).toBe("alice"); + expect(sampled.get("password")).toBe("[REDACTED]"); + expect(sampled.get("refresh_token")).toBe("[REDACTED]"); + expect(sampled.get("locale")).toBe("zh-CN"); + }); + + it("fails closed when structured response parsing fails", () => { + expect( + transformResponseBodyForCapture({ + body: '{"password":"unterminated}', + base64Encoded: false, + mimeType: "application/json", + redactPatterns: ["password"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }) + ).toBeNull(); + + expect( + transformResponseBodyForCapture({ + body: "password=%E0%A4%A", + base64Encoded: false, + mimeType: "application/x-www-form-urlencoded", + redactPatterns: ["password"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }) + ).toBeNull(); + }); + + it("fails closed for invalid UTF-8 base64 and non-text MIME bodies", () => { + expect( + transformResponseBodyForCapture({ + body: Buffer.from([0xff, 0xfe, 0xfd]).toString("base64"), + base64Encoded: true, + mimeType: "application/json", + redactPatterns: ["password"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }) + ).toBeNull(); + + expect( + transformResponseBodyForCapture({ + body: Buffer.from("plain-secret", "utf8").toString("base64"), + base64Encoded: true, + mimeType: "application/octet-stream", + redactPatterns: ["secret"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }) + ).toBeNull(); + }); + + it("fails closed for unstructured text when sensitive patterns require field redaction", () => { + expect( + transformResponseBodyForCapture({ + body: "password: hunter2", + base64Encoded: false, + mimeType: "text/plain", + redactPatterns: ["password"], + maxBytes: 64 * 1024, + decodeBase64: decodeBase64ForTest + }) + ).toBeNull(); }); }); diff --git a/apps/extension/src/sw/body-capture-utils.ts b/apps/extension/src/sw/body-capture-utils.ts index 2b02630..8e88eea 100644 --- a/apps/extension/src/sw/body-capture-utils.ts +++ b/apps/extension/src/sw/body-capture-utils.ts @@ -1,5 +1,7 @@ import type { CaptureMode, RecorderConfig } from "@webblackbox/protocol"; +export { transformResponseBodyForCapture } from "webblackbox/lite-materializer"; + export type BodyCaptureRule = { enabled: boolean; maxBytes: number; @@ -18,15 +20,6 @@ type RuleResolutionOptions = { fallbackMaxBytes?: number; }; -type TransformResponseBodyArgs = { - body: string; - base64Encoded: boolean; - redactPatterns: string[]; - maxBytes: number; - decodeBase64: (value: string) => Uint8Array; - redactionToken?: string; -}; - const DEFAULT_FALLBACK_MAX_BYTES = 256 * 1024; const DEFAULT_BODY_MIME_ALLOWLIST = [ "text/*", @@ -37,7 +30,6 @@ const DEFAULT_BODY_MIME_ALLOWLIST = [ "application/javascript", "application/x-www-form-urlencoded" ]; -const DEFAULT_REDACTION_TOKEN = "[REDACTED]"; export function resolveLiteBodyCaptureRule( config: BodyCaptureConfig, @@ -207,47 +199,6 @@ export function normalizeMimeType(value: string | null | undefined): string | un return normalized && normalized.length > 0 ? normalized : undefined; } -export function redactBodyText( - value: string, - patterns: string[], - redactionToken: string = DEFAULT_REDACTION_TOKEN -): { - value: string; - redacted: boolean; -} { - if (patterns.length === 0 || value.length === 0) { - return { - value, - redacted: false - }; - } - - let output = value; - let touched = false; - - for (const pattern of patterns) { - const normalized = pattern.trim(); - - if (!normalized) { - continue; - } - - const regex = new RegExp(normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); - - if (!regex.test(output)) { - continue; - } - - output = output.replace(regex, redactionToken); - touched = true; - } - - return { - value: output, - redacted: touched - }; -} - export function isTextualMimeType(mimeType: string): boolean { return ( mimeType.startsWith("text/") || @@ -269,44 +220,6 @@ export function isLikelyTextualResourceType(resourceType?: string): boolean { ); } -export function transformResponseBodyForCapture(args: TransformResponseBodyArgs): { - originalBytes: Uint8Array; - sampledBytes: Uint8Array; - redacted: boolean; - truncated: boolean; -} { - const originalBytes = args.base64Encoded - ? args.decodeBase64(args.body) - : new TextEncoder().encode(args.body); - - let candidateBytes = originalBytes; - let redacted = false; - - if (!args.base64Encoded && args.redactPatterns.length > 0) { - const redaction = redactBodyText( - args.body, - args.redactPatterns, - args.redactionToken ?? DEFAULT_REDACTION_TOKEN - ); - - if (redaction.redacted) { - candidateBytes = new TextEncoder().encode(redaction.value); - redacted = true; - } - } - - const maxBytes = normalizeBodyCaptureMaxBytes(args.maxBytes); - const truncated = candidateBytes.byteLength > maxBytes; - const sampledBytes = truncated ? candidateBytes.slice(0, maxBytes) : candidateBytes; - - return { - originalBytes, - sampledBytes, - redacted, - truncated - }; -} - function buildDefaultRule( config: BodyCaptureConfig, options: RuleResolutionOptions diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index dc08fe1..c932c76 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -57,7 +57,6 @@ import { normalizeBodyCaptureMaxBytes as normalizeBodyCaptureMaxBytesUtil, isTextualMimeType as isTextualMimeTypeUtil, normalizeMimeType as normalizeMimeTypeUtil, - redactBodyText as redactBodyTextUtil, resolveFullBodyCaptureRule as resolveFullBodyCaptureRuleUtil, resolveLiteBodyCaptureRule as resolveLiteBodyCaptureRuleUtil, transformResponseBodyForCapture @@ -1555,7 +1554,7 @@ async function materializeLiteNetworkBody( const url = asString(payload.url) ?? ""; const mimeType = normalizeMimeType(asString(payload.mimeType)); - if (!reqId || !body || (encoding !== "utf8" && encoding !== "base64")) { + if (!reqId || !body || !mimeType || (encoding !== "utf8" && encoding !== "base64")) { return null; } @@ -1565,30 +1564,23 @@ async function materializeLiteNetworkBody( return null; } - let bytes: Uint8Array; - let redacted = payload.redacted === true; - - if (encoding === "utf8") { - const redaction = redactBodyText(body, runtime.config.redaction.redactBodyPatterns); - redacted = redacted || redaction.redacted; - bytes = new TextEncoder().encode(redaction.value); - } else { - bytes = decodeBase64(body); - } + const transformed = transformResponseBodyForCapture({ + body, + base64Encoded: encoding === "base64", + mimeType, + redactPatterns: runtime.config.redaction.redactBodyPatterns, + maxBytes: captureRule.maxBytes, + redactionToken: LITE_BODY_REDACTED_TOKEN, + decodeBase64 + }); - if (bytes.byteLength === 0) { + if (!transformed) { return null; } - const size = normalizeNonNegativeInt(payload.size) ?? bytes.byteLength; + const size = normalizeNonNegativeInt(payload.size) ?? transformed.originalBytes.byteLength; const truncatedByInput = payload.truncated === true; - const maxBytes = captureRule.maxBytes; - const truncatedByLimit = bytes.byteLength > maxBytes; - const sampledBytes = truncatedByLimit ? bytes.slice(0, maxBytes) : bytes; - const contentHash = await runtime.pipeline.putBlob( - mimeType ?? "application/octet-stream", - sampledBytes - ); + const contentHash = await runtime.pipeline.putBlob(mimeType, transformed.sampledBytes); return { ...rawEvent, @@ -1598,9 +1590,9 @@ async function materializeLiteNetworkBody( contentHash, mimeType, size, - sampledSize: sampledBytes.byteLength, - truncated: truncatedByInput || truncatedByLimit || sampledBytes.byteLength < size, - redacted + sampledSize: transformed.sampledBytes.byteLength, + truncated: truncatedByInput || transformed.truncated, + redacted: payload.redacted === true || transformed.redacted } }; } @@ -2441,11 +2433,17 @@ async function captureResponseBody( const transformed = transformResponseBodyForCapture({ body: response.body, base64Encoded: response.base64Encoded === true, + mimeType: normalizedMime, redactPatterns: runtime.config.redaction.redactBodyPatterns, maxBytes: captureRule.maxBytes, redactionToken: LITE_BODY_REDACTED_TOKEN, decodeBase64 }); + + if (!transformed) { + return; + } + const hash = await runtime.pipeline.putBlob( metadata?.mimeType ?? "application/octet-stream", transformed.sampledBytes @@ -3443,16 +3441,6 @@ function normalizeMimeType(value: string | null): string | undefined { return normalizeMimeTypeUtil(value); } -function redactBodyText( - value: string, - patterns: string[] -): { - value: string; - redacted: boolean; -} { - return redactBodyTextUtil(value, patterns, LITE_BODY_REDACTED_TOKEN); -} - function normalizeFullModePayload(method: string, params: unknown): unknown { const payload = asRecord(params); diff --git a/packages/webblackbox/src/lite-materializer.test.ts b/packages/webblackbox/src/lite-materializer.test.ts index e2c2e96..83f8e01 100644 --- a/packages/webblackbox/src/lite-materializer.test.ts +++ b/packages/webblackbox/src/lite-materializer.test.ts @@ -166,7 +166,8 @@ describe("lite-materializer", () => { expect(result).not.toBeNull(); expect(putBlobCalls).toHaveLength(1); expect(putBlobCalls[0]?.mime).toBe("application/x-www-form-urlencoded"); - expect(putBlobCalls[0]?.text).toContain("[REDACTED]"); + expect(new URLSearchParams(putBlobCalls[0]?.text).get("token")).toBe("[REDACTED]"); + expect(putBlobCalls[0]?.text).not.toContain("secret-token"); expect(putBlobCalls[0]?.bytes.byteLength).toBeLessThanOrEqual(4 * 1024); expect(putBlobCalls[0]?.bytes.byteLength).toBeLessThan( new TextEncoder().encode(body).byteLength @@ -180,6 +181,89 @@ describe("lite-materializer", () => { }); }); + it("decodes base64 JSON and redacts nested sensitive values before persistence", async () => { + const config = cloneConfig(); + config.sampling.bodyCaptureMaxBytes = 64 * 1024; + const putBlobCalls: Array<{ mime: string; text: string }> = []; + const body = JSON.stringify({ + user: { + password: "密碼-不可保留", + details: [{ api_key: "api-secret-value" }] + }, + locale: "zh-CN" + }); + + const result = await materializeLiteRawEvent( + createRawEvent("networkBody", { + reqId: "R-base64-json", + url: "https://example.test/api/profile", + mimeType: "application/json; charset=utf-8", + encoding: "base64", + body: Buffer.from(body, "utf8").toString("base64"), + size: new TextEncoder().encode(body).byteLength + }), + { + config, + putBlob: async (mime, bytes) => { + putBlobCalls.push({ mime, text: new TextDecoder().decode(bytes) }); + return "hash-base64-json"; + } + } + ); + + expect(result).not.toBeNull(); + expect(putBlobCalls).toHaveLength(1); + expect(putBlobCalls[0]?.mime).toBe("application/json"); + + const persisted = JSON.parse(putBlobCalls[0]?.text ?? "") as { + user: { password: string; details: Array<{ api_key: string }> }; + locale: string; + }; + + expect(persisted.user.password).toBe("[REDACTED]"); + expect(persisted.user.details[0]?.api_key).toBe("[REDACTED]"); + expect(persisted.locale).toBe("zh-CN"); + expect(putBlobCalls[0]?.text).not.toContain("不可保留"); + expect(putBlobCalls[0]?.text).not.toContain("api-secret-value"); + expect(result?.payload).toMatchObject({ + contentHash: "hash-base64-json", + redacted: true, + truncated: false + }); + }); + + it("does not persist malformed structured or undecodable textual bodies", async () => { + const config = cloneConfig(); + config.sampling.bodyCaptureMaxBytes = 64 * 1024; + const putBlob = vi.fn(async () => "unexpected-hash"); + const context = { config, putBlob }; + + const malformedJson = await materializeLiteRawEvent( + createRawEvent("networkBody", { + reqId: "R-malformed-json", + url: "https://example.test/api/profile", + mimeType: "application/json", + encoding: "utf8", + body: '{"password":"unterminated}' + }), + context + ); + const invalidUtf8 = await materializeLiteRawEvent( + createRawEvent("networkBody", { + reqId: "R-invalid-utf8", + url: "https://example.test/api/profile", + mimeType: "application/json", + encoding: "base64", + body: Buffer.from([0xff, 0xfe, 0xfd]).toString("base64") + }), + context + ); + + expect(malformedJson).toBeNull(); + expect(invalidUtf8).toBeNull(); + expect(putBlob).not.toHaveBeenCalled(); + }); + it("does not persist network bodies without an allowed MIME type", async () => { const config = cloneConfig(); config.sampling.bodyCaptureMaxBytes = 4 * 1024; diff --git a/packages/webblackbox/src/lite-materializer.ts b/packages/webblackbox/src/lite-materializer.ts index b03128e..4eaee92 100644 --- a/packages/webblackbox/src/lite-materializer.ts +++ b/packages/webblackbox/src/lite-materializer.ts @@ -2,6 +2,9 @@ import type { RecorderConfig } from "@webblackbox/protocol"; import type { RawRecorderEvent } from "@webblackbox/recorder"; import type { LiteMaterializerContext } from "./types.js"; +import { transformResponseBodyForCapture } from "./response-body-redaction.js"; + +export { transformResponseBodyForCapture } from "./response-body-redaction.js"; const DEFAULT_NETWORK_BODY_MAX_BYTES = 256 * 1024; const DEFAULT_BODY_MIME_ALLOWLIST = [ @@ -13,7 +16,6 @@ const DEFAULT_BODY_MIME_ALLOWLIST = [ "application/javascript", "application/x-www-form-urlencoded" ]; -const REDACTED_TOKEN = "[REDACTED]"; const DEFAULT_SCREENSHOT_MAX_DATA_URL_LENGTH = 12 * 1024 * 1024; const DEFAULT_SCREENSHOT_MAX_BYTES = 6 * 1024 * 1024; const DEFAULT_DOM_SNAPSHOT_MAX_BYTES = 1_500 * 1024; @@ -258,7 +260,7 @@ async function materializeLiteNetworkBody( const url = asString(payload.url) ?? ""; const mimeType = normalizeMimeType(asString(payload.mimeType)); - if (!reqId || !body || (encoding !== "utf8" && encoding !== "base64")) { + if (!reqId || !body || !mimeType || (encoding !== "utf8" && encoding !== "base64")) { return null; } @@ -268,26 +270,22 @@ async function materializeLiteNetworkBody( return null; } - let bytes: Uint8Array; - let redacted = payload.redacted === true; - - if (encoding === "utf8") { - const redaction = redactBodyText(body, context.config.redaction.redactBodyPatterns); - redacted = redacted || redaction.redacted; - bytes = new TextEncoder().encode(redaction.value); - } else { - bytes = decodeBase64(body); - } + const transformed = transformResponseBodyForCapture({ + body, + base64Encoded: encoding === "base64", + mimeType, + redactPatterns: context.config.redaction.redactBodyPatterns, + maxBytes: captureRule.maxBytes, + decodeBase64 + }); - if (bytes.byteLength === 0) { + if (!transformed) { return null; } - const size = normalizeNonNegativeInt(payload.size) ?? bytes.byteLength; + const size = normalizeNonNegativeInt(payload.size) ?? transformed.originalBytes.byteLength; const truncatedByInput = payload.truncated === true; - const truncatedByLimit = bytes.byteLength > captureRule.maxBytes; - const sampledBytes = truncatedByLimit ? bytes.slice(0, captureRule.maxBytes) : bytes; - const contentHash = await context.putBlob(mimeType ?? "application/octet-stream", sampledBytes); + const contentHash = await context.putBlob(mimeType, transformed.sampledBytes); return { ...rawEvent, @@ -297,9 +295,9 @@ async function materializeLiteNetworkBody( contentHash, mimeType, size, - sampledSize: sampledBytes.byteLength, - truncated: truncatedByInput || truncatedByLimit || sampledBytes.byteLength < size, - redacted + sampledSize: transformed.sampledBytes.byteLength, + truncated: truncatedByInput || transformed.truncated, + redacted: payload.redacted === true || transformed.redacted } }; } @@ -481,46 +479,6 @@ function normalizeMimeType(value: string | null): string | undefined { return normalized && normalized.length > 0 ? normalized : undefined; } -function redactBodyText( - value: string, - patterns: string[] -): { - value: string; - redacted: boolean; -} { - if (patterns.length === 0 || value.length === 0) { - return { - value, - redacted: false - }; - } - - let output = value; - let touched = false; - - for (const pattern of patterns) { - const normalized = pattern.trim(); - - if (!normalized) { - continue; - } - - const regex = new RegExp(normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"); - - if (!regex.test(output)) { - continue; - } - - output = output.replace(regex, REDACTED_TOKEN); - touched = true; - } - - return { - value: output, - redacted: touched - }; -} - function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) diff --git a/packages/webblackbox/src/response-body-redaction.ts b/packages/webblackbox/src/response-body-redaction.ts new file mode 100644 index 0000000..6d278ac --- /dev/null +++ b/packages/webblackbox/src/response-body-redaction.ts @@ -0,0 +1,402 @@ +const DEFAULT_REDACTION_TOKEN = "[REDACTED]"; +const MAX_JSON_DEPTH = 128; +const MAX_JSON_NODES = 100_000; + +export type TransformResponseBodyArgs = { + body: string; + base64Encoded: boolean; + mimeType: string | undefined; + redactPatterns: string[]; + maxBytes: number; + decodeBase64: (value: string) => Uint8Array; + redactionToken?: string; +}; + +export type TransformedResponseBody = { + originalBytes: Uint8Array; + sampledBytes: Uint8Array; + redacted: boolean; + truncated: boolean; +}; + +type TextRedactionResult = { + value: string; + redacted: boolean; +}; + +type JsonRedactionState = { + nodes: number; + redacted: boolean; +}; + +/** + * Decodes, validates, redacts, and byte-caps a textual response body before persistence. + * Unsupported MIME types and bodies that cannot be parsed or decoded safely are rejected. + */ +export function transformResponseBodyForCapture( + args: TransformResponseBodyArgs +): TransformedResponseBody | null { + const mimeType = normalizeMimeType(args.mimeType); + + if (!mimeType || !isTextualMimeType(mimeType)) { + return null; + } + + const maxBytes = normalizeMaxBytes(args.maxBytes); + + if (maxBytes === 0) { + return null; + } + + const decoded = decodeBodyText(args); + + if (!decoded) { + return null; + } + + const patterns = normalizePatterns(args.redactPatterns); + const redaction = redactTextByMime( + decoded.text, + mimeType, + patterns, + args.redactionToken ?? DEFAULT_REDACTION_TOKEN + ); + + if (!redaction) { + return null; + } + + const candidateBytes = redaction.redacted + ? new TextEncoder().encode(redaction.value) + : decoded.bytes; + const truncated = candidateBytes.byteLength > maxBytes; + const sampledBytes = truncated ? truncateUtf8(candidateBytes, maxBytes) : candidateBytes; + + if (sampledBytes.byteLength === 0) { + return null; + } + + return { + originalBytes: decoded.bytes, + sampledBytes, + redacted: redaction.redacted, + truncated + }; +} + +function decodeBodyText( + args: TransformResponseBodyArgs +): { bytes: Uint8Array; text: string } | null { + if (!args.base64Encoded) { + return { + bytes: new TextEncoder().encode(args.body), + text: args.body + }; + } + + const normalizedBase64 = args.body.replace(/\s/g, ""); + + if (!isValidBase64(normalizedBase64)) { + return null; + } + + let bytes: Uint8Array; + + try { + bytes = args.decodeBase64(normalizedBase64); + } catch { + return null; + } + + const expectedLength = decodedBase64Length(normalizedBase64); + + if (bytes.byteLength !== expectedLength) { + return null; + } + + try { + return { + bytes, + text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) + }; + } catch { + return null; + } +} + +function redactTextByMime( + value: string, + mimeType: string, + patterns: string[], + redactionToken: string +): TextRedactionResult | null { + if (isJsonMimeType(mimeType)) { + return redactJsonText(value, patterns, redactionToken); + } + + if (mimeType === "application/x-www-form-urlencoded") { + return redactFormText(value, patterns, redactionToken); + } + + // Unstructured text, XML, and executable source cannot be redacted reliably by field name. + // Preserve explicit capture only when no sensitive patterns are configured; otherwise drop it. + if (patterns.length > 0) { + return null; + } + + return { + value, + redacted: false + }; +} + +function redactJsonText( + value: string, + patterns: string[], + redactionToken: string +): TextRedactionResult | null { + let parsed: unknown; + + try { + parsed = JSON.parse(stripByteOrderMark(value)) as unknown; + } catch { + return null; + } + + if (patterns.length === 0) { + return { + value, + redacted: false + }; + } + + const state: JsonRedactionState = { + nodes: 0, + redacted: false + }; + + let sanitized: unknown; + + try { + sanitized = redactJsonValue(parsed, patterns, redactionToken, state, 0); + } catch { + return null; + } + + if (!state.redacted) { + return { + value, + redacted: false + }; + } + + try { + return { + value: JSON.stringify(sanitized), + redacted: true + }; + } catch { + return null; + } +} + +function redactJsonValue( + value: unknown, + patterns: string[], + redactionToken: string, + state: JsonRedactionState, + depth: number +): unknown { + state.nodes += 1; + + if (depth > MAX_JSON_DEPTH || state.nodes > MAX_JSON_NODES) { + throw new Error("Response JSON exceeds redaction complexity limits"); + } + + if (typeof value === "string") { + if (containsSensitivePattern(value, patterns)) { + state.redacted = true; + return redactionToken; + } + + return value; + } + + if (Array.isArray(value)) { + return value.map((entry) => redactJsonValue(entry, patterns, redactionToken, state, depth + 1)); + } + + if (value === null || typeof value !== "object") { + return value; + } + + const output: Record = Object.create(null) as Record; + + for (const [key, entry] of Object.entries(value as Record)) { + const sensitiveField = matchesSensitiveField(key, patterns); + const nextValue = sensitiveField + ? redactionToken + : redactJsonValue(entry, patterns, redactionToken, state, depth + 1); + + if (sensitiveField) { + state.redacted = true; + } + + Object.defineProperty(output, key, { + value: nextValue, + enumerable: true, + configurable: true, + writable: true + }); + } + + return output; +} + +function redactFormText( + value: string, + patterns: string[], + redactionToken: string +): TextRedactionResult | null { + const entries: Array<[string, string]> = []; + + for (const segment of value.split("&")) { + if (segment.length === 0) { + continue; + } + + const equalsIndex = segment.indexOf("="); + const encodedKey = equalsIndex >= 0 ? segment.slice(0, equalsIndex) : segment; + const encodedValue = equalsIndex >= 0 ? segment.slice(equalsIndex + 1) : ""; + const key = decodeFormComponent(encodedKey); + const entryValue = decodeFormComponent(encodedValue); + + if (key === null || entryValue === null) { + return null; + } + + entries.push([key, entryValue]); + } + + if (patterns.length === 0) { + return { + value, + redacted: false + }; + } + + let redacted = false; + const output = new URLSearchParams(); + + for (const [key, entryValue] of entries) { + const sensitive = + matchesSensitiveField(key, patterns) || containsSensitivePattern(entryValue, patterns); + output.append(key, sensitive ? redactionToken : entryValue); + redacted = redacted || sensitive; + } + + return { + value: redacted ? output.toString() : value, + redacted + }; +} + +function decodeFormComponent(value: string): string | null { + try { + return decodeURIComponent(value.replace(/\+/g, " ")); + } catch { + return null; + } +} + +function normalizePatterns(patterns: string[]): string[] { + const output: string[] = []; + + for (const pattern of patterns) { + const normalized = pattern.trim().toLowerCase(); + + if (normalized && !output.includes(normalized)) { + output.push(normalized); + } + } + + return output; +} + +function matchesSensitiveField(field: string, patterns: string[]): boolean { + const normalizedField = field.toLowerCase(); + return patterns.some((pattern) => normalizedField.includes(pattern)); +} + +function containsSensitivePattern(value: string, patterns: string[]): boolean { + const normalizedValue = value.toLowerCase(); + return patterns.some((pattern) => normalizedValue.includes(pattern)); +} + +function normalizeMimeType(value: string | undefined): string | null { + if (!value) { + return null; + } + + const [mime] = value.split(";"); + const normalized = mime?.trim().toLowerCase(); + return normalized && normalized.length > 0 ? normalized : null; +} + +function isTextualMimeType(mimeType: string): boolean { + return ( + mimeType.startsWith("text/") || + isJsonMimeType(mimeType) || + mimeType.includes("xml") || + mimeType.includes("javascript") || + mimeType.includes("ecmascript") || + mimeType === "application/x-www-form-urlencoded" + ); +} + +function isJsonMimeType(mimeType: string): boolean { + return mimeType === "application/json" || mimeType === "text/json" || mimeType.endsWith("+json"); +} + +function normalizeMaxBytes(value: number): number { + if (!Number.isFinite(value) || value <= 0) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} + +function truncateUtf8(bytes: Uint8Array, maxBytes: number): Uint8Array { + let end = Math.min(bytes.byteLength, maxBytes); + const decoder = new TextDecoder("utf-8", { fatal: true }); + + while (end > 0) { + const candidate = bytes.slice(0, end); + + try { + decoder.decode(candidate); + return candidate; + } catch { + end -= 1; + } + } + + return new Uint8Array(); +} + +function stripByteOrderMark(value: string): string { + return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; +} + +function isValidBase64(value: string): boolean { + if (value.length === 0 || value.length % 4 !== 0) { + return false; + } + + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); +} + +function decodedBase64Length(value: string): number { + const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0; + return (value.length / 4) * 3 - padding; +} From 86dfa586af4ca91db341a167ffc40aa1d8570aa6 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:40:58 +0800 Subject: [PATCH 013/181] fix(player): send share query keys as headers --- apps/player/src/lib/share.test.ts | 45 +++++++++++++++++++++++++++++++ apps/player/src/lib/share.ts | 20 ++++++++------ apps/player/src/main.ts | 5 ++-- 3 files changed, 60 insertions(+), 10 deletions(-) create mode 100644 apps/player/src/lib/share.test.ts diff --git a/apps/player/src/lib/share.test.ts b/apps/player/src/lib/share.test.ts new file mode 100644 index 0000000..483fb7b --- /dev/null +++ b/apps/player/src/lib/share.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { resolveShareArchiveRequest } from "./share.js"; + +describe("resolveShareArchiveRequest", () => { + it("resolves a share id against the configured server", () => { + expect(resolveShareArchiveRequest("abcdefgh", "https://share.example.test")).toEqual({ + shareId: "abcdefgh", + baseUrl: "https://share.example.test", + archiveUrl: "https://share.example.test/api/share/abcdefgh/archive" + }); + }); + + it.each([ + "https://share.example.test/share/abcdefgh?key=query-secret", + "https://share.example.test/api/share/abcdefgh/meta?key=query-secret", + "https://share.example.test/api/share/abcdefgh/archive?key=query-secret" + ])("moves a query key from %s into an API header credential", (reference) => { + const resolved = resolveShareArchiveRequest(reference, "https://fallback.invalid"); + + expect(resolved).toEqual({ + shareId: "abcdefgh", + baseUrl: "https://share.example.test", + archiveUrl: "https://share.example.test/api/share/abcdefgh/archive", + queryApiKey: "query-secret" + }); + expect(resolved?.archiveUrl).not.toContain("key="); + }); + + it("does not propagate unrelated query parameters or fragments", () => { + const resolved = resolveShareArchiveRequest( + "https://share.example.test/share/abcdefgh?utm_source=test#secret-fragment", + "https://fallback.invalid" + ); + + expect(resolved?.archiveUrl).toBe("https://share.example.test/api/share/abcdefgh/archive"); + expect(resolved).not.toHaveProperty("queryApiKey"); + }); + + it("rejects unsupported references", () => { + expect( + resolveShareArchiveRequest("https://share.example.test/other/path", "https://fallback") + ).toBeNull(); + }); +}); diff --git a/apps/player/src/lib/share.ts b/apps/player/src/lib/share.ts index 17107c3..117b7c8 100644 --- a/apps/player/src/lib/share.ts +++ b/apps/player/src/lib/share.ts @@ -2,6 +2,7 @@ export type ShareArchiveRequest = { shareId: string; baseUrl: string; archiveUrl: string; + queryApiKey?: string; }; export function resolveShareArchiveRequest( @@ -36,11 +37,11 @@ export function resolveShareArchiveRequest( if (sharePageMatch?.[1]) { const shareId = sharePageMatch[1]; const baseUrl = parsed.origin; - const keySuffix = buildAuthQuerySuffix(parsed); return { shareId, baseUrl, - archiveUrl: `${baseUrl}/api/share/${encodeURIComponent(shareId)}/archive${keySuffix}` + archiveUrl: `${baseUrl}/api/share/${encodeURIComponent(shareId)}/archive`, + ...readQueryApiKey(parsed) }; } @@ -50,7 +51,8 @@ export function resolveShareArchiveRequest( return { shareId: archiveMatch[1], baseUrl: parsed.origin, - archiveUrl: parsed.toString() + archiveUrl: `${parsed.origin}${parsed.pathname}`, + ...readQueryApiKey(parsed) }; } @@ -58,11 +60,11 @@ export function resolveShareArchiveRequest( if (metadataMatch?.[1]) { const shareId = metadataMatch[1]; - const keySuffix = buildAuthQuerySuffix(parsed); return { shareId, baseUrl: parsed.origin, - archiveUrl: `${parsed.origin}/api/share/${encodeURIComponent(shareId)}/archive${keySuffix}` + archiveUrl: `${parsed.origin}/api/share/${encodeURIComponent(shareId)}/archive`, + ...readQueryApiKey(parsed) }; } @@ -102,11 +104,13 @@ export function resolveShareServerOrigin(value: string | null): string | null { } } -function buildAuthQuerySuffix(url: URL): string { +function readQueryApiKey(url: URL): Pick { const key = url.searchParams.get("key"); if (!key) { - return ""; + return {}; } - return `?key=${encodeURIComponent(key)}`; + return { + queryApiKey: key + }; } diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 9a5efec..1eff8ad 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -1941,8 +1941,9 @@ async function loadArchiveFromShareReference(reference: string, apiKey: string): try { const headers: Record = {}; - if (trimmedApiKey.length > 0) { - headers["x-webblackbox-api-key"] = trimmedApiKey; + const requestApiKey = trimmedApiKey || resolved.queryApiKey || ""; + if (requestApiKey.length > 0) { + headers["x-webblackbox-api-key"] = requestApiKey; } const response = await fetch(resolved.archiveUrl, { From e89d83d17cc763abc40326d626853a5e584bb64d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:42:58 +0800 Subject: [PATCH 014/181] fix(player): make latest archive load win --- apps/player/src/lib/latest-task.test.ts | 23 +++++++ apps/player/src/lib/latest-task.ts | 16 +++++ apps/player/src/main.ts | 80 +++++++++++++++++++++---- 3 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 apps/player/src/lib/latest-task.test.ts create mode 100644 apps/player/src/lib/latest-task.ts diff --git a/apps/player/src/lib/latest-task.test.ts b/apps/player/src/lib/latest-task.test.ts new file mode 100644 index 0000000..0ceda89 --- /dev/null +++ b/apps/player/src/lib/latest-task.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { LatestTaskGate } from "./latest-task.js"; + +describe("LatestTaskGate", () => { + it("allows only the most recently started task to commit", () => { + const gate = new LatestTaskGate(); + const slowFirst = gate.begin(); + const fastSecond = gate.begin(); + + expect(gate.isCurrent(fastSecond)).toBe(true); + expect(gate.isCurrent(slowFirst)).toBe(false); + }); + + it("invalidates pending tasks when the selection is cleared", () => { + const gate = new LatestTaskGate(); + const pending = gate.begin(); + + gate.invalidate(); + + expect(gate.isCurrent(pending)).toBe(false); + }); +}); diff --git a/apps/player/src/lib/latest-task.ts b/apps/player/src/lib/latest-task.ts new file mode 100644 index 0000000..6b70048 --- /dev/null +++ b/apps/player/src/lib/latest-task.ts @@ -0,0 +1,16 @@ +export class LatestTaskGate { + private generation = 0; + + public begin(): number { + this.generation += 1; + return this.generation; + } + + public invalidate(): void { + this.generation += 1; + } + + public isCurrent(token: number): boolean { + return token === this.generation; + } +} diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 1eff8ad..4539d9e 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -54,6 +54,7 @@ import { } from "./lib/replay.js"; import { decodeResponsePreview, type ResponsePreview } from "./lib/response-decoder.js"; import { highlightJsonPreview, redactPreviewText } from "./lib/response-preview.js"; +import { LatestTaskGate } from "./lib/latest-task.js"; import { buildActionScopeIndex, extractReqIdFromEvent, @@ -410,6 +411,8 @@ const ACTION_MARKER_TYPES = new Set([ const initialShareServerBaseUrl = readStoredText(SHARE_SERVER_BASE_URL_STORAGE_KEY) ?? DEFAULT_SHARE_SERVER_BASE_URL; const initialShareServerApiKeysByOrigin = readStoredShareServerApiKeys(initialShareServerBaseUrl); +const primaryArchiveLoadGate = new LatestTaskGate(); +const compareArchiveLoadGate = new LatestTaskGate(); const state: PlayerState = { player: null, @@ -1536,6 +1539,7 @@ async function handlePrimaryArchiveChange(): Promise { const file = refs.archiveInput.files?.[0]; if (!file) { + primaryArchiveLoadGate.invalidate(); return; } @@ -1543,11 +1547,28 @@ async function handlePrimaryArchiveChange(): Promise { } async function loadPrimaryArchiveFile(file: File): Promise { - const bytes = new Uint8Array(await file.arrayBuffer()); - await loadPrimaryArchiveBytes(bytes, file.name); + const loadToken = primaryArchiveLoadGate.begin(); + + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + await loadPrimaryArchiveBytes(bytes, file.name, loadToken); + } catch (error) { + if (primaryArchiveLoadGate.isCurrent(loadToken)) { + setFeedback( + i18n.t("feedbackArchiveLoadFailed", { + sourceName: file.name, + error: String(error) + }) + ); + } + } } -async function loadPrimaryArchiveBytes(bytes: Uint8Array, sourceName: string): Promise { +async function loadPrimaryArchiveBytes( + bytes: Uint8Array, + sourceName: string, + loadToken: number +): Promise { pausePlayback(); hideProgressHover(); hideQuickTriagePanel(); @@ -1557,6 +1578,10 @@ async function loadPrimaryArchiveBytes(bytes: Uint8Array, sourceName: string): P const player = await openArchiveWithPassphraseFallback(bytes, sourceName); const model = buildArchiveModel(player); + if (!primaryArchiveLoadGate.isCurrent(loadToken)) { + return false; + } + resetScreenshotResources(); state.responsePreviewByHash.clear(); @@ -1572,23 +1597,33 @@ async function loadPrimaryArchiveBytes(bytes: Uint8Array, sourceName: string): P refreshCompareSummary(); await renderAll({ forcePanels: true, forceScreenshot: true }); + + if (!primaryArchiveLoadGate.isCurrent(loadToken)) { + return false; + } + showQuickTriagePanel(model, sourceName); const feedbackKey = hasPlaybackEvents(model.events) ? "feedbackArchiveLoaded" : "feedbackArchiveLoadedWithoutPlayback"; setFeedback(i18n.t(feedbackKey, { sourceName })); + return true; } catch (error) { - hideQuickTriagePanel(); - setFeedback( - i18n.t("feedbackArchiveLoadFailed", { - sourceName, - error: String(error) - }) - ); + if (primaryArchiveLoadGate.isCurrent(loadToken)) { + hideQuickTriagePanel(); + setFeedback( + i18n.t("feedbackArchiveLoadFailed", { + sourceName, + error: String(error) + }) + ); + } + return false; } } async function handleCompareArchiveChange(): Promise { + const loadToken = compareArchiveLoadGate.begin(); const file = refs.compareInput.files?.[0]; if (!file) { @@ -1602,12 +1637,21 @@ async function handleCompareArchiveChange(): Promise { try { const bytes = new Uint8Array(await file.arrayBuffer()); const comparePlayer = await openArchiveWithPassphraseFallback(bytes, file.name); + + if (!compareArchiveLoadGate.isCurrent(loadToken)) { + return; + } + state.comparePlayer = comparePlayer; state.compareModel = buildArchiveModel(comparePlayer); refreshCompareSummary(); renderSummary(); setFeedback(i18n.t("feedbackCompareLoaded", { fileName: file.name })); } catch (error) { + if (!compareArchiveLoadGate.isCurrent(loadToken)) { + return; + } + state.comparePlayer = null; state.compareModel = null; setFeedback( @@ -1925,6 +1969,7 @@ async function loadArchiveFromSharePrompt(): Promise { } async function loadArchiveFromShareReference(reference: string, apiKey: string): Promise { + const loadToken = primaryArchiveLoadGate.begin(); const trimmedReference = reference.trim(); const trimmedApiKey = apiKey.trim(); @@ -1956,10 +2001,19 @@ async function loadArchiveFromShareReference(reference: string, apiKey: string): } const bytes = new Uint8Array(await response.arrayBuffer()); - await loadPrimaryArchiveBytes(bytes, `shared-${resolved.shareId}.webblackbox`); - setFeedback(i18n.t("feedbackSharedArchiveLoaded", { shareId: resolved.shareId })); + const loaded = await loadPrimaryArchiveBytes( + bytes, + `shared-${resolved.shareId}.webblackbox`, + loadToken + ); + + if (loaded && primaryArchiveLoadGate.isCurrent(loadToken)) { + setFeedback(i18n.t("feedbackSharedArchiveLoaded", { shareId: resolved.shareId })); + } } catch (error) { - setFeedback(i18n.t("feedbackSharedArchiveLoadFailed", { error: String(error) })); + if (primaryArchiveLoadGate.isCurrent(loadToken)) { + setFeedback(i18n.t("feedbackSharedArchiveLoadFailed", { error: String(error) })); + } } } From 01bc38b89a5fef1cbd189b7a13d4cd4d332e60ef Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:45:51 +0800 Subject: [PATCH 015/181] fix(player): keep share API keys in memory --- apps/player/README.md | 1 + apps/player/src/lib/share-api-key.test.ts | 64 +++++++++++ apps/player/src/lib/share-api-key.ts | 12 +-- apps/player/src/main.ts | 124 +++++----------------- 4 files changed, 90 insertions(+), 111 deletions(-) create mode 100644 apps/player/src/lib/share-api-key.test.ts diff --git a/apps/player/README.md b/apps/player/README.md index 309c526..423558f 100644 --- a/apps/player/README.md +++ b/apps/player/README.md @@ -138,6 +138,7 @@ pnpm player:pages:deploy - Jira issue templates - HAR export - Share upload and link-based reload via `@webblackbox/share-server` +- Share API keys are retained only in page memory after a successful request; they are never persisted to `localStorage`, and legacy persisted keys are removed on startup ### Session Comparison diff --git a/apps/player/src/lib/share-api-key.test.ts b/apps/player/src/lib/share-api-key.test.ts new file mode 100644 index 0000000..d98119f --- /dev/null +++ b/apps/player/src/lib/share-api-key.test.ts @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it } from "vitest"; + +import { + bindShareApiKeyInputToTargetOrigin, + getShareServerApiKeyForBaseUrl, + setShareServerApiKeyForBaseUrl +} from "./share-api-key.js"; + +describe("share API key memory", () => { + it("keeps keys isolated by normalized origin", () => { + const keys: Record = {}; + setShareServerApiKeyForBaseUrl(keys, "https://share-a.example/path", " key-a "); + setShareServerApiKeyForBaseUrl(keys, "https://share-b.example", "key-b"); + + expect(getShareServerApiKeyForBaseUrl(keys, "https://share-a.example/other")).toBe("key-a"); + expect(getShareServerApiKeyForBaseUrl(keys, "https://share-b.example")).toBe("key-b"); + }); + + it("replaces an edited key when the target origin changes", () => { + const sourceInput = document.createElement("input"); + const apiKeyInput = document.createElement("input"); + sourceInput.value = "https://share-a.example/share/one"; + const keys = { + "https://share-a.example": "key-a", + "https://share-b.example": "key-b" + }; + const detach = bindShareApiKeyInputToTargetOrigin( + sourceInput, + apiKeyInput, + (value) => value, + (baseUrl) => getShareServerApiKeyForBaseUrl(keys, baseUrl) + ); + + expect(apiKeyInput.value).toBe("key-a"); + apiKeyInput.value = "unsent-edited-key-a"; + apiKeyInput.dispatchEvent(new Event("input")); + sourceInput.value = "https://share-b.example/share/two"; + sourceInput.dispatchEvent(new Event("input")); + + expect(apiKeyInput.value).toBe("key-b"); + detach(); + }); + + it("preserves edits while the target origin remains unchanged", () => { + const sourceInput = document.createElement("input"); + const apiKeyInput = document.createElement("input"); + sourceInput.value = "https://share.example/share/one"; + const detach = bindShareApiKeyInputToTargetOrigin( + sourceInput, + apiKeyInput, + (value) => value, + () => "stored-key" + ); + + apiKeyInput.value = "edited-key"; + sourceInput.value = "https://share.example/share/two"; + sourceInput.dispatchEvent(new Event("input")); + + expect(apiKeyInput.value).toBe("edited-key"); + detach(); + }); +}); diff --git a/apps/player/src/lib/share-api-key.ts b/apps/player/src/lib/share-api-key.ts index 6ffed48..1ad87f5 100644 --- a/apps/player/src/lib/share-api-key.ts +++ b/apps/player/src/lib/share-api-key.ts @@ -39,15 +39,10 @@ export function bindShareApiKeyInputToTargetOrigin( resolveBaseUrl: (value: string) => string | null, resolveApiKeyForBaseUrl: (baseUrl: string | null) => string ): () => void { - let apiKeyEdited = false; let resolvedBaseUrl = resolveBaseUrl(sourceInput.value); let targetOrigin = resolveShareServerOrigin(resolvedBaseUrl); apiKeyInput.value = resolveApiKeyForBaseUrl(resolvedBaseUrl); - const onApiKeyInput = (): void => { - apiKeyEdited = true; - }; - const onSourceInput = (): void => { const nextBaseUrl = resolveBaseUrl(sourceInput.value); const nextOrigin = resolveShareServerOrigin(nextBaseUrl); @@ -58,17 +53,12 @@ export function bindShareApiKeyInputToTargetOrigin( resolvedBaseUrl = nextBaseUrl; targetOrigin = nextOrigin; - - if (!apiKeyEdited) { - apiKeyInput.value = resolveApiKeyForBaseUrl(resolvedBaseUrl); - } + apiKeyInput.value = resolveApiKeyForBaseUrl(resolvedBaseUrl); }; - apiKeyInput.addEventListener("input", onApiKeyInput); sourceInput.addEventListener("input", onSourceInput); return () => { - apiKeyInput.removeEventListener("input", onApiKeyInput); sourceInput.removeEventListener("input", onSourceInput); }; } diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 4539d9e..224121a 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -69,11 +69,7 @@ import { getShareServerApiKeyForBaseUrl, setShareServerApiKeyForBaseUrl } from "./lib/share-api-key.js"; -import { - normalizeShareServerBaseUrl, - resolveShareArchiveRequest, - resolveShareServerOrigin -} from "./lib/share.js"; +import { normalizeShareServerBaseUrl, resolveShareArchiveRequest } from "./lib/share.js"; import { readScreenshotContext, readScreenshotMarker, @@ -410,7 +406,9 @@ const ACTION_MARKER_TYPES = new Set([ const initialShareServerBaseUrl = readStoredText(SHARE_SERVER_BASE_URL_STORAGE_KEY) ?? DEFAULT_SHARE_SERVER_BASE_URL; -const initialShareServerApiKeysByOrigin = readStoredShareServerApiKeys(initialShareServerBaseUrl); +const initialShareServerApiKeysByOrigin: Record = {}; +removeStoredItem(SHARE_SERVER_API_KEYS_STORAGE_KEY); +removeStoredItem(LEGACY_SHARE_SERVER_API_KEY_STORAGE_KEY); const primaryArchiveLoadGate = new LatestTaskGate(); const compareArchiveLoadGate = new LatestTaskGate(); @@ -1895,7 +1893,6 @@ async function shareLoadedArchive(): Promise { state.shareServerBaseUrl = normalizedBaseUrl; writeStoredText(SHARE_SERVER_BASE_URL_STORAGE_KEY, normalizedBaseUrl); - rememberShareServerApiKey(normalizedBaseUrl, shareConfig.apiKey); const headers: Record = { "content-type": "application/octet-stream", @@ -1951,6 +1948,7 @@ async function shareLoadedArchive(): Promise { throw new Error(i18n.messages.feedbackShareMissingUrl); } + rememberShareServerApiKey(normalizedBaseUrl, shareConfig.apiKey); await copyText(shareUrl); setFeedback(i18n.t("feedbackShareSucceeded", { shareUrl })); } catch (error) { @@ -1968,7 +1966,11 @@ async function loadArchiveFromSharePrompt(): Promise { await loadArchiveFromShareReference(shareInput.reference, shareInput.apiKey); } -async function loadArchiveFromShareReference(reference: string, apiKey: string): Promise { +async function loadArchiveFromShareReference( + reference: string, + apiKey: string, + rememberApiKeyOnSuccess = true +): Promise { const loadToken = primaryArchiveLoadGate.begin(); const trimmedReference = reference.trim(); const trimmedApiKey = apiKey.trim(); @@ -1982,7 +1984,6 @@ async function loadArchiveFromShareReference(reference: string, apiKey: string): state.shareServerBaseUrl = resolved.baseUrl; writeStoredText(SHARE_SERVER_BASE_URL_STORAGE_KEY, resolved.baseUrl); - rememberShareServerApiKey(resolved.baseUrl, trimmedApiKey); try { const headers: Record = {}; @@ -2008,6 +2009,9 @@ async function loadArchiveFromShareReference(reference: string, apiKey: string): ); if (loaded && primaryArchiveLoadGate.isCurrent(loadToken)) { + if (rememberApiKeyOnSuccess) { + rememberShareServerApiKey(resolved.baseUrl, trimmedApiKey); + } setFeedback(i18n.t("feedbackSharedArchiveLoaded", { shareId: resolved.shareId })); } } catch (error) { @@ -2031,7 +2035,7 @@ async function maybeAutoLoadSharedArchiveFromLocation(): Promise { } setFeedback(i18n.messages.feedbackSharedArchiveLoadingFromUrl); - await loadArchiveFromShareReference(shareRef, ""); + await loadArchiveFromShareReference(shareRef, "", false); } async function promptShareUploadConfig(): Promise<{ @@ -2059,17 +2063,21 @@ async function promptShareUploadConfig(): Promise<{ } if (result !== "confirm") { + refs.shareUploadApiKey.value = ""; return null; } if (!refs.shareUploadPrivacyReviewed.checked) { + refs.shareUploadApiKey.value = ""; return null; } - return { + const config = { baseUrl: refs.shareUploadBaseUrl.value.trim(), apiKey: refs.shareUploadApiKey.value.trim() }; + refs.shareUploadApiKey.value = ""; + return config; } function renderSharePrivacyPreflight(): void { @@ -2253,18 +2261,22 @@ async function promptShareReferenceInput(): Promise<{ reference: string; apiKey: } if (result !== "confirm") { + refs.shareLoadApiKey.value = ""; return null; } const reference = refs.shareLoadReference.value.trim(); if (reference.length === 0) { + refs.shareLoadApiKey.value = ""; return null; } - return { + const shareInput = { reference, apiKey: refs.shareLoadApiKey.value.trim() }; + refs.shareLoadApiKey.value = ""; + return shareInput; } function togglePlayback(): void { @@ -5669,92 +5681,4 @@ function setFeedback(text: string): void { function rememberShareServerApiKey(baseUrl: string, apiKey: string): void { setShareServerApiKeyForBaseUrl(state.shareServerApiKeysByOrigin, baseUrl, apiKey); - persistShareServerApiKeys(state.shareServerApiKeysByOrigin); -} - -function readStoredShareServerApiKeys(baseUrl: string): Record { - const parsed = parseStoredShareServerApiKeys(readStoredText(SHARE_SERVER_API_KEYS_STORAGE_KEY)); - const legacyApiKey = readStoredText(LEGACY_SHARE_SERVER_API_KEY_STORAGE_KEY); - - if (legacyApiKey) { - const origin = resolveShareServerOrigin(baseUrl); - - if (origin && !parsed[origin]) { - parsed[origin] = legacyApiKey; - } - - removeStoredItem(LEGACY_SHARE_SERVER_API_KEY_STORAGE_KEY); - persistShareServerApiKeys(parsed); - } - - return parsed; -} - -function parseStoredShareServerApiKeys(raw: string | null): Record { - if (!raw) { - return {}; - } - - try { - const candidate = asRecord(JSON.parse(raw)); - - if (!candidate) { - return {}; - } - - const parsed: Record = {}; - - for (const [originCandidate, apiKeyCandidate] of Object.entries(candidate)) { - if (typeof apiKeyCandidate !== "string") { - continue; - } - - const origin = resolveShareServerOrigin(originCandidate); - const apiKey = apiKeyCandidate.trim(); - - if (!origin || apiKey.length === 0) { - continue; - } - - parsed[origin] = apiKey; - } - - return parsed; - } catch { - removeStoredItem(SHARE_SERVER_API_KEYS_STORAGE_KEY); - return {}; - } -} - -function persistShareServerApiKeys(apiKeysByOrigin: Record): void { - const entries: Array<{ origin: string; apiKey: string }> = []; - - for (const [originCandidate, apiKeyCandidate] of Object.entries(apiKeysByOrigin)) { - const origin = resolveShareServerOrigin(originCandidate); - const apiKey = apiKeyCandidate.trim(); - - if (!origin || apiKey.length === 0) { - continue; - } - - entries.push({ - origin, - apiKey - }); - } - - entries.sort((left, right) => left.origin.localeCompare(right.origin)); - - if (entries.length === 0) { - removeStoredItem(SHARE_SERVER_API_KEYS_STORAGE_KEY); - return; - } - - const serialized: Record = {}; - - for (const entry of entries) { - serialized[entry.origin] = entry.apiKey; - } - - writeStoredText(SHARE_SERVER_API_KEYS_STORAGE_KEY, JSON.stringify(serialized)); } From 4e2e1b85c04e16eb01aa5da9fb6144311b577dbf Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:50:14 +0800 Subject: [PATCH 016/181] fix(player): include captured bodies in HAR --- apps/mcp-server/src/session-tools.ts | 2 +- apps/player/src/lib/i18n.ts | 3 ++ apps/player/src/main.ts | 11 +++- packages/player-sdk/src/index.test.ts | 13 ++++- packages/player-sdk/src/index.ts | 77 ++++++++++++++++++++++++--- 5 files changed, 93 insertions(+), 13 deletions(-) diff --git a/apps/mcp-server/src/session-tools.ts b/apps/mcp-server/src/session-tools.ts index 67da703..87d08b3 100644 --- a/apps/mcp-server/src/session-tools.ts +++ b/apps/mcp-server/src/session-tools.ts @@ -694,7 +694,7 @@ export async function exportHarFromArchive(args: ExportHarArgs): Promise<{ const archivePath = resolveArchivePath(args.path); const range = buildRange(args.monoStart, args.monoEnd); const player = await openArchivePlayer(archivePath, args.passphrase, range ?? undefined); - const har = player.exportHar(range ?? undefined); + const har = await player.exportHar(range ?? undefined); return { archive: archivePath, diff --git a/apps/player/src/lib/i18n.ts b/apps/player/src/lib/i18n.ts index 9d417cd..57c67ef 100644 --- a/apps/player/src/lib/i18n.ts +++ b/apps/player/src/lib/i18n.ts @@ -251,6 +251,7 @@ type PlayerMessages = { screenRecordingMeta: string; feedbackBugReportExported: string; feedbackHarExported: string; + feedbackHarExportFailed: string; feedbackGitHubIssueExported: string; feedbackJiraIssueExported: string; feedbackQuickTriageDismissed: string; @@ -539,6 +540,7 @@ const PLAYER_MESSAGES: Record = { "Recording {current} / {duration} | {chunks} chunks | {size}B | {dimensions}", feedbackBugReportExported: "Bug report exported.", feedbackHarExported: "HAR exported.", + feedbackHarExportFailed: "Failed to export HAR: {error}", feedbackGitHubIssueExported: "GitHub issue template exported.", feedbackJiraIssueExported: "Jira issue template exported.", feedbackQuickTriageDismissed: "Quick triage dismissed.", @@ -854,6 +856,7 @@ const PLAYER_MESSAGES: Record = { screenRecordingMeta: "录屏 {current} / {duration} | {chunks} 个分片 | {size}B | {dimensions}", feedbackBugReportExported: "已导出缺陷报告。", feedbackHarExported: "已导出 HAR。", + feedbackHarExportFailed: "导出 HAR 失败:{error}", feedbackGitHubIssueExported: "已导出 GitHub issue 模板。", feedbackJiraIssueExported: "已导出 Jira issue 模板。", feedbackQuickTriageDismissed: "已关闭快速分诊。", diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 224121a..00737ca 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -1135,8 +1135,15 @@ function bindGlobalActions(): void { return; } - downloadTextFile("webblackbox-session.har", player.exportHar(), "application/json"); - setFeedback(i18n.messages.feedbackHarExported); + void player + .exportHar() + .then((har) => { + downloadTextFile("webblackbox-session.har", har, "application/json"); + setFeedback(i18n.messages.feedbackHarExported); + }) + .catch((error: unknown) => { + setFeedback(i18n.t("feedbackHarExportFailed", { error: String(error) })); + }); }); refs.exportPlaywright.addEventListener("click", () => { diff --git a/packages/player-sdk/src/index.test.ts b/packages/player-sdk/src/index.test.ts index 3419ce2..92ef172 100644 --- a/packages/player-sdk/src/index.test.ts +++ b/packages/player-sdk/src/index.test.ts @@ -543,17 +543,26 @@ describe("WebBlackboxPlayer", () => { expect(fetchSnippet).toContain("await fetch"); expect(fetchSnippet).toContain("https://example.com/api"); - const har = JSON.parse(player.exportHar()) as { + const har = JSON.parse(await player.exportHar()) as { log: { entries: Array<{ request: { method: string }; - response: { status: number }; + response: { + status: number; + content: { text?: string; encoding?: string; mimeType: string; size: number }; + }; }>; }; }; expect(har.log.entries).toHaveLength(1); expect(har.log.entries[0]?.request.method).toBe("POST"); expect(har.log.entries[0]?.response.status).toBe(200); + expect(har.log.entries[0]?.response.content).toMatchObject({ + mimeType: "application/json", + size: 11, + text: '{"ok":true}' + }); + expect(har.log.entries[0]?.response.content.encoding).toBeUndefined(); }); it("builds storage timeline, report, and playwright script", async () => { diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index da5c94f..72c032e 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -1500,9 +1500,16 @@ export class WebBlackboxPlayer { return `await fetch(${JSON.stringify(entry.url)}, ${JSON.stringify(options, null, 2)});`; } - /** Exports a HAR 1.2 document from network events. */ - public exportHar(range?: PlayerRange): string { - const entries = this.getNetworkWaterfall(range).map((entry) => toHarEntry(entry)); + /** Exports a HAR 1.2 document, including captured response bodies, from network events. */ + public async exportHar(range?: PlayerRange): Promise { + const entries = await Promise.all( + this.getNetworkWaterfall(range).map(async (entry) => { + const responseBody = entry.responseBodyHash + ? await this.getBlob(entry.responseBodyHash) + : null; + return toHarEntry(entry, responseBody ?? undefined); + }) + ); const started = new Date(this.events[0]?.t ?? Date.now()).toISOString(); const har = { @@ -2438,7 +2445,10 @@ function readSelector(event: WebBlackboxEvent): string | null { return selector; } -function toHarEntry(entry: NetworkWaterfallEntry): Record { +function toHarEntry( + entry: NetworkWaterfallEntry, + responseBody?: { mime: string; bytes: Uint8Array } +): Record { const queryString = parseQueryString(entry.url); const requestCookies = parseCookieHeader(entry.requestHeaders.cookie); const responseCookies = parseSetCookieHeader(entry.responseHeaders["set-cookie"]); @@ -2471,10 +2481,7 @@ function toHarEntry(entry: NetworkWaterfallEntry): Record { httpVersion: "HTTP/1.1", cookies: responseCookies, headers: headersToHarArray(entry.responseHeaders), - content: { - size: entry.responseBodySize ?? entry.encodedDataLength ?? 0, - mimeType: entry.mimeType ?? "application/octet-stream" - }, + content: buildHarResponseContent(entry, responseBody), redirectURL: entry.responseHeaders.location ?? "", headersSize: -1, bodySize: entry.responseBodySize ?? -1 @@ -2492,6 +2499,60 @@ function toHarEntry(entry: NetworkWaterfallEntry): Record { }; } +function buildHarResponseContent( + entry: NetworkWaterfallEntry, + responseBody?: { mime: string; bytes: Uint8Array } +): Record { + const mimeType = responseBody?.mime ?? entry.mimeType ?? "application/octet-stream"; + const content: Record = { + size: responseBody?.bytes.byteLength ?? entry.responseBodySize ?? entry.encodedDataLength ?? 0, + mimeType + }; + + if (!responseBody) { + return content; + } + + if (isTextualHarMimeType(mimeType)) { + content.text = new TextDecoder("utf-8", { fatal: false }).decode(responseBody.bytes); + } else { + content.text = encodeBase64(responseBody.bytes); + content.encoding = "base64"; + } + + return content; +} + +function isTextualHarMimeType(mimeType: string): boolean { + const normalized = mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + return ( + normalized.startsWith("text/") || + normalized.includes("json") || + normalized.includes("xml") || + normalized.includes("javascript") || + normalized.includes("x-www-form-urlencoded") + ); +} + +function encodeBase64(bytes: Uint8Array): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let output = ""; + + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + const combined = (first << 16) | ((second ?? 0) << 8) | (third ?? 0); + + output += alphabet[(combined >> 18) & 63] ?? ""; + output += alphabet[(combined >> 12) & 63] ?? ""; + output += second === undefined ? "=" : (alphabet[(combined >> 6) & 63] ?? ""); + output += third === undefined ? "=" : (alphabet[combined & 63] ?? ""); + } + + return output; +} + function buildDomDiff( previous: DomSnapshotRef, current: DomSnapshotRef, From 17644bf935f778ebdde0ee4728768841c556949d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:48:23 +0800 Subject: [PATCH 017/181] fix: enforce encrypted local pipeline storage --- apps/extension/README.md | 2 + apps/extension/package.json | 1 + apps/extension/src/offscreen/index.ts | 5 +- .../src/offscreen/pipeline-storage.test.ts | 111 ++++++++ .../src/offscreen/pipeline-storage.ts | 49 ++++ docs/PRIVACY.md | 2 +- docs/SECURITY.md | 2 + packages/pipeline/README.md | 32 ++- packages/pipeline/src/index.test.ts | 32 +++ packages/pipeline/src/pipeline.ts | 52 +++- packages/pipeline/src/storage.test.ts | 94 ++++++- packages/pipeline/src/storage.ts | 248 +++++++++++++++++- packages/webblackbox/README.md | 8 +- packages/webblackbox/package.json | 1 + packages/webblackbox/src/lite-sdk.test.ts | 74 +++++- packages/webblackbox/src/lite-sdk.ts | 66 ++++- packages/webblackbox/src/types.ts | 1 + pnpm-lock.yaml | 3 + 18 files changed, 750 insertions(+), 33 deletions(-) create mode 100644 apps/extension/src/offscreen/pipeline-storage.test.ts create mode 100644 apps/extension/src/offscreen/pipeline-storage.ts diff --git a/apps/extension/README.md b/apps/extension/README.md index 498719e..d8fb829 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -57,6 +57,8 @@ The extension consists of multiple main components: - Runs the `FlightRecorderPipeline` for event processing - Handles chunking, compression, indexing, and blob storage +- Encrypts event chunks and blobs before IndexedDB persistence with a non-extractable, purpose-specific AES-GCM key that survives offscreen/service-worker restarts +- Purges the legacy plaintext cache (or an encrypted cache whose managed key was lost) instead of reopening unverifiable payloads - Generates `.webblackbox` ZIP archives on export - Isolated from the main page for performance diff --git a/apps/extension/package.json b/apps/extension/package.json index 6533653..8423774 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -37,6 +37,7 @@ "webblackbox": "workspace:*" }, "devDependencies": { + "fake-indexeddb": "^6.2.5", "jszip": "^3.10.1" } } diff --git a/apps/extension/src/offscreen/index.ts b/apps/extension/src/offscreen/index.ts index 60becbb..01c7966 100644 --- a/apps/extension/src/offscreen/index.ts +++ b/apps/extension/src/offscreen/index.ts @@ -1,4 +1,4 @@ -import { FlightRecorderPipeline, IndexedDbPipelineStorage } from "@webblackbox/pipeline"; +import { FlightRecorderPipeline } from "@webblackbox/pipeline"; import type { CapturePolicy, PrivacyScannerResult, @@ -10,6 +10,7 @@ import type { import { getChromeApi } from "../shared/chrome-api.js"; import { createExtensionI18n } from "../shared/i18n.js"; import { PORT_NAMES } from "../shared/messages.js"; +import { getExtensionPipelineStorage } from "./pipeline-storage.js"; type OffscreenPipelineRequest = { kind: "sw.pipeline-request"; @@ -199,7 +200,7 @@ async function processPipelineRequest(message: OffscreenPipelineRequest): Promis return null; } - const storage = new IndexedDbPipelineStorage("webblackbox-flight-recorder"); + const storage = await getExtensionPipelineStorage(); const pipeline = new FlightRecorderPipeline({ session: message.session, storage, diff --git a/apps/extension/src/offscreen/pipeline-storage.test.ts b/apps/extension/src/offscreen/pipeline-storage.test.ts new file mode 100644 index 0000000..19b3313 --- /dev/null +++ b/apps/extension/src/offscreen/pipeline-storage.test.ts @@ -0,0 +1,111 @@ +import "fake-indexeddb/auto"; + +import { + EncryptedPipelineStorage, + IndexedDbPipelineStorage, + getOrCreateIndexedDbPipelineStorageKey, + type StoredBlob, + type StoredChunk +} from "@webblackbox/pipeline"; +import type { SessionMetadata } from "@webblackbox/protocol"; +import { describe, expect, it } from "vitest"; + +import { + EXTENSION_PIPELINE_DATABASE, + EXTENSION_PIPELINE_KEY_PURPOSE, + EXTENSION_PIPELINE_KEYRING_DATABASE, + LEGACY_PLAINTEXT_PIPELINE_DATABASE, + getExtensionPipelineStorage +} from "./pipeline-storage.js"; + +const SESSION: SessionMetadata = { + sid: "S-extension-at-rest-test", + tabId: 1, + startedAt: 1, + mode: "full", + url: "https://example.test/", + tags: [] +}; + +function createChunk(text: string): StoredChunk { + const bytes = new TextEncoder().encode(text); + + return { + sid: SESSION.sid, + meta: { + chunkId: "C-extension-at-rest", + seq: 1, + tStart: 1, + tEnd: 1, + monoStart: 1, + monoEnd: 1, + eventCount: 1, + byteLength: bytes.byteLength, + codec: "none", + sha256: "a".repeat(64) + }, + bytes + }; +} + +function createBlob(text: string): StoredBlob { + const bytes = new TextEncoder().encode(text); + + return { + hash: "b".repeat(64), + mime: "text/plain", + size: bytes.byteLength, + bytes, + createdAt: 1, + refCount: 1 + }; +} + +describe("extension offscreen pipeline storage", () => { + it("purges legacy plaintext, encrypts payloads, and recovers the persisted key", async () => { + const eventSecret = "EXTENSION_EVENT_SECRET"; + const blobSecret = "EXTENSION_BLOB_SECRET"; + const legacy = new IndexedDbPipelineStorage(LEGACY_PLAINTEXT_PIPELINE_DATABASE); + await legacy.putSession(SESSION); + await legacy.putChunk(createChunk("LEGACY_PLAINTEXT_EVENT")); + + const storage = await getExtensionPipelineStorage(); + await storage.putSession(SESSION); + await storage.putChunk(createChunk(eventSecret)); + await storage.putBlob(createBlob(blobSecret), SESSION.sid); + + const legacyAfterPurge = new IndexedDbPipelineStorage(LEGACY_PLAINTEXT_PIPELINE_DATABASE); + expect(await legacyAfterPurge.getSession(SESSION.sid)).toBeUndefined(); + + const rawStorage = new IndexedDbPipelineStorage(EXTENSION_PIPELINE_DATABASE); + const rawChunk = await rawStorage.getChunk(SESSION.sid, "C-extension-at-rest"); + const rawBlob = await rawStorage.getBlob("b".repeat(64)); + expect(new TextDecoder().decode(rawChunk?.bytes ?? new Uint8Array())).not.toContain( + eventSecret + ); + expect(new TextDecoder().decode(rawBlob?.bytes ?? new Uint8Array())).not.toContain(blobSecret); + + const recoveredKey = await getOrCreateIndexedDbPipelineStorageKey({ + databaseName: EXTENSION_PIPELINE_KEYRING_DATABASE, + purpose: EXTENSION_PIPELINE_KEY_PURPOSE + }); + const restartedStorage = new EncryptedPipelineStorage( + new IndexedDbPipelineStorage(EXTENSION_PIPELINE_DATABASE), + { key: recoveredKey.key } + ); + + expect(recoveredKey.created).toBe(false); + await restartedStorage.assertReady(); + expect( + new TextDecoder().decode( + (await restartedStorage.getChunk(SESSION.sid, "C-extension-at-rest"))?.bytes ?? + new Uint8Array() + ) + ).toBe(eventSecret); + expect( + new TextDecoder().decode( + (await restartedStorage.getBlob("b".repeat(64)))?.bytes ?? new Uint8Array() + ) + ).toBe(blobSecret); + }); +}); diff --git a/apps/extension/src/offscreen/pipeline-storage.ts b/apps/extension/src/offscreen/pipeline-storage.ts new file mode 100644 index 0000000..abc6205 --- /dev/null +++ b/apps/extension/src/offscreen/pipeline-storage.ts @@ -0,0 +1,49 @@ +import { + EncryptedPipelineStorage, + IndexedDbPipelineStorage, + deleteIndexedDbDatabase, + getOrCreateIndexedDbPipelineStorageKey, + type PipelineStorage +} from "@webblackbox/pipeline"; + +export const EXTENSION_PIPELINE_DATABASE = "webblackbox-flight-recorder-encrypted-v1"; +export const EXTENSION_PIPELINE_KEYRING_DATABASE = "webblackbox-flight-recorder-keyring-v1"; +export const LEGACY_PLAINTEXT_PIPELINE_DATABASE = "webblackbox-flight-recorder"; +export const EXTENSION_PIPELINE_KEY_PURPOSE = "webblackbox-extension:pipeline-payload:aes-gcm:v1"; + +let storagePromise: Promise | null = null; + +/** + * Returns the shared encrypted storage used by all offscreen pipelines. + * + * The non-extractable key survives offscreen/service-worker restarts in a + * purpose-specific IndexedDB keyring. If that key is newly created, any data + * database that may contain legacy plaintext or payloads encrypted with a lost + * key is purged before it is opened. + */ +export function getExtensionPipelineStorage(): Promise { + storagePromise ??= initializeExtensionPipelineStorage(); + return storagePromise; +} + +async function initializeExtensionPipelineStorage(): Promise { + await deleteIndexedDbDatabase(LEGACY_PLAINTEXT_PIPELINE_DATABASE); + + const managedKey = await getOrCreateIndexedDbPipelineStorageKey({ + databaseName: EXTENSION_PIPELINE_KEYRING_DATABASE, + purpose: EXTENSION_PIPELINE_KEY_PURPOSE + }); + + if (managedKey.created) { + await deleteIndexedDbDatabase(EXTENSION_PIPELINE_DATABASE); + } + + const storage = new EncryptedPipelineStorage( + new IndexedDbPipelineStorage(EXTENSION_PIPELINE_DATABASE), + { + key: managedKey.key + } + ); + await storage.assertReady(); + return storage; +} diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 524eb7b..e397472 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -16,7 +16,7 @@ By default, WebBlackbox does not collect raw input values, DOM text, screenshots ## Local Storage -Captured sessions remain local until the user exports or shares an archive. Local stopped sessions are subject to retention controls, and enterprise policies can cap local retention. +Captured sessions remain local until the user exports or shares an archive. Under the required local-at-rest policy, persisted event chunks and blobs are authenticated and encrypted with AES-GCM; raw persistent storage is rejected. Extension upgrades purge the former plaintext cache, and a missing managed key causes unverifiable cached payloads to be purged instead of silently reopened. Query indexes and session metadata remain plaintext and must not contain captured payload values. Local stopped sessions are subject to retention controls, and enterprise policies can cap local retention. ## Export And Share diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 767c951..5fedbf9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -17,6 +17,8 @@ The dev/enterprise profile can enable deeper diagnostics, including CDP, but the Real-user archives require export encryption. Public share uploads require encrypted `.webblackbox` archives and never accept passphrases. Private archive paths include event chunks, blobs, indexes, and `privacy/manifest.json`; older encrypted archives with plaintext private files must be re-exported before public sharing. Client-side share metadata is limited to an allowlisted public summary. +Local event chunks and blobs are also protected before persistent storage. The pipeline rejects persistent storage that does not declare authenticated AES-GCM payload protection when `localAtRest` is required. The extension stores a non-extractable, purpose-specific Web Crypto key in an extension-origin IndexedDB keyring so offscreen/service-worker restarts can recover the cache without exporting raw key material. On upgrade, the legacy plaintext database is purged; if the managed key is lost, unverifiable cached payloads are purged rather than read as plaintext. + Plaintext synthetic or local-debug export exemptions require a well-formed `captureContextEvidenceRef` that is also allowlisted by the trusted pipeline/embedder configuration. A capture policy cannot make its own plaintext exemption trusted. ## Player Safety diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md index 27e1584..e76e4ef 100644 --- a/packages/pipeline/README.md +++ b/packages/pipeline/README.md @@ -86,30 +86,38 @@ is present in `trustedPlaintextExemptionEvidenceRefs`. The deprecated `allowPlaintextLocalExport` option is retained for source compatibility but cannot bypass these checks. -### Optional At-Rest Storage Encryption +### Required At-Rest Storage Encryption -`EncryptedPipelineStorage` encrypts chunk/blob cache payload bytes before persistence (for example when using IndexedDB storage). +`FlightRecorderPipeline.start()` verifies the storage security capability. Volatile memory storage is accepted, but a persistent storage such as raw `IndexedDbPipelineStorage` is rejected when `capturePolicy.encryption.localAtRest` is `"required"`. Wrap persistent storage with `EncryptedPipelineStorage`, which uses authenticated AES-GCM for chunk/blob payload bytes and rejects legacy plaintext on read. ```typescript import { + deleteIndexedDbDatabase, EncryptedPipelineStorage, IndexedDbPipelineStorage, - derivePipelineStorageKey + getOrCreateIndexedDbPipelineStorageKey } from "@webblackbox/pipeline"; -const derived = await derivePipelineStorageKey("cache-passphrase"); +const databaseName = "webblackbox-flight-recorder-encrypted-v1"; +const managedKey = await getOrCreateIndexedDbPipelineStorageKey({ + databaseName: "webblackbox-flight-recorder-keyring-v1", + purpose: "my-app:pipeline-payload:aes-gcm:v1" +}); -const storage = new EncryptedPipelineStorage( - new IndexedDbPipelineStorage("webblackbox-flight-recorder"), - { - key: derived.key - } -); +// A newly created key cannot decrypt any pre-existing payload. Purge possible +// legacy plaintext or data whose key was lost before opening the data database. +if (managedKey.created) { + await deleteIndexedDbDatabase(databaseName); +} -// Persist derived.salt + derived.iterations with your own secure key policy. +const storage = new EncryptedPipelineStorage(new IndexedDbPipelineStorage(databaseName), { + key: managedKey.key +}); ``` -Note: this protects event/blob payload bytes at rest; indexes and session metadata remain plaintext for queryability. +The managed key is non-extractable, persisted in a purpose-specific IndexedDB keyring, and recoverable after a worker/page restart. Supplying a missing, rejected, extractable, wrong-algorithm, or wrong-usage key fails closed before the pipeline starts. Applications with an external key manager may instead supply their own non-extractable 256-bit AES-GCM key with `encrypt` and `decrypt` usages. + +Note: this protects event/blob payload bytes at rest; indexes and session metadata remain plaintext for queryability and must not contain captured payload values. ### Blob Storage diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index 52d99b7..3ba4a2c 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -13,6 +13,7 @@ import { FlightRecorderPipeline, type FlightRecorderPipelineOptions } from "./pi import { derivePipelineStorageKey, EncryptedPipelineStorage, + IndexedDbPipelineStorage, MemoryPipelineStorage } from "./storage.js"; @@ -112,6 +113,37 @@ function createNoisyPayload(size: number, seed: number): string { } describe("pipeline", () => { + it("rejects plaintext persistent storage when local-at-rest encryption is required", async () => { + const pipeline = createTestPipeline({ + session: { + ...SESSION, + sid: "S-plaintext-persistent-storage" + }, + storage: new IndexedDbPipelineStorage( + `wb-pipeline-policy-${Date.now()}-${Math.random().toString(16).slice(2)}` + ) + }); + + await expect( + pipeline.putBlob("text/plain", new TextEncoder().encode("must-not-persist")) + ).rejects.toThrow(/persistent.*authenticated AES-GCM/i); + await expect(pipeline.start()).rejects.toThrow(/persistent.*authenticated AES-GCM/i); + }); + + it("fails pipeline startup when the at-rest encryption key is unavailable", async () => { + const pipeline = createTestPipeline({ + session: { + ...SESSION, + sid: "S-missing-at-rest-key" + }, + storage: new EncryptedPipelineStorage(new MemoryPipelineStorage(), { + key: Promise.reject(new Error("simulated missing key")) + }) + }); + + await expect(pipeline.start()).rejects.toThrow(/key is unavailable/i); + }); + it("rejects events without privacy classification", async () => { const storage = new MemoryPipelineStorage(); const pipeline = createTestPipeline({ diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index 17e086d..ae0c6c4 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -18,7 +18,12 @@ import { createWebBlackboxArchive } from "./exporter.js"; import { sha256Hex } from "./hash.js"; import { EventIndexer } from "./indexer.js"; import { assertPrivacyScannerPassed, buildPrivacyManifest } from "./privacy.js"; -import type { PipelineStorage, StoredBlob, StoredChunk } from "./storage.js"; +import { + PIPELINE_STORAGE_SECURITY, + type PipelineStorage, + type StoredBlob, + type StoredChunk +} from "./storage.js"; export type FlightRecorderPipelineOptions = { session: SessionMetadata; @@ -95,6 +100,7 @@ const SYNTHETIC_EVIDENCE_PATTERN = /^(?:synthetic-fixture|ci-run):[A-Za-z0-9][A- export class FlightRecorderPipeline { private readonly chunker: EventChunker; private readonly chunkCodec: (typeof CHUNK_CODECS)[number]; + private storageReadyPromise: Promise | null = null; public constructor(private readonly options: FlightRecorderPipelineOptions) { const codec = resolveChunkCodec(options.chunkCodec); @@ -104,6 +110,7 @@ export class FlightRecorderPipeline { } public async start(): Promise { + await this.ensureStorageReady(); const lastSequence = (await this.options.storage.getLatestChunkMeta(this.options.session.sid))?.seq ?? 0; @@ -179,6 +186,7 @@ export class FlightRecorderPipeline { } public async putBlob(mime: string, bytes: Uint8Array): Promise { + await this.ensureStorageReady(); const hash = await sha256Hex(bytes); const blob: StoredBlob = { hash, @@ -198,6 +206,7 @@ export class FlightRecorderPipeline { request: RequestIndexEntry[]; inverted: InvertedIndexEntry[]; }> { + await this.ensureStorageReady(); await this.flush(); const chunks = await this.options.storage.listChunks(this.options.session.sid); const snapshot = await this.buildIndexesFromChunks(chunks); @@ -207,6 +216,7 @@ export class FlightRecorderPipeline { public async exportBundle(options: ExportBundleOptions = {}): Promise { this.assertExportEncryptionPolicy(options); + await this.ensureStorageReady(); await this.flush(); const rawChunks = await this.options.storage.listChunks(this.options.session.sid); const exportPolicy = resolveExportPolicy(options, { @@ -656,6 +666,7 @@ export class FlightRecorderPipeline { events: WebBlackboxEvent[], bytes: Uint8Array ): Promise { + await this.ensureStorageReady(); const first = events[0]; const last = events[events.length - 1]; const hash = await sha256Hex(bytes); @@ -680,6 +691,14 @@ export class FlightRecorderPipeline { await this.options.storage.putChunk(chunk); } + private ensureStorageReady(): Promise { + this.storageReadyPromise ??= assertLocalAtRestStorage( + this.options.storage, + this.options.capturePolicy + ); + return this.storageReadyPromise; + } + private async buildIndexesFromChunks(chunks: StoredChunk[]): Promise { const indexer = new EventIndexer(); @@ -732,6 +751,37 @@ export class FlightRecorderPipeline { } } +async function assertLocalAtRestStorage( + storage: PipelineStorage, + capturePolicy: CapturePolicy | undefined +): Promise { + const localAtRest = capturePolicy?.encryption.localAtRest ?? "required"; + + if (localAtRest !== "required") { + return; + } + + const capability = storage[PIPELINE_STORAGE_SECURITY]; + + if (!capability) { + throw new Error( + "Pipeline storage does not declare a verifiable local-at-rest security capability." + ); + } + + if ( + capability.persistence === "persistent" && + (capability.payloadProtection !== "authenticated-encryption" || + capability.algorithm !== "AES-GCM") + ) { + throw new Error( + "capturePolicy.encryption.localAtRest is required; persistent pipeline storage must use authenticated AES-GCM payload encryption." + ); + } + + await storage.assertReady?.(); +} + function resolveExportPolicy( options: ExportBundleOptions, context: { diff --git a/packages/pipeline/src/storage.test.ts b/packages/pipeline/src/storage.test.ts index 0545121..22480e0 100644 --- a/packages/pipeline/src/storage.test.ts +++ b/packages/pipeline/src/storage.test.ts @@ -4,8 +4,10 @@ import type { ChunkTimeIndexEntry, SessionMetadata } from "@webblackbox/protocol import { describe, expect, it, vi } from "vitest"; import { + deleteIndexedDbDatabase, derivePipelineStorageKey, EncryptedPipelineStorage, + getOrCreateIndexedDbPipelineStorageKey, IndexedDbPipelineStorage, MemoryPipelineStorage, type StoredBlob, @@ -231,10 +233,8 @@ describe("storage", () => { await baseStorage.putChunk(createChunk(sid, "C-plain", 2, '{"plain":true}\n')); await baseStorage.putBlob(createBlob("b".repeat(64), Uint8Array.from([9, 9, 9])), sid); - expect(Array.from((await storage.getChunk(sid, "C-plain"))?.bytes ?? [])).toEqual( - Array.from(new TextEncoder().encode('{"plain":true}\n')) - ); - expect(Array.from((await storage.getBlob("b".repeat(64)))?.bytes ?? [])).toEqual([9, 9, 9]); + await expect(storage.getChunk(sid, "C-plain")).rejects.toThrow(/refusing.*plaintext/i); + await expect(storage.getBlob("b".repeat(64))).rejects.toThrow(/refusing.*plaintext/i); await expect(storage.getChunk(sid, "missing")).resolves.toBeUndefined(); await expect(storage.getBlob("c".repeat(64))).resolves.toBeUndefined(); @@ -403,4 +403,90 @@ describe("storage", () => { await storage.deleteSession(sid); expect(await innerStorage.getBlob(hash)).toBeUndefined(); }); + + it("persists a non-extractable purpose key and recovers encrypted chunks and blobs", async () => { + const dataDatabaseName = createDbName(); + const keyDatabaseName = `${dataDatabaseName}-keys`; + const purpose = "pipeline-test:payload:aes-gcm:v1"; + const sid = "S-idb-key-recovery"; + const hash = "f".repeat(64); + const eventSecret = "EVENT_SECRET_MUST_NOT_BE_PLAINTEXT"; + const blobSecret = "BLOB_SECRET_MUST_NOT_BE_PLAINTEXT"; + const firstManagedKey = await getOrCreateIndexedDbPipelineStorageKey({ + databaseName: keyDatabaseName, + purpose + }); + const rawStorage = new IndexedDbPipelineStorage(dataDatabaseName); + const firstStorage = new EncryptedPipelineStorage(rawStorage, { + key: firstManagedKey.key + }); + + expect(firstManagedKey.created).toBe(true); + expect(firstManagedKey.key.extractable).toBe(false); + await firstStorage.assertReady(); + await firstStorage.putChunk(createChunk(sid, "C-secret", 1, `${eventSecret}\n`)); + await firstStorage.putBlob(createBlob(hash, new TextEncoder().encode(blobSecret)), sid); + + const persistedChunk = await rawStorage.getChunk(sid, "C-secret"); + const persistedBlob = await rawStorage.getBlob(hash); + expect(new TextDecoder().decode(persistedChunk?.bytes)).not.toContain(eventSecret); + expect(new TextDecoder().decode(persistedBlob?.bytes)).not.toContain(blobSecret); + + const recoveredManagedKey = await getOrCreateIndexedDbPipelineStorageKey({ + databaseName: keyDatabaseName, + purpose + }); + const recoveredStorage = new EncryptedPipelineStorage( + new IndexedDbPipelineStorage(dataDatabaseName), + { key: recoveredManagedKey.key } + ); + + expect(recoveredManagedKey.created).toBe(false); + await recoveredStorage.assertReady(); + expect( + new TextDecoder().decode((await recoveredStorage.getChunk(sid, "C-secret"))?.bytes) + ).toBe(`${eventSecret}\n`); + expect(new TextDecoder().decode((await recoveredStorage.getBlob(hash))?.bytes)).toBe( + blobSecret + ); + }); + + it("fails closed when an encrypted persistent storage key is missing", async () => { + const storage = new EncryptedPipelineStorage(new IndexedDbPipelineStorage(createDbName()), { + key: Promise.reject(new Error("simulated missing key")) + }); + + await expect(storage.assertReady()).rejects.toThrow(/key is unavailable/i); + }); + + it("rejects extractable keys for persistent payload encryption", async () => { + const extractableKey = await crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256 + }, + true, + ["encrypt", "decrypt"] + ); + const storage = new EncryptedPipelineStorage(new IndexedDbPipelineStorage(createDbName()), { + key: extractableKey + }); + + await expect(storage.assertReady()).rejects.toThrow(/non-extractable 256-bit AES-GCM/i); + }); + + it("purges a legacy IndexedDB database before encrypted storage initialization", async () => { + const databaseName = createDbName(); + const legacy = await openRawDb(databaseName, 1, (db) => { + db.createObjectStore("legacy", { keyPath: "id" }); + }); + await writeRawRows(legacy, "legacy", [{ id: "secret", value: "PLAINTEXT_LEGACY_DATA" }]); + legacy.close(); + + await deleteIndexedDbDatabase(databaseName); + + const reopened = await openRawDb(databaseName, 1, () => undefined); + expect(reopened.objectStoreNames.contains("legacy")).toBe(false); + reopened.close(); + }); }); diff --git a/packages/pipeline/src/storage.ts b/packages/pipeline/src/storage.ts index 5e751a4..c483e2f 100644 --- a/packages/pipeline/src/storage.ts +++ b/packages/pipeline/src/storage.ts @@ -29,7 +29,17 @@ export type StoredIndexes = { inverted: InvertedIndexEntry[]; }; +export type PipelineStorageSecurityCapability = Readonly<{ + persistence: "volatile" | "persistent"; + payloadProtection: "plaintext" | "authenticated-encryption"; + algorithm?: "AES-GCM"; +}>; + +export const PIPELINE_STORAGE_SECURITY = Symbol.for("@webblackbox/pipeline/storage-security"); + export type PipelineStorage = { + readonly [PIPELINE_STORAGE_SECURITY]: PipelineStorageSecurityCapability; + assertReady?(): Promise; putSession(metadata: SessionMetadata): Promise; getSession(sid: string): Promise; putChunk(chunk: StoredChunk): Promise; @@ -56,6 +66,8 @@ const MAX_QUOTA_RECOVERY_ATTEMPTS = 2; const STORAGE_ENCRYPTION_MAGIC = new Uint8Array([0x57, 0x42, 0x45, 0x31]); // WBE1 const STORAGE_ENCRYPTION_IV_BYTES = 12; const STORAGE_ENCRYPTION_KDF_ITERATIONS = 120_000; +const STORAGE_KEYRING_VERSION = 1; +const STORAGE_KEYRING_STORE = "keys"; export type PipelineStorageKeyOptions = { salt?: Uint8Array; @@ -72,7 +84,29 @@ export type EncryptedPipelineStorageOptions = { key: CryptoKey | Promise; }; +export type IndexedDbPipelineStorageKeyOptions = { + databaseName: string; + purpose: string; +}; + +export type IndexedDbPipelineStorageKey = { + key: CryptoKey; + created: boolean; +}; + +type IndexedDbPipelineStorageKeyRecord = { + purpose: string; + algorithm: "AES-GCM"; + createdAt: number; + key: CryptoKey; +}; + export class MemoryPipelineStorage implements PipelineStorage { + public readonly [PIPELINE_STORAGE_SECURITY] = Object.freeze({ + persistence: "volatile", + payloadProtection: "plaintext" + } as const); + private readonly sessions = new Map(); private readonly chunks = new Map(); @@ -257,11 +291,104 @@ export async function derivePipelineStorageKey( }; } +/** + * Loads or creates a non-extractable AES-GCM key in a dedicated IndexedDB keyring. + * The caller must use a purpose reserved for pipeline payload encryption. + */ +export async function getOrCreateIndexedDbPipelineStorageKey( + options: IndexedDbPipelineStorageKeyOptions +): Promise { + const databaseName = requireNonEmptyStorageIdentifier(options.databaseName, "databaseName"); + const purpose = requireNonEmptyStorageIdentifier(options.purpose, "purpose"); + const db = await openPipelineStorageKeyring(databaseName); + + try { + const existing = await readPipelineStorageKeyRecord(db, purpose); + + if (existing) { + assertValidPipelineStorageKeyRecord(existing, purpose); + return { + key: existing.key, + created: false + }; + } + + const cryptoApi = requireCryptoApi(); + const generated = await cryptoApi.subtle.generateKey( + { + name: "AES-GCM", + length: 256 + }, + false, + ["encrypt", "decrypt"] + ); + assertValidPipelineStorageKey(generated); + + const record: IndexedDbPipelineStorageKeyRecord = { + purpose, + algorithm: "AES-GCM", + createdAt: Date.now(), + key: generated + }; + + try { + await runTransaction(db, STORAGE_KEYRING_STORE, "readwrite", (store) => { + return requestToPromise(store.add(record)); + }); + return { + key: generated, + created: true + }; + } catch (error) { + if (!isIndexedDbConstraintError(error)) { + throw error; + } + + const concurrent = await readPipelineStorageKeyRecord(db, purpose); + + if (!concurrent) { + throw new Error("Pipeline storage key creation raced but no persisted key was found."); + } + + assertValidPipelineStorageKeyRecord(concurrent, purpose); + return { + key: concurrent.key, + created: false + }; + } + } finally { + db.close(); + } +} + +/** Deletes an IndexedDB database, failing when another context blocks the purge. */ +export async function deleteIndexedDbDatabase(databaseName: string): Promise { + const normalized = requireNonEmptyStorageIdentifier(databaseName, "databaseName"); + + if (!globalThis.indexedDB) { + throw new Error("indexedDB is unavailable in this runtime"); + } + + await new Promise((resolve, reject) => { + const request = globalThis.indexedDB.deleteDatabase(normalized); + + request.onsuccess = () => resolve(); + request.onerror = () => { + reject(request.error ?? new Error(`Failed to delete IndexedDB database: ${normalized}`)); + }; + request.onblocked = () => { + reject(new Error(`IndexedDB database purge was blocked: ${normalized}`)); + }; + }); +} + /** * PipelineStorage wrapper that encrypts chunk/blob payload bytes before writing * and decrypts on read. Metadata/indexes remain plaintext for queryability. */ export class EncryptedPipelineStorage implements PipelineStorage { + public readonly [PIPELINE_STORAGE_SECURITY]: PipelineStorageSecurityCapability; + private readonly keyPromise: Promise; public constructor( @@ -269,6 +396,16 @@ export class EncryptedPipelineStorage implements PipelineStorage { options: EncryptedPipelineStorageOptions ) { this.keyPromise = Promise.resolve(options.key); + this[PIPELINE_STORAGE_SECURITY] = Object.freeze({ + persistence: storage[PIPELINE_STORAGE_SECURITY]?.persistence ?? "persistent", + payloadProtection: "authenticated-encryption", + algorithm: "AES-GCM" + }); + } + + public async assertReady(): Promise { + await this.resolveKey(); + await this.storage.assertReady?.(); } public async putSession(metadata: SessionMetadata): Promise { @@ -371,7 +508,7 @@ export class EncryptedPipelineStorage implements PipelineStorage { } private async encryptStoredBytes(bytes: Uint8Array): Promise { - const key = await this.keyPromise; + const key = await this.resolveKey(); const iv = randomBytes(STORAGE_ENCRYPTION_IV_BYTES); const cryptoApi = requireCryptoApi(); const encrypted = await cryptoApi.subtle.encrypt( @@ -388,10 +525,12 @@ export class EncryptedPipelineStorage implements PipelineStorage { private async decryptStoredBytes(bytes: Uint8Array): Promise { if (!looksEncryptedStorageBytes(bytes)) { - return bytes; + throw new Error( + "Refusing to read plaintext payload from encrypted pipeline storage. Purge or explicitly migrate legacy storage before use." + ); } - const key = await this.keyPromise; + const key = await this.resolveKey(); const ivStart = STORAGE_ENCRYPTION_MAGIC.byteLength; const ivEnd = ivStart + STORAGE_ENCRYPTION_IV_BYTES; const iv = bytes.slice(ivStart, ivEnd); @@ -413,6 +552,19 @@ export class EncryptedPipelineStorage implements PipelineStorage { throw new Error("Unable to decrypt pipeline storage payload."); } } + + private async resolveKey(): Promise { + let key: CryptoKey; + + try { + key = await this.keyPromise; + } catch { + throw new Error("Encrypted pipeline storage key is unavailable."); + } + + assertValidPipelineStorageKey(key); + return key; + } } type DbRow = { @@ -436,10 +588,19 @@ const DB_VERSION = 3; const CHUNKS_BY_SID_SEQ_INDEX = "by-sid-seq"; export class IndexedDbPipelineStorage implements PipelineStorage { + public readonly [PIPELINE_STORAGE_SECURITY] = Object.freeze({ + persistence: "persistent", + payloadProtection: "plaintext" + } as const); + private dbPromise: Promise | null = null; public constructor(private readonly dbName = "webblackbox-pipeline") {} + public async assertReady(): Promise { + await this.db(); + } + public async putSession(metadata: SessionMetadata): Promise { await this.put( "sessions", @@ -787,6 +948,9 @@ export class IndexedDbPipelineStorage implements PipelineStorage { }; request.onsuccess = () => { + request.result.onversionchange = () => { + request.result.close(); + }; resolve(request.result); }; @@ -1069,6 +1233,84 @@ function looksEncryptedStorageBytes(bytes: Uint8Array): boolean { return true; } +function assertValidPipelineStorageKey(key: CryptoKey): void { + const algorithm = key?.algorithm as AesKeyAlgorithm | undefined; + const usages = Array.from(key?.usages ?? []); + + if ( + !key || + key.type !== "secret" || + key.extractable || + algorithm?.name !== "AES-GCM" || + algorithm.length !== 256 || + !usages.includes("encrypt") || + !usages.includes("decrypt") + ) { + throw new Error( + "Pipeline storage encryption requires a non-extractable 256-bit AES-GCM key with encrypt/decrypt usage." + ); + } +} + +function assertValidPipelineStorageKeyRecord( + record: IndexedDbPipelineStorageKeyRecord, + purpose: string +): void { + if (record.purpose !== purpose || record.algorithm !== "AES-GCM") { + throw new Error(`Invalid persisted pipeline storage key record for purpose: ${purpose}`); + } + + assertValidPipelineStorageKey(record.key); +} + +function requireNonEmptyStorageIdentifier(value: string, field: string): string { + const normalized = typeof value === "string" ? value.trim() : ""; + + if (!normalized || normalized.length > 256) { + throw new Error(`Pipeline storage ${field} must be a non-empty string up to 256 characters.`); + } + + return normalized; +} + +function openPipelineStorageKeyring(databaseName: string): Promise { + if (!globalThis.indexedDB) { + return Promise.reject(new Error("indexedDB is unavailable in this runtime")); + } + + return new Promise((resolve, reject) => { + const request = globalThis.indexedDB.open(databaseName, STORAGE_KEYRING_VERSION); + + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(STORAGE_KEYRING_STORE)) { + request.result.createObjectStore(STORAGE_KEYRING_STORE, { keyPath: "purpose" }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => { + reject( + request.error ?? new Error(`Failed to open pipeline storage keyring: ${databaseName}`) + ); + }; + }); +} + +async function readPipelineStorageKeyRecord( + db: IDBDatabase, + purpose: string +): Promise { + return runTransaction(db, STORAGE_KEYRING_STORE, "readonly", (store) => { + return requestToPromise(store.get(purpose)); + }); +} + +function isIndexedDbConstraintError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === "ConstraintError") || + (error instanceof Error && error.name === "ConstraintError") + ); +} + async function runTransaction( db: IDBDatabase, storeName: string, diff --git a/packages/webblackbox/README.md b/packages/webblackbox/README.md index 9953c4c..6542e1c 100644 --- a/packages/webblackbox/README.md +++ b/packages/webblackbox/README.md @@ -61,9 +61,11 @@ import { installInjectedLiteCaptureHooks } from "webblackbox/injected-hooks"; import { materializeLiteRawEvent } from "webblackbox/lite-materializer"; ``` -## Optional IndexedDB Cache Encryption +## IndexedDB Cache Encryption -When using `storage: "indexeddb"`, you can provide `pipelineStorageEncryptionKey` to encrypt cached chunk/blob payload bytes at rest. +When using `storage: "indexeddb"` under the default `localAtRest: "required"` policy, the SDK automatically creates a non-extractable AES-GCM key in a purpose-specific IndexedDB keyring and encrypts cached chunk/blob payload bytes. Recreating the SDK with the same `indexedDbName` recovers the key and cache after a page restart. If the key is missing, the SDK purges any unverifiable legacy/plaintext data before opening the cache. + +You can provide `pipelineStorageEncryptionKey` when key lifecycle is managed externally: ```ts import { derivePipelineStorageKey } from "@webblackbox/pipeline"; @@ -77,7 +79,7 @@ const sdk = new WebBlackboxLiteSdk({ }); ``` -Persist `derived.salt` + `derived.iterations` using your own key-management policy if you need to reopen the same encrypted cache. +Persist `derived.salt` + `derived.iterations` using your own key-management policy to derive the same key after restart. The key must be non-extractable AES-GCM-256 with `encrypt` and `decrypt` usages. Passing a raw persistent `pipelineStorage` without an authenticated-encryption capability is rejected at `start()`. ## Default Safety Tuning diff --git a/packages/webblackbox/package.json b/packages/webblackbox/package.json index 0500b04..292007e 100644 --- a/packages/webblackbox/package.json +++ b/packages/webblackbox/package.json @@ -66,6 +66,7 @@ "test": "vitest run --passWithNoTests" }, "devDependencies": { + "fake-indexeddb": "^6.2.5", "jsdom": "^26.1.0" }, "dependencies": { diff --git a/packages/webblackbox/src/lite-sdk.test.ts b/packages/webblackbox/src/lite-sdk.test.ts index 75e1c40..b118748 100644 --- a/packages/webblackbox/src/lite-sdk.test.ts +++ b/packages/webblackbox/src/lite-sdk.test.ts @@ -1,6 +1,12 @@ +import "fake-indexeddb/auto"; + import { describe, expect, it, vi, beforeEach } from "vitest"; -import { MemoryPipelineStorage, readWebBlackboxArchive } from "@webblackbox/pipeline"; +import { + IndexedDbPipelineStorage, + MemoryPipelineStorage, + readWebBlackboxArchive +} from "@webblackbox/pipeline"; import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; import type { RawRecorderEvent } from "@webblackbox/recorder"; @@ -180,6 +186,72 @@ describe("WebBlackboxLiteSdk", () => { expect(agent?.disposeCalls).toBe(1); }); + it("auto-encrypts indexeddb payloads and recovers them after an SDK restart", async () => { + const databaseName = `wb-lite-encrypted-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const sid = "S-lite-indexeddb-encrypted-restart"; + const eventSecret = "LITE_EVENT_SECRET_MUST_BE_ENCRYPTED"; + const first = new WebBlackboxLiteSdk({ + sid, + storage: "indexeddb", + indexedDbName: databaseName, + injectHooks: false, + useDefaultPlugins: false + }); + + await first.start(); + first.ingestRawEvent( + createRawEvent("marker", { + message: eventSecret + }) + ); + await first.flush(); + + const rawStorage = new IndexedDbPipelineStorage(databaseName); + const persistedChunks = await rawStorage.listChunks(sid); + expect(persistedChunks.length).toBeGreaterThan(0); + expect( + persistedChunks.some((chunk) => new TextDecoder().decode(chunk.bytes).includes(eventSecret)) + ).toBe(false); + await first.dispose(); + + const restarted = new WebBlackboxLiteSdk({ + sid, + storage: "indexeddb", + indexedDbName: databaseName, + injectHooks: false, + useDefaultPlugins: false + }); + const exported = await restarted.export({ + passphrase: "restart-archive-passphrase" + }); + const parsed = await readWebBlackboxArchive(exported.bytes, { + passphrase: "restart-archive-passphrase" + }); + + expect(parsed.events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "user.marker" + }) + ]) + ); + await restarted.dispose(); + }); + + it("rejects a raw persistent custom storage under the required at-rest policy", async () => { + const sdk = new WebBlackboxLiteSdk({ + sid: "S-lite-raw-persistent-rejected", + pipelineStorage: new IndexedDbPipelineStorage( + `wb-lite-raw-${Date.now()}-${Math.random().toString(16).slice(2)}` + ), + injectHooks: false, + useDefaultPlugins: false + }); + + await expect(sdk.start()).rejects.toThrow(/persistent.*authenticated AES-GCM/i); + await sdk.dispose(); + }); + it("exports normalized events and materialized screenshot payloads", async () => { const sdk = new WebBlackboxLiteSdk({ sid: "S-sdk-export", diff --git a/packages/webblackbox/src/lite-sdk.ts b/packages/webblackbox/src/lite-sdk.ts index fe8ff67..b465cb8 100644 --- a/packages/webblackbox/src/lite-sdk.ts +++ b/packages/webblackbox/src/lite-sdk.ts @@ -1,5 +1,6 @@ import { DEFAULT_EXPORT_POLICY, + DEFAULT_CAPTURE_POLICY, DEFAULT_RECORDER_CONFIG, createSessionId, sanitizeUrlForPrivacy, @@ -13,6 +14,9 @@ import { FlightRecorderPipeline, IndexedDbPipelineStorage, MemoryPipelineStorage, + PIPELINE_STORAGE_SECURITY, + deleteIndexedDbDatabase, + getOrCreateIndexedDbPipelineStorageKey, type PipelineStorage } from "@webblackbox/pipeline"; import { @@ -38,6 +42,8 @@ const DEFAULT_TAB_ID = 0; const PIPELINE_BATCH_MAX_EVENTS = 160; const PIPELINE_BATCH_FLUSH_DELAY_MS = 120; const PIPELINE_BATCH_DRAIN_CHUNK_EVENTS = 160; +const PIPELINE_STORAGE_KEY_PURPOSE = "webblackbox-lite:pipeline-payload:aes-gcm:v1"; +const managedIndexedDbStorageKeys = new Map>(); /** * Browser-focused SDK for recording, buffering, and exporting Lite sessions. @@ -89,7 +95,11 @@ export class WebBlackboxLiteSdk { this.tabId = normalizeTabId(options.tabId); this.config = mergeRecorderConfig(options.config, options.sampling); this.session = createSessionMetadata(this.sid, this.tabId, options); - this.storage = resolveStorage(options, this.sid); + this.storage = resolveStorage( + options, + this.sid, + this.config.capturePolicy ?? DEFAULT_CAPTURE_POLICY + ); this.pipeline = new FlightRecorderPipeline({ session: this.session, storage: this.storage, @@ -675,7 +685,11 @@ function normalizeExportBoundedInt( return Math.min(max, Math.max(min, Math.round(value))); } -function resolveStorage(options: WebBlackboxLiteSdkOptions, sid: string): PipelineStorage { +function resolveStorage( + options: WebBlackboxLiteSdkOptions, + sid: string, + capturePolicy: NonNullable +): PipelineStorage { if (options.pipelineStorage) { return maybeWrapEncryptedStorage(options.pipelineStorage, options); } @@ -686,19 +700,59 @@ function resolveStorage(options: WebBlackboxLiteSdkOptions, sid: string): Pipeli return maybeWrapEncryptedStorage(new MemoryPipelineStorage(), options); } - return maybeWrapEncryptedStorage( - new IndexedDbPipelineStorage(options.indexedDbName ?? `webblackbox-lite-${sid}`), - options - ); + const databaseName = options.indexedDbName ?? `webblackbox-lite-${sid}`; + const key = + options.pipelineStorageEncryptionKey ?? + (capturePolicy.encryption.localAtRest === "required" + ? resolveManagedIndexedDbStorageKey(databaseName) + : undefined); + + return maybeWrapEncryptedStorage(new IndexedDbPipelineStorage(databaseName), { + ...options, + pipelineStorageEncryptionKey: key + }); } return maybeWrapEncryptedStorage(new MemoryPipelineStorage(), options); } +function resolveManagedIndexedDbStorageKey(databaseName: string): Promise { + const existing = managedIndexedDbStorageKeys.get(databaseName); + + if (existing) { + return existing; + } + + const key = getOrCreateIndexedDbPipelineStorageKey({ + databaseName: `${databaseName}-keyring-v1`, + purpose: PIPELINE_STORAGE_KEY_PURPOSE + }).then(async (managedKey) => { + if (managedKey.created) { + // A missing key makes any existing payloads unverifiable. Purge instead of + // silently mixing legacy plaintext or data encrypted with a lost key. + await deleteIndexedDbDatabase(databaseName); + } + + return managedKey.key; + }); + + managedIndexedDbStorageKeys.set(databaseName, key); + key.catch(() => { + if (managedIndexedDbStorageKeys.get(databaseName) === key) { + managedIndexedDbStorageKeys.delete(databaseName); + } + }); + return key; +} + function maybeWrapEncryptedStorage( storage: PipelineStorage, options: WebBlackboxLiteSdkOptions ): PipelineStorage { + if (storage[PIPELINE_STORAGE_SECURITY]?.payloadProtection === "authenticated-encryption") { + return storage; + } + if (!options.pipelineStorageEncryptionKey) { return storage; } diff --git a/packages/webblackbox/src/types.ts b/packages/webblackbox/src/types.ts index 5d71f2b..1042ea4 100644 --- a/packages/webblackbox/src/types.ts +++ b/packages/webblackbox/src/types.ts @@ -91,6 +91,7 @@ export type WebBlackboxLiteSdkOptions = { indexedDbName?: string; storage?: "memory" | "indexeddb"; pipelineStorage?: PipelineStorage; + /** External non-extractable AES-GCM-256 key; IndexedDB storage auto-manages one when omitted. */ pipelineStorageEncryptionKey?: CryptoKey | Promise; injectHooks?: boolean; injectHookFlag?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4374cd..09a6994 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: specifier: ^2.0.2 version: 2.0.2 devDependencies: + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^26.1.0 version: 26.1.0 From 18ba7337572099abacec2d77e9640de6710e3315 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:53:01 +0800 Subject: [PATCH 018/181] fix(bench): exercise filtered export hot path --- benchmarks/ci-thresholds.json | 5 +++- packages/pipeline/scripts/benchmark.ts | 27 ++++++++++++++++--- scripts/bench-regression-check.mjs | 37 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/benchmarks/ci-thresholds.json b/benchmarks/ci-thresholds.json index b245330..eb9ff96 100644 --- a/benchmarks/ci-thresholds.json +++ b/benchmarks/ci-thresholds.json @@ -8,6 +8,9 @@ "ingestMinOpsPerSec": 20000, "fullExportMaxMs": 10000, "filteredExportMaxMs": 8000, - "archiveDropRatioMin": 0.2 + "filteredParseMaxMs": 5000, + "archiveDropRatioMin": 0.2, + "eventDropRatioMin": 0.2, + "eventDropRatioMax": 0.8 } } diff --git a/packages/pipeline/scripts/benchmark.ts b/packages/pipeline/scripts/benchmark.ts index 0ddc1d6..6814686 100644 --- a/packages/pipeline/scripts/benchmark.ts +++ b/packages/pipeline/scripts/benchmark.ts @@ -1,11 +1,13 @@ import { performance } from "node:perf_hooks"; import type { + CapturePolicy, PrivacyClassification, PrivacyDataCategory, SessionMetadata, WebBlackboxEvent } from "@webblackbox/protocol"; +import { DEFAULT_CAPTURE_POLICY } from "@webblackbox/protocol"; import { FlightRecorderPipeline, @@ -21,6 +23,17 @@ const DEFAULT_SCREENSHOT_INTERVAL = 120; const DEFAULT_BLOB_POOL = 24; const DEFAULT_BLOB_BYTES = 24 * 1024; const EVENT_STEP_MS = 120; +const BENCHMARK_EVIDENCE_REF = "local-attestation:pipeline-benchmark"; +const BENCHMARK_CAPTURE_POLICY: CapturePolicy = { + ...DEFAULT_CAPTURE_POLICY, + captureContext: "local-debug", + captureContextEvidenceRef: BENCHMARK_EVIDENCE_REF, + encryption: { + localAtRest: "required", + archive: "synthetic-local-debug-exempt", + archiveKeyEnvelope: "none" + } +}; const FULL_EXPORT_OPTIONS = { includeScreenshots: true, includeScreenRecordings: true, @@ -34,6 +47,7 @@ type PipelineBenchmarkReport = { screenshotInterval: number; maxArchiveMb: number; recentMinutes: number; + eventStepMs: number; ingestDurationMs: number; ingestThroughputOpsPerSec: number; chunkCount: number; @@ -119,7 +133,9 @@ async function createBenchmarkPipeline(): Promise<{ session, storage, maxChunkBytes: 512 * 1024, - chunkCodec: "none" + chunkCodec: "none", + capturePolicy: BENCHMARK_CAPTURE_POLICY, + trustedPlaintextExemptionEvidenceRefs: [BENCHMARK_EVIDENCE_REF] }); await pipeline.start(); @@ -267,14 +283,18 @@ async function run(): Promise { screenshotHashes.push(hash); } - const baseTime = Date.now() - 45 * 60 * 1000; + const eventStepMs = Math.max( + EVENT_STEP_MS, + Math.ceil((recentWindowMs * 2) / Math.max(1, eventCount - 1)) + ); + const baseTime = Date.now() - eventStepMs * Math.max(0, eventCount - 1); const ingestStart = performance.now(); for (let index = 0; index < eventCount; index += 1) { const event = createEvent( session.sid, index, - baseTime + index * EVENT_STEP_MS, + baseTime + index * eventStepMs, textPool, screenshotHashes, screenshotInterval, @@ -326,6 +346,7 @@ async function run(): Promise { screenshotInterval, maxArchiveMb, recentMinutes, + eventStepMs, ingestDurationMs: ingestMs, ingestThroughputOpsPerSec: ingestOps, chunkCount: chunks.length, diff --git a/scripts/bench-regression-check.mjs b/scripts/bench-regression-check.mjs index 934f5f7..1806b80 100644 --- a/scripts/bench-regression-check.mjs +++ b/scripts/bench-regression-check.mjs @@ -157,6 +157,32 @@ function runChecks(recorder, pipeline, thresholds) { ) ); + checks.push( + assertCheck( + "pipeline.filteredParseDurationMs", + pipeline.filteredParseDurationMs <= thresholds.pipeline.filteredParseMaxMs, + `expected <= ${thresholds.pipeline.filteredParseMaxMs}, got ${pipeline.filteredParseDurationMs.toFixed( + 2 + )}` + ) + ); + + checks.push( + assertCheck( + "pipeline.filteredExportEvents.nonEmpty", + pipeline.filteredExportEvents > 0, + `expected > 0, got ${pipeline.filteredExportEvents}` + ) + ); + + checks.push( + assertCheck( + "pipeline.filteredExportEvents.reduced", + pipeline.filteredExportEvents < pipeline.fullExportEvents, + `expected < ${pipeline.fullExportEvents}, got ${pipeline.filteredExportEvents}` + ) + ); + checks.push( assertCheck( "pipeline.archiveDropRatio", @@ -165,6 +191,17 @@ function runChecks(recorder, pipeline, thresholds) { ) ); + checks.push( + assertCheck( + "pipeline.eventDropRatio", + pipeline.eventDropRatio >= thresholds.pipeline.eventDropRatioMin && + pipeline.eventDropRatio <= thresholds.pipeline.eventDropRatioMax, + `expected ${thresholds.pipeline.eventDropRatioMin}..${thresholds.pipeline.eventDropRatioMax}, got ${pipeline.eventDropRatio.toFixed( + 3 + )}` + ) + ); + return checks; } From 9c87bb1dcba29ce86c1e6242c99b67938160a0ca Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 00:59:59 +0800 Subject: [PATCH 019/181] fix(extension): sync storage test dependency lock --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09a6994..c0f53ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: specifier: workspace:* version: link:../../packages/webblackbox devDependencies: + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jszip: specifier: ^3.10.1 version: 3.10.1 From eaa1550af570188720e852d49c402ca55d049934 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:00:12 +0800 Subject: [PATCH 020/181] fix(deps): update MCP SDK and gate production audit --- .github/workflows/ci.yml | 3 + apps/mcp-server/package.json | 2 +- package.json | 9 ++ pnpm-lock.yaml | 248 +++++++++++++++++++++-------------- 4 files changed, 165 insertions(+), 97 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 155b32a..dbf9297 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Production dependency audit + run: pnpm audit --prod --audit-level=high + - name: Format Check run: pnpm format:check diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 7ec6db7..6cee5bb 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -56,7 +56,7 @@ "test": "vitest run --config vitest.config.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.21.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@webblackbox/player-sdk": "workspace:*", "jszip": "^3.10.1", "zod": "^4.1.11" diff --git a/package.json b/package.json index d255cda..cafffa7 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,15 @@ "engines": { "node": ">=22.0.0" }, + "pnpm": { + "overrides": { + "@hono/node-server": "1.19.10", + "express-rate-limit": "8.5.2", + "fast-uri": "3.1.2", + "hono": "4.12.29", + "path-to-regexp": "8.4.2" + } + }, "scripts": { "dev": "turbo run dev --parallel", "build": "turbo run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0f53ed..b16b62c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@hono/node-server': 1.19.10 + express-rate-limit: 8.5.2 + fast-uri: 3.1.2 + hono: 4.12.29 + path-to-regexp: 8.4.2 + importers: .: @@ -94,8 +101,8 @@ importers: apps/mcp-server: dependencies: '@modelcontextprotocol/sdk': - specifier: ^1.21.1 - version: 1.26.0(zod@4.3.6) + specifier: ^1.29.0 + version: 1.29.0(zod@4.3.6) '@webblackbox/player-sdk': specifier: workspace:* version: link:../../packages/player-sdk @@ -235,6 +242,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -243,6 +254,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} @@ -252,6 +267,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -560,11 +579,11 @@ packages: '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + '@hono/node-server@1.19.10': + resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.29 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -610,8 +629,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -975,6 +994,9 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1055,8 +1077,8 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} brace-expansion@1.1.12: @@ -1198,14 +1220,18 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + conventional-commit-types@3.0.0: resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==} @@ -1366,8 +1392,8 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} esbuild@0.27.3: @@ -1443,8 +1469,8 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} eventsource@3.0.7: @@ -1459,8 +1485,8 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} peerDependencies: express: '>= 4.11' @@ -1493,8 +1519,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1653,16 +1679,16 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} homedir-polyfill@1.0.3: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} - hono@4.11.9: - resolution: {integrity: sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==} + hono@4.12.29: + resolution: {integrity: sha512-1hNiRjawYrLq/4m3DQQjPGFg0VZkk4RjQJDff/excI6Dm9BiL75qxGrd7/c6YOxPdq6AscP3LiXhQ6fKFC1Waw==} engines: {node: '>=16.9.0'} html-encoding-sniffer@4.0.0: @@ -1705,6 +1731,10 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -1752,8 +1782,8 @@ packages: resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} engines: {node: '>=12.0.0'} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1834,8 +1864,8 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} @@ -2184,8 +2214,8 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-to-regexp@8.3.0: - resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} @@ -2280,8 +2310,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -2290,8 +2320,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: @@ -2425,8 +2455,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -2437,8 +2467,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -2686,9 +2716,9 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typedoc@0.28.17: resolution: {integrity: sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==} @@ -2895,10 +2925,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: - zod: ^3.25 || ^4 + zod: ^3.25.28 || ^4 zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2920,17 +2950,28 @@ snapshots: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 + optional: true + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': {} + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -3275,9 +3316,9 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 - '@hono/node-server@1.19.9(hono@4.11.9)': + '@hono/node-server@1.19.10(hono@4.12.29)': dependencies: - hono: 4.11.9 + hono: 4.12.29 '@humanfs/core@0.19.1': {} @@ -3327,25 +3368,25 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.9) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) + '@hono/node-server': 1.19.10(hono@4.12.29) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 cors: 2.8.6 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.11.9 - jose: 6.1.3 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.29 + jose: 6.2.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) + zod-to-json-schema: 3.25.2(zod@4.3.6) transitivePeerDependencies: - supports-color @@ -3463,8 +3504,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3687,9 +3728,9 @@ snapshots: agent-base@7.1.4: {} - ajv-formats@3.0.1(ajv@8.17.1): + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: - ajv: 8.17.1 + ajv: 8.20.0 ajv@6.12.6: dependencies: @@ -3701,7 +3742,15 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + optional: true + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3771,17 +3820,17 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - body-parser@2.2.2: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.3 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -3918,10 +3967,12 @@ snapshots: consola@3.4.2: {} - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} + content-type@2.0.0: {} + conventional-commit-types@3.0.0: {} conventional-commits-parser@6.3.0: @@ -4062,7 +4113,7 @@ snapshots: es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4179,11 +4230,11 @@ snapshots: eventemitter3@5.0.4: {} - eventsource-parser@3.0.6: {} + eventsource-parser@3.1.0: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.1.0 expand-tilde@2.0.2: dependencies: @@ -4191,16 +4242,16 @@ snapshots: expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 - ip-address: 10.0.1 + ip-address: 10.2.0 express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.2.2 - content-disposition: 1.0.1 + body-parser: 2.3.0 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -4218,13 +4269,13 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 + qs: 6.15.3 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -4253,7 +4304,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fastq@1.20.1: dependencies: @@ -4360,18 +4411,18 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-tsconfig@4.13.6: dependencies: @@ -4436,7 +4487,7 @@ snapshots: has-symbols@1.1.0: {} - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -4444,7 +4495,7 @@ snapshots: dependencies: parse-passwd: 1.0.0 - hono@4.11.9: {} + hono@4.12.29: {} html-encoding-sniffer@4.0.0: dependencies: @@ -4490,6 +4541,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -4540,7 +4595,7 @@ snapshots: through: 2.3.8 wrap-ansi: 7.0.0 - ip-address@10.0.1: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -4600,7 +4655,7 @@ snapshots: jiti@2.6.1: optional: true - jose@6.1.3: {} + jose@6.2.3: {} joycon@3.1.1: {} @@ -4953,7 +5008,7 @@ snapshots: path-key@3.1.1: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -5017,21 +5072,22 @@ snapshots: punycode@2.3.1: {} - qs@6.14.2: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 quansync@0.2.11: {} queue-microtask@1.2.3: {} - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 unpipe: 1.0.0 react-dom@19.2.4(react@19.2.4): @@ -5137,7 +5193,7 @@ snapshots: depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 - path-to-regexp: 8.3.0 + path-to-regexp: 8.4.2 transitivePeerDependencies: - supports-color @@ -5176,7 +5232,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -5200,7 +5256,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -5220,11 +5276,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -5448,9 +5504,9 @@ snapshots: type-fest@0.21.3: {} - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 @@ -5609,7 +5665,7 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.25.1(zod@4.3.6): + zod-to-json-schema@3.25.2(zod@4.3.6): dependencies: zod: 4.3.6 From f69b17a942394ce8d70b6cfff9b2a457113544db Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:01:08 +0800 Subject: [PATCH 021/181] fix(packages): include licenses in published tarballs --- apps/mcp-server/LICENSE | 21 +++++++++++++++++++++ packages/cdp-router/LICENSE | 21 +++++++++++++++++++++ packages/pipeline/LICENSE | 21 +++++++++++++++++++++ packages/player-sdk/LICENSE | 21 +++++++++++++++++++++ packages/protocol/LICENSE | 21 +++++++++++++++++++++ packages/recorder/LICENSE | 21 +++++++++++++++++++++ packages/webblackbox/LICENSE | 21 +++++++++++++++++++++ 7 files changed, 147 insertions(+) create mode 100644 apps/mcp-server/LICENSE create mode 100644 packages/cdp-router/LICENSE create mode 100644 packages/pipeline/LICENSE create mode 100644 packages/player-sdk/LICENSE create mode 100644 packages/protocol/LICENSE create mode 100644 packages/recorder/LICENSE create mode 100644 packages/webblackbox/LICENSE diff --git a/apps/mcp-server/LICENSE b/apps/mcp-server/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/apps/mcp-server/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/cdp-router/LICENSE b/packages/cdp-router/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/packages/cdp-router/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/pipeline/LICENSE b/packages/pipeline/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/packages/pipeline/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/player-sdk/LICENSE b/packages/player-sdk/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/packages/player-sdk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/protocol/LICENSE b/packages/protocol/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/packages/protocol/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/recorder/LICENSE b/packages/recorder/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/packages/recorder/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/webblackbox/LICENSE b/packages/webblackbox/LICENSE new file mode 100644 index 0000000..fcf7ea4 --- /dev/null +++ b/packages/webblackbox/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Web LLM + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From baf5e19e083e6b0ce2e1347caecb67a327387f52 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:02:27 +0800 Subject: [PATCH 022/181] fix(build): include every workspace in root tsconfig --- tsconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tsconfig.json b/tsconfig.json index 9dd46d5..e63a440 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,9 @@ { "path": "./packages/player-sdk" }, + { + "path": "./packages/webblackbox" + }, { "path": "./apps/mcp-server" }, @@ -26,6 +29,9 @@ }, { "path": "./apps/player" + }, + { + "path": "./apps/share-server" } ] } From 4bb5c167e286249eceb12b276805dc59381f1466 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:03:16 +0800 Subject: [PATCH 023/181] docs(protocol): align event catalog with schema --- README.md | 2 +- packages/protocol/README.md | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6a33466..146a81f 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Full SDK details are in [packages/webblackbox/README.md](packages/webblackbox/RE ## What It Captures -WebBlackbox currently records 57 event types across 13 categories, including: +WebBlackbox currently records 62 event types across 12 categories, including: - User input and navigation - Console logs and runtime errors diff --git a/packages/protocol/README.md b/packages/protocol/README.md index 9877423..01a6f02 100644 --- a/packages/protocol/README.md +++ b/packages/protocol/README.md @@ -36,7 +36,7 @@ npm install @webblackbox/protocol ## Event Types -WebBlackbox currently defines 57 event types, organized by category: +WebBlackbox currently defines 62 event types, organized across 12 categories: ### Meta Events @@ -44,6 +44,10 @@ WebBlackbox currently defines 57 event types, organized by category: - `meta.session.end` — Session termination - `meta.config` — Configuration snapshot +### Privacy Events + +- `privacy.violation` — Capture-policy or redaction violation notice + ### System Events - `sys.debugger.attach` / `sys.debugger.detach` — CDP debugger lifecycle @@ -101,6 +105,10 @@ WebBlackbox currently defines 57 event types, organized by category: ### Screen Events - `screen.screenshot` — Page screenshot with pointer position +- `screen.recording.start` — Screen recording session started +- `screen.recording.chunk` — Screen recording media chunk +- `screen.recording.end` — Screen recording session completed +- `screen.recording.error` — Screen recording failure - `screen.viewport` — Viewport dimension changes ### Storage Events From e64a70091be610ac898cd45c4e6b6e568e1c0ce1 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:03:50 +0800 Subject: [PATCH 024/181] fix(player): validate archive security invariants --- apps/mcp-server/src/session-tools.test.ts | 29 +- apps/share-server/src/index.test.ts | 4 +- packages/pipeline/src/exporter.ts | 142 ++++-- packages/pipeline/src/index.test.ts | 126 +++++- packages/player-sdk/src/index.test.ts | 349 ++++++++++++++- packages/player-sdk/src/index.ts | 235 ++++++---- packages/protocol/src/archive-validation.ts | 407 ++++++++++++++++++ packages/protocol/src/constants.ts | 4 + packages/protocol/src/index.ts | 1 + packages/protocol/src/schemas.ts | 178 ++++++-- .../webblackbox/src/lite-materializer.test.ts | 6 +- packages/webblackbox/src/lite-materializer.ts | 6 +- 12 files changed, 1290 insertions(+), 197 deletions(-) create mode 100644 packages/protocol/src/archive-validation.ts diff --git a/apps/mcp-server/src/session-tools.test.ts b/apps/mcp-server/src/session-tools.test.ts index 90f5a25..d3e10bb 100644 --- a/apps/mcp-server/src/session-tools.test.ts +++ b/apps/mcp-server/src/session-tools.test.ts @@ -314,6 +314,26 @@ async function createArchiveFixture(events: WebBlackboxEvent[]): Promise JSON.stringify(event)).join("\n"); + const eventBytes = Buffer.from(eventContent); + const timeIndex = + firstEvent && lastEvent + ? [ + { + chunkId, + seq: 1, + tStart: firstEvent.t, + tEnd: lastEvent.t, + monoStart: firstEvent.mono, + monoEnd: lastEvent.mono, + eventCount: events.length, + byteLength: eventBytes.byteLength, + codec: "none" as const, + sha256: sha256Hex(eventContent) + } + ] + : []; const manifest: ExportManifest = { protocolVersion: 1, createdAt: new Date(0).toISOString(), @@ -350,13 +370,10 @@ async function createArchiveFixture(events: WebBlackboxEvent[]): Promise JSON.stringify(event)).join("\n") - ); + addTextFile(`events/${chunkId}.ndjson`, eventContent); const fileHashes = Object.fromEntries( [...files.entries()].map(([path, content]) => [path, sha256Hex(content)]) @@ -438,6 +455,7 @@ function createBaselineEvents(): WebBlackboxEvent[] { data: { requestId: "REQ-B-1", response: { + url: "https://example.com/api/items", status: 200, statusText: "OK", mimeType: "application/json" @@ -560,6 +578,7 @@ function createRegressionEvents(): WebBlackboxEvent[] { data: { requestId: "REQ-R-1", response: { + url: "https://example.com/api/items", status: 500, statusText: "Internal Server Error", mimeType: "application/json" diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 70f89cb..643b7cb 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -700,7 +700,7 @@ async function createEnvelopeArchive( origin: "https://fixture.example", title: "Fixture" }, - chunkCodec: "ndjson", + chunkCodec: "none", redactionProfile: { redactHeaders: [], redactCookieNames: [], @@ -711,7 +711,7 @@ async function createEnvelopeArchive( stats: { eventCount: 0, chunkCount: 0, - blobCount: 0, + blobCount: 2, durationMs: 0 }, ...(encrypted diff --git a/packages/pipeline/src/exporter.ts b/packages/pipeline/src/exporter.ts index fd3eff9..b86deac 100644 --- a/packages/pipeline/src/exporter.ts +++ b/packages/pipeline/src/exporter.ts @@ -1,6 +1,18 @@ import JSZip from "jszip"; -import { inferBlobFileExtension } from "@webblackbox/protocol"; +import { + assertArchiveChunk, + assertArchiveEventIndexes, + assertArchiveLayout, + inferBlobFileExtension, + parseArchivedEvent, + parseExportManifest, + parseHashesManifest, + parseInvertedIndex, + parsePrivacyManifest, + parseRequestIndex, + parseTimeIndex +} from "@webblackbox/protocol"; import type { ChunkCodec, @@ -14,7 +26,7 @@ import type { WebBlackboxEvent } from "@webblackbox/protocol"; -import { decodeChunkEvents } from "./codec.js"; +import { decodeChunkBytes } from "./codec.js"; import { sha256Hex } from "./hash.js"; import type { StoredBlob, StoredChunk } from "./storage.js"; @@ -144,41 +156,42 @@ export async function readWebBlackboxArchive( options: ArchiveReadOptions = {} ): Promise { const zip = await JSZip.loadAsync(bytes); - const integrity = await readJson(zip, "integrity/hashes.json"); + const integrity = parseHashesManifest(await readJsonValue(zip, "integrity/hashes.json")); await verifyArchiveIntegrity(zip, integrity); - const manifest = await readJson(zip, "manifest.json"); + const manifest = parseExportManifest(await readJsonValue(zip, "manifest.json")); const archiveKey = await resolveArchiveReadKey(manifest, options.passphrase); - const timeIndex = await readArchiveJson( - zip, - "index/time.json", - manifest, - archiveKey + const timeIndex = parseTimeIndex( + await readArchiveJsonValue(zip, "index/time.json", manifest, archiveKey) ); - const requestIndex = await readArchiveJson( - zip, - "index/req.json", - manifest, - archiveKey + const requestIndex = parseRequestIndex( + await readArchiveJsonValue(zip, "index/req.json", manifest, archiveKey) ); - const invertedIndex = await readArchiveJson( - zip, - "index/inv.json", - manifest, - archiveKey + const invertedIndex = parseInvertedIndex( + await readArchiveJsonValue(zip, "index/inv.json", manifest, archiveKey) ); - const privacyManifest = await readOptionalArchiveJson( + const privacyValue = await readOptionalArchiveJsonValue( zip, "privacy/manifest.json", manifest, archiveKey ); + const privacyManifest = privacyValue === null ? null : parsePrivacyManifest(privacyValue); + + assertArchiveLayout({ + paths: archiveFilePaths(zip), + manifest, + timeIndex, + requestIndex, + invertedIndex, + privacyManifest + }); const eventEntries = Object.keys(zip.files) .filter((path) => path.startsWith("events/") && path.endsWith(".ndjson")) .sort(); - const chunkCodecById = new Map(timeIndex.map((entry) => [entry.chunkId, entry.codec] as const)); + const chunkIndexById = new Map(timeIndex.map((entry) => [entry.chunkId, entry] as const)); const events: WebBlackboxEvent[] = []; @@ -186,17 +199,33 @@ export async function readWebBlackboxArchive( const file = zip.file(path); if (!file) { - continue; + throw new Error(`Invalid WebBlackbox archive: missing indexed event chunk '${path}'.`); } const content = await file.async("uint8array"); const decoded = await decryptArchiveFile(path, content, manifest, archiveKey); const chunkId = parseChunkIdFromPath(path); - const codec = - (chunkId ? chunkCodecById.get(chunkId) : undefined) ?? (manifest.chunkCodec as ChunkCodec); - events.push(...(await decodeChunkEvents(decoded, codec))); + const index = chunkId ? chunkIndexById.get(chunkId) : undefined; + + if (!index) { + throw new Error(`Invalid WebBlackbox archive: time index is missing event chunk '${path}'.`); + } + + const chunkEvents = await parseArchiveChunkEvents(decoded, index.codec, path); + + assertArchiveChunk({ + path, + index, + encodedByteLength: decoded.byteLength, + encodedSha256: await sha256Hex(decoded), + events: chunkEvents + }); + + events.push(...chunkEvents); } + assertArchiveEventIndexes(manifest, events, requestIndex, invertedIndex); + return { manifest, events, @@ -221,7 +250,7 @@ async function addJsonFile( fileHashes[path] = await sha256Hex(bytes); } -async function readJson(zip: JSZip, path: string): Promise { +async function readJsonValue(zip: JSZip, path: string): Promise { const file = zip.file(path); if (!file) { @@ -229,30 +258,73 @@ async function readJson(zip: JSZip, path: string): Promise { } const content = await file.async("string"); - return JSON.parse(content) as TValue; + return parseArchiveJson(content, path); } -async function readArchiveJson( +async function readArchiveJsonValue( zip: JSZip, path: string, manifest: ExportManifest, archiveKey: CryptoKey | null -): Promise { +): Promise { const content = await readArchiveFileText(zip, path, manifest, archiveKey); - return JSON.parse(content) as TValue; + return parseArchiveJson(content, path); } -async function readOptionalArchiveJson( +async function readOptionalArchiveJsonValue( zip: JSZip, path: string, manifest: ExportManifest, archiveKey: CryptoKey | null -): Promise { +): Promise { if (!zip.file(path)) { return null; } - return readArchiveJson(zip, path, manifest, archiveKey); + return readArchiveJsonValue(zip, path, manifest, archiveKey); +} + +function parseArchiveJson(content: string, path: string): unknown { + try { + return JSON.parse(content) as unknown; + } catch { + throw new Error(`Invalid WebBlackbox archive: '${path}' contains malformed JSON.`); + } +} + +async function parseArchiveChunkEvents( + encoded: Uint8Array, + codec: ChunkCodec, + path: string +): Promise { + const decoded = await decodeChunkBytes(encoded, codec); + const lines = new TextDecoder() + .decode(decoded) + .split(/\r?\n/) + .filter((line) => line.trim().length > 0); + const events: WebBlackboxEvent[] = []; + + for (const [lineIndex, line] of lines.entries()) { + let value: unknown; + + try { + value = JSON.parse(line) as unknown; + } catch { + throw new Error( + `Invalid WebBlackbox archive: '${path}' contains malformed JSON at event line ${lineIndex + 1}.` + ); + } + + events.push(parseArchivedEvent(value, path, lineIndex + 1)); + } + + return events; +} + +function archiveFilePaths(zip: JSZip): string[] { + return Object.entries(zip.files) + .filter(([, file]) => !file.dir) + .map(([path]) => path); } async function readArchiveFileText( @@ -395,7 +467,9 @@ async function decryptArchiveFile( const fileMeta = encryption.files[path]; if (!fileMeta) { - return bytes; + throw new Error( + `Invalid WebBlackbox archive: encrypted archive is missing file metadata for '${path}'.` + ); } if (!archiveKey) { diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index 3ba4a2c..4831b36 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -4,6 +4,7 @@ import JSZip from "jszip"; import { type CapturePolicy, DEFAULT_CAPTURE_POLICY, + type ExportManifest, type SessionMetadata, type WebBlackboxEvent } from "@webblackbox/protocol"; @@ -58,6 +59,40 @@ function createEvent( t: number, data?: WebBlackboxEvent["data"] ): WebBlackboxEvent { + const defaultData: Record = + type === "network.request" + ? { + reqId: "R-1", + url: "https://example.com/api", + method: "GET" + } + : type === "network.response" + ? { + reqId: "R-1", + status: 200 + } + : type === "console.entry" + ? { + level: "info", + text: "hello" + } + : { message: "hello" }; + const dataRecord = + data && typeof data === "object" && !Array.isArray(data) + ? (data as Record) + : null; + const shouldMergeDefaults = + (type === "network.request" && !dataRecord?.request) || + (type === "network.response" && !dataRecord?.response) || + type === "console.entry"; + const eventData = + dataRecord && shouldMergeDefaults + ? { + ...defaultData, + ...dataRecord + } + : (data ?? defaultData); + return { v: 1, sid: SESSION.sid, @@ -91,10 +126,7 @@ function createEvent( : "low", redacted: true }, - data: data ?? { - reqId: "R-1", - message: "hello" - } + data: eventData }; } @@ -970,6 +1002,62 @@ describe("pipeline", () => { ).toBe(true); }); + it("rejects plaintext fallback when an encrypted manifest omits private file metadata", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = new FlightRecorderPipeline({ + session: SESSION, + storage, + maxChunkBytes: 128 + }); + + await pipeline.start(); + await pipeline.ingest(createEvent("E-encryption-map", "user.click", 100)); + const exported = await pipeline.exportBundle({ + ...FULL_EXPORT_OPTIONS, + passphrase: "secret-passphrase" + }); + const zip = await JSZip.loadAsync(exported.bytes); + const manifestFile = zip.file("manifest.json"); + + if (!manifestFile) { + throw new Error("Missing encrypted archive manifest"); + } + + const manifest = JSON.parse(await manifestFile.async("string")) as ExportManifest; + + if (!manifest.encryption) { + throw new Error("Expected encrypted archive metadata"); + } + + manifest.encryption.files = {}; + zip.file("manifest.json", JSON.stringify(manifest)); + await writeArchiveIntegrityForTest(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect( + readWebBlackboxArchive(bytes, { passphrase: "secret-passphrase" }) + ).rejects.toThrow(/schema validation|at least one private file/i); + }); + + it("rejects schema-invalid event payloads while reading an archive", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 128 + }); + const invalidEvent = createEvent("E-invalid-payload", "network.request", 100); + invalidEvent.data = {}; + + await pipeline.start(); + await pipeline.ingest(invalidEvent); + const exported = await pipeline.exportBundle(FULL_EXPORT_OPTIONS); + + await expect(readWebBlackboxArchive(exported.bytes)).rejects.toThrow( + /event line 1 failed schema validation/i + ); + }); + it("supports optional at-rest encryption for chunk/blob cache payloads", async () => { const baseStorage = new MemoryPipelineStorage(); const key = await derivePipelineStorageKey("cache-passphrase", { @@ -1253,3 +1341,33 @@ describe("pipeline", () => { expect((await storage.listBlobs()).length).toBe(0); }); }); + +async function writeArchiveIntegrityForTest(zip: JSZip): Promise { + const files: Record = {}; + + for (const [path, file] of Object.entries(zip.files)) { + if (file.dir || path === "integrity/hashes.json") { + continue; + } + + const bytes = await file.async("uint8array"); + const digest = await crypto.subtle.digest("SHA-256", toArrayBufferForTest(bytes)); + files[path] = [...new Uint8Array(digest)] + .map((value) => value.toString(16).padStart(2, "0")) + .join(""); + } + + zip.file( + "integrity/hashes.json", + JSON.stringify({ + manifestSha256: files["manifest.json"] ?? "", + files + }) + ); +} + +function toArrayBufferForTest(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} diff --git a/packages/player-sdk/src/index.test.ts b/packages/player-sdk/src/index.test.ts index 92ef172..4381daf 100644 --- a/packages/player-sdk/src/index.test.ts +++ b/packages/player-sdk/src/index.test.ts @@ -168,27 +168,10 @@ describe("WebBlackboxPlayer", () => { ); }); - it("parses chunks lazily when queried", async () => { + it("rejects malformed event chunks while opening the archive", async () => { const bytes = await createLazyParseFixtureArchive(); - const player = await WebBlackboxPlayer.open(bytes); - - expect( - player.query({ - range: { - monoStart: 0, - monoEnd: 50 - } - }) - ).toEqual([expect.objectContaining({ id: "E-L-1" })]); - expect(() => - player.query({ - range: { - monoStart: 90, - monoEnd: 120 - } - }) - ).toThrow(/chunk-000002/i); + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow(/chunk-000002.*malformed JSON/i); }); it("memoizes full-range queries and derived analyzers", async () => { @@ -314,6 +297,181 @@ describe("WebBlackboxPlayer", () => { } }); + it("rejects plaintext archives that claim encryption with an empty file map", async () => { + const bytes = await rewriteArchiveManifest(await createFixtureArchive(), (manifest) => { + manifest.encryption = createEncryptionMetadata({}); + }); + + await expect(WebBlackboxPlayer.open(bytes, { passphrase: "test-passphrase" })).rejects.toThrow( + /schema validation|at least one private file/i + ); + }); + + it("rejects encrypted archives with incomplete private-file metadata", async () => { + const bytes = await rewriteArchiveManifest(await createFixtureArchive(), (manifest) => { + manifest.encryption = createEncryptionMetadata({ + "events/chunk-000001.ndjson": { + ivBase64: toBase64(randomBytes(12)) + } + }); + }); + + await expect(WebBlackboxPlayer.open(bytes, { passphrase: "test-passphrase" })).rejects.toThrow( + /missing file metadata for 'index\/time\.json'/i + ); + }); + + it("rejects encrypted archives that reuse an AES-GCM initialization vector", async () => { + const bytes = await rewriteArchiveManifest( + await createEncryptedArchive(await createFixtureArchive(), "test-passphrase"), + (manifest) => { + const encryption = manifest.encryption; + const reused = encryption?.files["index/time.json"]; + + if (!encryption || !reused) { + throw new Error("Missing encrypted fixture metadata"); + } + + encryption.files["events/chunk-000001.ndjson"] = { ...reused }; + } + ); + + await expect(WebBlackboxPlayer.open(bytes, { passphrase: "test-passphrase" })).rejects.toThrow( + /reuses an initialization vector/i + ); + }); + + it("rejects unsupported and unsafe archive encryption parameters", async () => { + const mutations: Array<(encryption: Record) => void> = [ + (encryption) => { + encryption.algorithm = "AES-CBC"; + }, + (encryption) => { + const kdf = encryption.kdf as Record; + kdf.name = "scrypt"; + }, + (encryption) => { + const kdf = encryption.kdf as Record; + kdf.hash = "SHA-1"; + }, + (encryption) => { + const kdf = encryption.kdf as Record; + kdf.iterations = 1; + }, + (encryption) => { + const kdf = encryption.kdf as Record; + kdf.iterations = 10_000_000; + }, + (encryption) => { + const kdf = encryption.kdf as Record; + kdf.saltBase64 = "invalid-salt"; + }, + (encryption) => { + const files = encryption.files as Record; + files["events/chunk-000001.ndjson"] = { ivBase64: "invalid-iv" }; + } + ]; + + for (const mutate of mutations) { + const bytes = await rewriteArchiveManifest(await createFixtureArchive(), (manifest) => { + const encryption = createEncryptionMetadata({ + "events/chunk-000001.ndjson": { + ivBase64: toBase64(randomBytes(12)) + } + }) as unknown as Record; + mutate(encryption); + manifest.encryption = encryption as unknown as ExportManifest["encryption"]; + }); + + await expect( + WebBlackboxPlayer.open(bytes, { passphrase: "test-passphrase" }) + ).rejects.toThrow(/schema validation/i); + } + }); + + it("rejects schema-invalid archived events during open", async () => { + const zip = await JSZip.loadAsync(await createFixtureArchive()); + const path = "events/chunk-000001.ndjson"; + const events = JSON.parse( + `[${(await zip.file(path)!.async("string")).split("\n").join(",")}]` + ) as WebBlackboxEvent[]; + const request = events.find((event) => event.type === "network.request"); + + if (!request) { + throw new Error("Missing network request fixture event"); + } + + request.data = {}; + zip.file(path, events.map((event) => JSON.stringify(event)).join("\n")); + await writeIntegrityManifest(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow( + /event line 3 failed schema validation/i + ); + }); + + it("rejects invalid chunk sequences, chunk mappings, and index event references", async () => { + const invalidSequenceZip = await JSZip.loadAsync(await createFixtureArchive()); + const timeIndex = JSON.parse( + await invalidSequenceZip.file("index/time.json")!.async("string") + ) as ChunkTimeIndexEntry[]; + + if (!timeIndex[0]) { + throw new Error("Missing time-index fixture entry"); + } + + timeIndex[0].seq = 0; + invalidSequenceZip.file("index/time.json", JSON.stringify(timeIndex)); + await writeIntegrityHashes(invalidSequenceZip); + const invalidSequenceBytes = await invalidSequenceZip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(invalidSequenceBytes)).rejects.toThrow( + /index\/time\.json failed schema validation/i + ); + + const invalidChunkMapZip = await JSZip.loadAsync(await createFixtureArchive()); + const invalidChunkMap = JSON.parse( + await invalidChunkMapZip.file("index/time.json")!.async("string") + ) as ChunkTimeIndexEntry[]; + + if (!invalidChunkMap[0]) { + throw new Error("Missing time-index fixture entry"); + } + + invalidChunkMap[0].chunkId = "chunk-does-not-exist"; + invalidChunkMapZip.file("index/time.json", JSON.stringify(invalidChunkMap)); + await writeIntegrityHashes(invalidChunkMapZip); + const invalidChunkMapBytes = await invalidChunkMapZip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(invalidChunkMapBytes)).rejects.toThrow( + /time index is missing event chunk|does not match archive event chunks/i + ); + + const unknownReferenceZip = await JSZip.loadAsync(await createFixtureArchive()); + unknownReferenceZip.file( + "index/req.json", + JSON.stringify([{ reqId: "R-1", eventIds: ["E-does-not-exist"] }]) + ); + await writeIntegrityHashes(unknownReferenceZip); + const unknownReferenceBytes = await unknownReferenceZip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(unknownReferenceBytes)).rejects.toThrow( + /index references an unknown event id/i + ); + }); + + it("rejects schema-invalid privacy manifests", async () => { + const zip = await JSZip.loadAsync(await createFixtureArchive()); + zip.file("privacy/manifest.json", JSON.stringify({ schemaVersion: 999 })); + await writeIntegrityManifest(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow( + /privacy\/manifest\.json failed schema validation/i + ); + }); + it("fails encrypted archive open when Web Crypto API is unavailable", async () => { const bytes = await createEncryptedArchive(await createFixtureArchive(), "test-passphrase"); const originalCrypto = (globalThis as unknown as { crypto?: Crypto }).crypto; @@ -693,7 +851,10 @@ async function createFixtureArchive(): Promise { mono: 1, type: "meta.session.start", id: "E-1", - data: {} + data: { + url: "https://example.com", + mode: "full" + } }, { v: 1, @@ -720,7 +881,9 @@ async function createFixtureArchive(): Promise { act: "A-1" }, data: { - url: "https://example.com/api" + reqId: "R-1", + url: "https://example.com/api", + method: "GET" } }, { @@ -736,6 +899,7 @@ async function createFixtureArchive(): Promise { act: "A-1" }, data: { + reqId: "R-1", status: 200 } }, @@ -852,6 +1016,8 @@ async function createPrivacyFixtureArchive(): Promise { id: "E-privacy-1", data: { reqId: "R-privacy", + url: "https://privacy.example.test/api", + method: "GET", headers: { authorization: hashed, "x-api-key": "[REDACTED]" @@ -1630,6 +1796,7 @@ async function createRichFixtureArchive(): Promise { data: { requestId: "R-1", response: { + url: "https://example.com/api", status: 200, statusText: "OK", mimeType: "application/json", @@ -1971,6 +2138,11 @@ async function tamperArchiveFile( } async function writeIntegrityManifest(zip: JSZip): Promise { + await synchronizePlainArchiveMetadata(zip); + await writeIntegrityHashes(zip); +} + +async function writeIntegrityHashes(zip: JSZip): Promise { const fileHashes: Record = {}; for (const path of Object.keys(zip.files).sort()) { @@ -2000,6 +2172,108 @@ async function writeIntegrityManifest(zip: JSZip): Promise { ); } +async function synchronizePlainArchiveMetadata(zip: JSZip): Promise { + const manifestFile = zip.file("manifest.json"); + const timeIndexFile = zip.file("index/time.json"); + + if (!manifestFile || !timeIndexFile) { + return; + } + + const manifest = JSON.parse(await manifestFile.async("string")) as ExportManifest; + + if (manifest.encryption) { + return; + } + + const currentTimeIndex = JSON.parse(await timeIndexFile.async("string")) as ChunkTimeIndexEntry[]; + const currentByChunkId = new Map(currentTimeIndex.map((entry) => [entry.chunkId, entry])); + const eventPaths = Object.keys(zip.files) + .filter((path) => /^events\/.+\.ndjson$/.test(path)) + .sort(); + const timeIndex: ChunkTimeIndexEntry[] = []; + + for (const [pathIndex, path] of eventPaths.entries()) { + const chunkId = /^events\/(.+)\.ndjson$/.exec(path)?.[1]; + const file = zip.file(path); + + if (!chunkId || !file) { + continue; + } + + const bytes = await file.async("uint8array"); + const current = currentByChunkId.get(chunkId); + const codec = current?.codec ?? manifest.chunkCodec; + let events: WebBlackboxEvent[] | null = null; + + try { + const decoded = decompressFixtureChunk(bytes, codec); + events = new TextDecoder() + .decode(decoded) + .split(/\r?\n/) + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as WebBlackboxEvent); + } catch { + // Malformed fixture chunks retain their explicit bounds so rejection is tested by the reader. + } + + const first = events?.[0]; + const last = events?.[events.length - 1]; + + timeIndex.push({ + chunkId, + seq: current?.seq ?? pathIndex + 1, + tStart: first?.t ?? current?.tStart ?? 0, + tEnd: last?.t ?? current?.tEnd ?? 0, + monoStart: first?.mono ?? current?.monoStart ?? 0, + monoEnd: last?.mono ?? current?.monoEnd ?? 0, + eventCount: events?.length ?? current?.eventCount ?? 1, + byteLength: bytes.byteLength, + codec, + sha256: await sha256HexForTest(bytes) + }); + } + + manifest.stats = { + ...manifest.stats, + eventCount: timeIndex.reduce((total, entry) => total + entry.eventCount, 0), + chunkCount: timeIndex.length, + blobCount: Object.entries(zip.files).filter( + ([path, file]) => path.startsWith("blobs/") && !file.dir + ).length + }; + + zip.file("manifest.json", JSON.stringify(manifest)); + zip.file("index/time.json", JSON.stringify(timeIndex)); +} + +function decompressFixtureChunk( + bytes: Uint8Array, + codec: ChunkTimeIndexEntry["codec"] +): Uint8Array { + if (codec === "gzip") { + return toUint8Array(zlib.gunzipSync(bytes)); + } + + if (codec === "br") { + return toUint8Array(zlib.brotliDecompressSync(bytes)); + } + + if (codec === "zst") { + const zstdDecompressSync = ( + zlib as unknown as { zstdDecompressSync?: (input: Uint8Array) => Uint8Array } + ).zstdDecompressSync; + + if (typeof zstdDecompressSync !== "function") { + throw new Error("zstd decompression is unavailable in this runtime"); + } + + return toUint8Array(zstdDecompressSync(bytes)); + } + + return bytes; +} + async function sha256HexForTest(bytes: Uint8Array): Promise { const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)); return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); @@ -2028,6 +2302,39 @@ function createDomSnapshotPayload(bodyChildren: string[]): Record +): NonNullable { + return { + algorithm: "AES-GCM", + kdf: { + name: "PBKDF2", + hash: "SHA-256", + iterations: 120_000, + saltBase64: toBase64(randomBytes(16)) + }, + files + }; +} + +async function rewriteArchiveManifest( + source: Uint8Array, + mutate: (manifest: ExportManifest) => void +): Promise { + const zip = await JSZip.loadAsync(source); + const file = zip.file("manifest.json"); + + if (!file) { + throw new Error("Missing fixture archive manifest"); + } + + const manifest = JSON.parse(await file.async("string")) as ExportManifest; + mutate(manifest); + zip.file("manifest.json", JSON.stringify(manifest)); + await writeIntegrityHashes(zip); + return zip.generateAsync({ type: "uint8array" }); +} + async function createEncryptedArchive(source: Uint8Array, passphrase: string): Promise { const zip = await JSZip.loadAsync(source); const manifestFile = zip.file("manifest.json"); diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index 72c032e..5e702d9 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -12,7 +12,20 @@ import type { WebBlackboxEvent, WebBlackboxEventType } from "@webblackbox/protocol"; -import { extractRequestId, inferBlobMime } from "@webblackbox/protocol"; +import { + assertArchiveChunk, + assertArchiveEventIndexes, + assertArchiveLayout, + extractRequestId, + inferBlobMime, + parseArchivedEvent, + parseExportManifest, + parseHashesManifest, + parseInvertedIndex, + parsePrivacyManifest, + parseRequestIndex, + parseTimeIndex +} from "@webblackbox/protocol"; /** Player lifecycle status. */ export type PlayerStatus = "idle" | "loaded"; @@ -463,6 +476,13 @@ type EventChunkDescriptor = { monoStart: number; monoEnd: number; codec: ChunkCodec; + index: ChunkTimeIndexEntry; + selected: boolean; +}; + +type EventChunkReadResult = { + chunks: EventChunkSource[]; + events: WebBlackboxEvent[]; }; /** @@ -555,52 +575,70 @@ export class WebBlackboxPlayer { const bytes = await normalizeOpenInput(input); const zip = await JSZip.loadAsync(bytes); - const integrity = await readJson(zip, "integrity/hashes.json"); + const integrity = parseHashesManifest(await readJsonValue(zip, "integrity/hashes.json")); assertArchiveFileSet(zip, integrity); await assertManifestIntegrity(zip, integrity); - const manifest = await readJson(zip, "manifest.json"); + const manifest = parseExportManifest(await readJsonValue(zip, "manifest.json")); const archiveKey = await resolveArchiveReadKey(manifest, options.passphrase); const encryptedFiles = manifest.encryption?.files ?? {}; - const timeIndex = await readIntegrityArchiveJson( - zip, - integrity, - "index/time.json", - archiveKey, - encryptedFiles + const timeIndex = parseTimeIndex( + await readIntegrityArchiveJsonValue( + zip, + integrity, + "index/time.json", + archiveKey, + encryptedFiles + ) ); - const requestIndex = await readIntegrityArchiveJson( - zip, - integrity, - "index/req.json", - archiveKey, - encryptedFiles + const requestIndex = parseRequestIndex( + await readIntegrityArchiveJsonValue( + zip, + integrity, + "index/req.json", + archiveKey, + encryptedFiles + ) ); - const invertedIndex = await readIntegrityArchiveJson( - zip, - integrity, - "index/inv.json", - archiveKey, - encryptedFiles + const invertedIndex = parseInvertedIndex( + await readIntegrityArchiveJsonValue( + zip, + integrity, + "index/inv.json", + archiveKey, + encryptedFiles + ) ); - const privacyManifest = await readOptionalIntegrityArchiveJson( + const privacyValue = await readOptionalIntegrityArchiveJsonValue( zip, integrity, "privacy/manifest.json", archiveKey, encryptedFiles ); - const eventChunks = await readEventChunkSources( + const privacyManifest = privacyValue === null ? null : parsePrivacyManifest(privacyValue); + + assertArchiveLayout({ + paths: archiveFilePaths(zip), + manifest, + timeIndex, + requestIndex, + invertedIndex, + privacyManifest + }); + + const eventChunkResult = await readEventChunkSources( zip, archiveKey, encryptedFiles, { range: options.range, - timeIndex, - defaultCodec: manifest.chunkCodec + timeIndex }, integrity ); + assertArchiveEventIndexes(manifest, eventChunkResult.events, requestIndex, invertedIndex); + return new WebBlackboxPlayer( zip, { @@ -611,7 +649,7 @@ export class WebBlackboxPlayer { integrity, privacyManifest }, - eventChunks, + eventChunkResult.chunks, archiveKey, encryptedFiles ); @@ -3062,19 +3100,19 @@ async function readEventChunkSources( options: { range?: PlayerRange; timeIndex?: ChunkTimeIndexEntry[]; - defaultCodec?: ChunkCodec; } = {}, integrity?: HashesManifest -): Promise { - const descriptors = buildEventChunkDescriptors(zip, options); +): Promise { + const descriptors = buildEventChunkDescriptors(options); const chunks: EventChunkSource[] = []; + const events: WebBlackboxEvent[] = []; for (const descriptor of descriptors) { const { path } = descriptor; const file = zip.file(path); if (!file) { - continue; + throw new Error(`Invalid WebBlackbox archive: missing indexed event chunk '${path}'.`); } const rawBytes = await file.async("uint8array"); @@ -3085,55 +3123,53 @@ async function readEventChunkSources( const decrypted = await decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); const bytes = await decodeChunkBytes(decrypted, descriptor.codec); - - chunks.push({ + const chunk: EventChunkSource = { chunkId: descriptor.chunkId, path, seq: descriptor.seq, monoStart: descriptor.monoStart, monoEnd: descriptor.monoEnd, bytes + }; + const chunkEvents = parseChunkEvents(chunk); + + assertArchiveChunk({ + path, + index: descriptor.index, + encodedByteLength: decrypted.byteLength, + encodedSha256: await sha256Hex(decrypted), + events: chunkEvents }); + + if (descriptor.selected) { + chunks.push(chunk); + } + + events.push(...chunkEvents); } - return chunks.sort((left, right) => left.seq - right.seq); + return { + chunks: chunks.sort((left, right) => left.seq - right.seq), + events + }; } -function buildEventChunkDescriptors( - zip: JSZip, - options: { - range?: PlayerRange; - timeIndex?: ChunkTimeIndexEntry[]; - defaultCodec?: ChunkCodec; - } -): EventChunkDescriptor[] { +function buildEventChunkDescriptors(options: { + range?: PlayerRange; + timeIndex?: ChunkTimeIndexEntry[]; +}): EventChunkDescriptor[] { const { range, timeIndex } = options; - const defaultCodec = options.defaultCodec ?? "none"; - - if (Array.isArray(timeIndex) && timeIndex.length > 0) { - return timeIndex - .filter((entry) => !range || chunkIntersectsRange(entry, range)) - .sort((left, right) => left.seq - right.seq) - .map((entry) => ({ - chunkId: entry.chunkId, - path: `events/${entry.chunkId}.ndjson`, - seq: entry.seq, - monoStart: entry.monoStart, - monoEnd: entry.monoEnd, - codec: entry.codec - })); - } - - return Object.keys(zip.files) - .filter((path) => path.startsWith("events/") && path.endsWith(".ndjson")) - .sort() - .map((path, index) => ({ - chunkId: parseChunkIdFromPath(path) ?? `chunk-${String(index + 1).padStart(6, "0")}`, - path, - seq: index + 1, - monoStart: Number.NEGATIVE_INFINITY, - monoEnd: Number.POSITIVE_INFINITY, - codec: defaultCodec + return (timeIndex ?? []) + .sort((left, right) => left.seq - right.seq) + .map((entry) => ({ + chunkId: entry.chunkId, + path: `events/${entry.chunkId}.ndjson`, + seq: entry.seq, + monoStart: entry.monoStart, + monoEnd: entry.monoEnd, + codec: entry.codec, + index: entry, + selected: !range || chunkIntersectsRange(entry, range) })); } @@ -3142,15 +3178,17 @@ function parseChunkEvents(chunk: EventChunkSource): WebBlackboxEvent[] { const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); const events: WebBlackboxEvent[] = []; - for (const line of lines) { + for (const [lineIndex, line] of lines.entries()) { try { - events.push(JSON.parse(line) as WebBlackboxEvent); + events.push(parseArchivedEvent(JSON.parse(line) as unknown, chunk.path, lineIndex + 1)); } catch (error) { - throw new Error( - `Failed to parse chunk '${chunk.chunkId}' from '${chunk.path}': ${ - error instanceof Error ? error.message : String(error) - }` - ); + if (error instanceof SyntaxError) { + throw new Error( + `Invalid WebBlackbox archive: '${chunk.path}' contains malformed JSON at event line ${lineIndex + 1}.` + ); + } + + throw error; } } @@ -3198,14 +3236,24 @@ async function decryptArchiveBytes( const encryptedFile = encryptedFiles[path]; if (!encryptedFile) { - return bytes; + if (Object.keys(encryptedFiles).length === 0) { + return bytes; + } + + throw new Error( + `Invalid WebBlackbox archive: encrypted archive is missing file metadata for '${path}'.` + ); } if (!archiveKey) { throw new Error("Archive is encrypted. Missing decryption key."); } - return decryptBytes(bytes, archiveKey, fromBase64(encryptedFile.ivBase64)); + try { + return await decryptBytes(bytes, archiveKey, fromBase64(encryptedFile.ivBase64)); + } catch { + throw new Error("Unable to decrypt archive content. The passphrase may be invalid."); + } } async function decodeChunkBytes(bytes: Uint8Array, codec: ChunkCodec): Promise { @@ -3375,11 +3423,6 @@ function codecFormats(codec: ChunkCodec): string[] { return []; } -function parseChunkIdFromPath(path: string): string | null { - const match = /^events\/(.+)\.ndjson$/.exec(path); - return match?.[1] ?? null; -} - function withinRange(event: WebBlackboxEvent, range?: PlayerRange): boolean { if (!range) { return true; @@ -3510,36 +3553,50 @@ function updateActionStats(span: ActionSpan, event: WebBlackboxEvent): void { } } -async function readJson(zip: JSZip, path: string): Promise { +async function readJsonValue(zip: JSZip, path: string): Promise { const content = await readZipFileText(zip, path); - return JSON.parse(content) as TValue; + return parseArchiveJson(content, path); } -async function readIntegrityArchiveJson( +async function readIntegrityArchiveJsonValue( zip: JSZip, integrity: HashesManifest, path: string, archiveKey: CryptoKey | null, encryptedFiles: Record -): Promise { +): Promise { const rawBytes = await readZipFileBytes(zip, path); await assertArchiveFileIntegrity(zip, integrity, path, rawBytes); const bytes = await decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); - return JSON.parse(new TextDecoder().decode(bytes)) as TValue; + return parseArchiveJson(new TextDecoder().decode(bytes), path); } -async function readOptionalIntegrityArchiveJson( +async function readOptionalIntegrityArchiveJsonValue( zip: JSZip, integrity: HashesManifest, path: string, archiveKey: CryptoKey | null, encryptedFiles: Record -): Promise { +): Promise { if (!zip.file(path)) { return null; } - return readIntegrityArchiveJson(zip, integrity, path, archiveKey, encryptedFiles); + return readIntegrityArchiveJsonValue(zip, integrity, path, archiveKey, encryptedFiles); +} + +function parseArchiveJson(content: string, path: string): unknown { + try { + return JSON.parse(content) as unknown; + } catch { + throw new Error(`Invalid WebBlackbox archive: '${path}' contains malformed JSON.`); + } +} + +function archiveFilePaths(zip: JSZip): string[] { + return Object.entries(zip.files) + .filter(([, file]) => !file.dir) + .map(([path]) => path); } async function readZipFileBytes(zip: JSZip, path: string): Promise { diff --git a/packages/protocol/src/archive-validation.ts b/packages/protocol/src/archive-validation.ts new file mode 100644 index 0000000..daa2715 --- /dev/null +++ b/packages/protocol/src/archive-validation.ts @@ -0,0 +1,407 @@ +import type { + ChunkTimeIndexEntry, + ExportManifest, + HashesManifest, + InvertedIndexEntry, + PrivacyManifest, + RequestIndexEntry, + WebBlackboxEvent +} from "./types.js"; + +import { + exportManifestSchema, + hashesManifestSchema, + invertedIndexSchema, + privacyManifestSchema, + requestIndexSchema, + timeIndexSchema, + webBlackboxEventSchema +} from "./schemas.js"; + +const REQUIRED_ARCHIVE_PATHS = [ + "manifest.json", + "index/time.json", + "index/req.json", + "index/inv.json" +] as const; + +const PRIVATE_INDEX_PATHS = new Set([ + "index/time.json", + "index/req.json", + "index/inv.json", + "privacy/manifest.json" +]); + +const EVENT_PATH_PATTERN = /^events\/([A-Za-z0-9][A-Za-z0-9._-]{0,255})\.ndjson$/; + +type ParseResult = + | { success: true; data: TValue } + | { + success: false; + error: { + issues: Array<{ + path: PropertyKey[]; + message: string; + }>; + }; + }; + +type RuntimeSchema = { + safeParse(value: unknown): ParseResult; +}; + +export type ArchiveLayoutInput = { + paths: string[]; + manifest: ExportManifest; + timeIndex: ChunkTimeIndexEntry[]; + requestIndex: RequestIndexEntry[]; + invertedIndex: InvertedIndexEntry[]; + privacyManifest: PrivacyManifest | null; +}; + +export type ArchiveChunkValidationInput = { + path: string; + index: ChunkTimeIndexEntry; + encodedByteLength: number; + encodedSha256: string; + events: WebBlackboxEvent[]; +}; + +/** Parses a runtime value with the protocol archive manifest schema. */ +export function parseExportManifest(value: unknown): ExportManifest { + return parseWithSchema(exportManifestSchema, value, "manifest.json"); +} + +/** Parses a runtime value with the protocol integrity manifest schema. */ +export function parseHashesManifest(value: unknown): HashesManifest { + return parseWithSchema(hashesManifestSchema, value, "integrity/hashes.json"); +} + +/** Parses a runtime value with the protocol time-index schema. */ +export function parseTimeIndex(value: unknown): ChunkTimeIndexEntry[] { + return parseWithSchema(timeIndexSchema, value, "index/time.json"); +} + +/** Parses a runtime value with the protocol request-index schema. */ +export function parseRequestIndex(value: unknown): RequestIndexEntry[] { + return parseWithSchema(requestIndexSchema, value, "index/req.json"); +} + +/** Parses a runtime value with the protocol inverted-index schema. */ +export function parseInvertedIndex(value: unknown): InvertedIndexEntry[] { + return parseWithSchema(invertedIndexSchema, value, "index/inv.json"); +} + +/** Parses a runtime value with the protocol privacy-manifest schema. */ +export function parsePrivacyManifest(value: unknown): PrivacyManifest { + return parseWithSchema(privacyManifestSchema, value, "privacy/manifest.json"); +} + +/** Parses and validates a single archived protocol event. */ +export function parseArchivedEvent(value: unknown, path: string, line: number): WebBlackboxEvent { + return parseWithSchema(webBlackboxEventSchema, value, `${path} event line ${line}`); +} + +/** Returns whether an archive path contains private session material. */ +export function isPrivateArchivePath(path: string): boolean { + return path.startsWith("events/") || path.startsWith("blobs/") || PRIVATE_INDEX_PATHS.has(path); +} + +/** + * Validates file/index/encryption invariants shared by archive readers. + * This intentionally validates relationships that independent Zod schemas cannot express. + */ +export function assertArchiveLayout(input: ArchiveLayoutInput): void { + const uniquePaths = new Set(input.paths); + + if (uniquePaths.size !== input.paths.length) { + throw archiveError("archive contains duplicate file paths"); + } + + for (const requiredPath of REQUIRED_ARCHIVE_PATHS) { + if (!uniquePaths.has(requiredPath)) { + throw archiveError(`archive is missing required file '${requiredPath}'`); + } + } + + assertEncryptionCoverage(input.manifest, input.paths); + assertTimeIndexLayout(input.manifest, input.timeIndex, input.paths); + assertSecondaryIndexes(input.requestIndex, input.invertedIndex); + assertPrivacyConsistency(input.manifest, input.privacyManifest); + + const blobCount = input.paths.filter((path) => path.startsWith("blobs/")).length; + + if (input.manifest.stats.blobCount !== blobCount) { + throw archiveError("manifest blob count does not match archive contents"); + } +} + +/** Validates an already decoded event chunk against its signed time-index entry. */ +export function assertArchiveChunk(input: ArchiveChunkValidationInput): void { + const { index, events } = input; + + if (input.encodedByteLength !== index.byteLength) { + throw archiveError(`chunk metadata byte length mismatch for '${input.path}'`); + } + + if (input.encodedSha256 !== index.sha256) { + throw archiveError(`chunk metadata digest mismatch for '${input.path}'`); + } + + if (events.length !== index.eventCount) { + throw archiveError(`chunk metadata event count mismatch for '${input.path}'`); + } + + if (events.length === 0) { + throw archiveError(`indexed event chunk '${input.path}' is empty`); + } + + const first = events[0]; + const last = events[events.length - 1]; + + if ( + !first || + !last || + first.t !== index.tStart || + last.t !== index.tEnd || + first.mono !== index.monoStart || + last.mono !== index.monoEnd + ) { + throw archiveError(`chunk time bounds do not match events for '${input.path}'`); + } + + const eventIds = new Set(); + let previousMono = Number.NEGATIVE_INFINITY; + + for (const event of events) { + if (eventIds.has(event.id)) { + throw archiveError(`chunk '${input.path}' contains duplicate event ids`); + } + + if (event.mono < previousMono) { + throw archiveError(`chunk '${input.path}' events are not in monotonic order`); + } + + if ( + event.t < index.tStart || + event.t > index.tEnd || + event.mono < index.monoStart || + event.mono > index.monoEnd + ) { + throw archiveError(`chunk '${input.path}' contains an event outside its index bounds`); + } + + eventIds.add(event.id); + previousMono = event.mono; + } +} + +/** Validates global event and secondary-index references after all chunks are decoded. */ +export function assertArchiveEventIndexes( + manifest: ExportManifest, + events: WebBlackboxEvent[], + requestIndex: RequestIndexEntry[], + invertedIndex: InvertedIndexEntry[] +): void { + if (events.length !== manifest.stats.eventCount) { + throw archiveError("manifest event count does not match decoded events"); + } + + const eventIds = new Set(); + let sessionId: string | null = null; + + for (const event of events) { + if (eventIds.has(event.id)) { + throw archiveError("archive contains duplicate event ids"); + } + + if (sessionId !== null && event.sid !== sessionId) { + throw archiveError("archive event chunks contain multiple session ids"); + } + + sessionId ??= event.sid; + eventIds.add(event.id); + } + + for (const entry of [...requestIndex, ...invertedIndex]) { + for (const eventId of entry.eventIds) { + if (!eventIds.has(eventId)) { + throw archiveError("archive index references an unknown event id"); + } + } + } +} + +function assertEncryptionCoverage(manifest: ExportManifest, paths: string[]): void { + const encryption = manifest.encryption; + + if (!encryption) { + return; + } + + const privatePaths = paths.filter(isPrivateArchivePath).sort(); + const privatePathSet = new Set(privatePaths); + const mappedPaths = Object.keys(encryption.files).sort(); + + for (const path of privatePaths) { + if (!encryption.files[path]) { + throw archiveError(`encrypted archive is missing file metadata for '${path}'`); + } + } + + for (const path of mappedPaths) { + if (!privatePathSet.has(path)) { + throw archiveError(`encrypted archive contains invalid file metadata for '${path}'`); + } + } + + const initializationVectors = new Set(); + + for (const path of mappedPaths) { + const iv = encryption.files[path]?.ivBase64; + + if (!iv) { + throw archiveError(`encrypted archive is missing an initialization vector for '${path}'`); + } + + if (initializationVectors.has(iv)) { + throw archiveError("encrypted archive reuses an initialization vector"); + } + + initializationVectors.add(iv); + } +} + +function assertTimeIndexLayout( + manifest: ExportManifest, + timeIndex: ChunkTimeIndexEntry[], + paths: string[] +): void { + const eventPaths = paths.filter((path) => path.startsWith("events/")).sort(); + const indexedPaths = new Set(); + const chunkIds = new Set(); + const sequences = new Set(); + let previousSequence = 0; + let indexedEventCount = 0; + + for (const entry of timeIndex) { + const path = `events/${entry.chunkId}.ndjson`; + + if (!EVENT_PATH_PATTERN.test(path)) { + throw archiveError("time index contains an invalid chunk id"); + } + + if (chunkIds.has(entry.chunkId) || sequences.has(entry.seq) || indexedPaths.has(path)) { + throw archiveError("time index contains duplicate chunk ids or sequences"); + } + + if (entry.seq <= previousSequence) { + throw archiveError("time index sequences are not strictly increasing"); + } + + chunkIds.add(entry.chunkId); + sequences.add(entry.seq); + indexedPaths.add(path); + previousSequence = entry.seq; + indexedEventCount += entry.eventCount; + } + + if (eventPaths.length !== indexedPaths.size) { + throw archiveError("time index does not match archive event chunks"); + } + + for (const path of eventPaths) { + if (!indexedPaths.has(path)) { + throw archiveError(`time index is missing event chunk '${path}'`); + } + } + + if (manifest.stats.chunkCount !== timeIndex.length) { + throw archiveError("manifest chunk count does not match time index"); + } + + if (manifest.stats.eventCount !== indexedEventCount) { + throw archiveError("manifest event count does not match time index"); + } +} + +function assertSecondaryIndexes( + requestIndex: RequestIndexEntry[], + invertedIndex: InvertedIndexEntry[] +): void { + const requestIds = new Set(); + const terms = new Set(); + + for (const entry of requestIndex) { + if (requestIds.has(entry.reqId) || new Set(entry.eventIds).size !== entry.eventIds.length) { + throw archiveError("request index contains duplicate ids or event references"); + } + + requestIds.add(entry.reqId); + } + + for (const entry of invertedIndex) { + const normalizedTerm = entry.term.toLowerCase(); + + if (terms.has(normalizedTerm) || new Set(entry.eventIds).size !== entry.eventIds.length) { + throw archiveError("inverted index contains duplicate terms or event references"); + } + + terms.add(normalizedTerm); + } +} + +function assertPrivacyConsistency( + manifest: ExportManifest, + privacyManifest: PrivacyManifest | null +): void { + if (!privacyManifest) { + return; + } + + const encrypted = Boolean(manifest.encryption); + + if ( + privacyManifest.encryption.archive !== (encrypted ? "encrypted" : "plaintext") || + (privacyManifest.encryption.algorithm !== undefined) !== encrypted + ) { + throw archiveError("privacy manifest encryption state does not match archive manifest"); + } + + if (privacyManifest.transfer && privacyManifest.transfer.encrypted !== encrypted) { + throw archiveError("privacy transfer encryption state does not match archive manifest"); + } + + if ( + privacyManifest.totals.events !== manifest.stats.eventCount || + privacyManifest.totals.blobs !== manifest.stats.blobCount + ) { + throw archiveError("privacy manifest totals do not match archive manifest"); + } +} + +function parseWithSchema( + schema: RuntimeSchema, + value: unknown, + label: string +): TValue { + const result = schema.safeParse(value); + + if (result.success) { + return result.data; + } + + const issues = result.error.issues + .slice(0, 4) + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.map(String).join(".") : "value"; + return `${path}: ${issue.message}`; + }) + .join("; "); + + throw archiveError(`${label} failed schema validation${issues ? ` (${issues})` : ""}`); +} + +function archiveError(message: string): Error { + return new Error(`Invalid WebBlackbox archive: ${message}.`); +} diff --git a/packages/protocol/src/constants.ts b/packages/protocol/src/constants.ts index 0434dc9..e747d23 100644 --- a/packages/protocol/src/constants.ts +++ b/packages/protocol/src/constants.ts @@ -1,5 +1,9 @@ export const WEBBLACKBOX_PROTOCOL_VERSION = 1; +export const ARCHIVE_PBKDF2_MIN_ITERATIONS = 100_000; + +export const ARCHIVE_PBKDF2_MAX_ITERATIONS = 1_000_000; + export const EVENT_LEVELS = ["debug", "info", "warn", "error"] as const; export const CAPTURE_MODES = ["lite", "full"] as const; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 9bd300b..8706657 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,4 +1,5 @@ export * from "./blob.js"; +export * from "./archive-validation.js"; export * from "./constants.js"; export * from "./defaults.js"; export * from "./ids.js"; diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 9da263d..27be56b 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import { + ARCHIVE_PBKDF2_MAX_ITERATIONS, + ARCHIVE_PBKDF2_MIN_ITERATIONS, CAPTURE_MODES, CHUNK_CODECS, EVENT_LEVELS, @@ -14,6 +16,14 @@ const recordStringUnknown = z.record(z.string(), z.unknown()); const stringArray = z.array(z.string()); +const sha256HexSchema = z.string().regex(/^[a-f0-9]{64}$/); + +// 16 bytes encoded as canonical padded base64 (22 data characters plus "=="). +const archiveSaltBase64Schema = z.string().regex(/^[A-Za-z0-9+/]{21}[AQgw]==$/); + +// 12 bytes encoded as canonical unpadded base64. +const archiveIvBase64Schema = z.string().regex(/^[A-Za-z0-9+/]{16}$/); + export const eventLevelSchema = z.enum(EVENT_LEVELS); export const captureModeSchema = z.enum(CAPTURE_MODES); @@ -214,37 +224,58 @@ export const sessionMetadataSchema = z export const chunkTimeIndexEntrySchema = z .object({ - chunkId: z.string().min(1), - seq: z.number().int().nonnegative(), + chunkId: z + .string() + .min(1) + .max(256) + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/), + seq: z.number().int().positive(), tStart: z.number().finite(), tEnd: z.number().finite(), monoStart: z.number().finite(), monoEnd: z.number().finite(), - eventCount: z.number().int().nonnegative(), + eventCount: z.number().int().positive(), byteLength: z.number().int().nonnegative(), codec: chunkCodecSchema, - sha256: z.string().min(1) - }) - .strict(); + sha256: sha256HexSchema + }) + .strict() + .superRefine((entry, context) => { + if (entry.tStart > entry.tEnd) { + context.addIssue({ + code: "custom", + path: ["tEnd"], + message: "must be greater than or equal to tStart" + }); + } + + if (entry.monoStart > entry.monoEnd) { + context.addIssue({ + code: "custom", + path: ["monoEnd"], + message: "must be greater than or equal to monoStart" + }); + } + }); export const requestIndexEntrySchema = z .object({ reqId: z.string().min(1), - eventIds: z.array(z.string().min(1)) + eventIds: z.array(z.string().min(1)).min(1) }) .strict(); export const invertedIndexEntrySchema = z .object({ term: z.string().min(1), - eventIds: z.array(z.string().min(1)) + eventIds: z.array(z.string().min(1)).min(1) }) .strict(); export const hashesManifestSchema = z .object({ - manifestSha256: z.string().min(1), - files: z.record(z.string(), z.string()) + manifestSha256: sha256HexSchema, + files: z.record(z.string().min(1), sha256HexSchema) }) .strict(); @@ -253,7 +284,7 @@ export const exportStatsSchema = z eventCount: z.number().int().nonnegative(), chunkCount: z.number().int().nonnegative(), blobCount: z.number().int().nonnegative(), - durationMs: z.number().int().nonnegative() + durationMs: z.number().finite().nonnegative() }) .strict(); @@ -264,18 +295,26 @@ export const exportEncryptionSchema = z .object({ name: z.literal("PBKDF2"), hash: z.literal("SHA-256"), - iterations: z.number().int().positive(), - saltBase64: z.string().min(1) + iterations: z + .number() + .int() + .min(ARCHIVE_PBKDF2_MIN_ITERATIONS) + .max(ARCHIVE_PBKDF2_MAX_ITERATIONS), + saltBase64: archiveSaltBase64Schema }) .strict(), - files: z.record( - z.string(), - z - .object({ - ivBase64: z.string().min(1) - }) - .strict() - ) + files: z + .record( + z.string().min(1), + z + .object({ + ivBase64: archiveIvBase64Schema + }) + .strict() + ) + .refine((files) => Object.keys(files).length > 0, { + message: "encrypted archives must declare at least one private file" + }) }) .strict(); @@ -404,9 +443,9 @@ export const networkBodyCaptureRuleSchema = z const metaSessionStartSchema = z .object({ - url: z.string().min(1), + url: z.string().min(1).optional(), title: z.string().optional(), - mode: captureModeSchema, + mode: captureModeSchema.optional(), permissions: recordStringUnknown.optional(), viewport: z .object({ @@ -419,9 +458,10 @@ const metaSessionStartSchema = z }) .strict(); -const networkRequestDataSchema = z +const normalizedNetworkRequestDataSchema = z .object({ reqId: z.string().min(1), + requestId: z.string().min(1).optional(), url: z.string().min(1), method: z.string().min(1), resourceType: z.string().optional(), @@ -429,11 +469,33 @@ const networkRequestDataSchema = z headers: z.record(z.string(), z.string()).optional(), postDataSize: z.number().int().nonnegative().optional() }) - .strict(); + .passthrough(); -const networkResponseDataSchema = z +const cdpNetworkRequestDataSchema = z + .object({ + requestId: z.string().min(1).optional(), + request: z + .object({ + requestId: z.string().min(1).optional(), + url: z.string().min(1), + method: z.string().min(1) + }) + .passthrough() + }) + .passthrough() + .refine((value) => Boolean(value.requestId || value.request.requestId), { + message: "requestId is required" + }); + +const networkRequestDataSchema = z.union([ + normalizedNetworkRequestDataSchema, + cdpNetworkRequestDataSchema +]); + +const normalizedNetworkResponseDataSchema = z .object({ reqId: z.string().min(1), + requestId: z.string().min(1).optional(), status: z.number().int(), statusText: z.string().optional(), mimeType: z.string().optional(), @@ -443,7 +505,24 @@ const networkResponseDataSchema = z timing: recordStringUnknown.optional(), headers: z.record(z.string(), z.string()).optional() }) - .strict(); + .passthrough(); + +const cdpNetworkResponseDataSchema = z + .object({ + requestId: z.string().min(1), + response: z + .object({ + url: z.string().min(1), + status: z.number().finite() + }) + .passthrough() + }) + .passthrough(); + +const networkResponseDataSchema = z.union([ + normalizedNetworkResponseDataSchema, + cdpNetworkResponseDataSchema +]); const consoleEntryDataSchema = z .object({ @@ -463,16 +542,36 @@ const consoleEntryDataSchema = z }) .strict(); -const errorExceptionDataSchema = z +const normalizedErrorExceptionDataSchema = z .object({ - message: z.string().min(1), + message: z.string().min(1).optional(), + text: z.string().min(1).optional(), name: z.string().optional(), stack: z.string().optional(), url: z.string().optional(), line: z.number().int().optional(), col: z.number().int().optional() }) - .strict(); + .passthrough() + .refine((value) => Boolean(value.message || value.text), { + message: "message or text is required" + }); + +const cdpErrorExceptionDataSchema = z + .object({ + exceptionDetails: z + .object({ + text: z.string(), + exceptionId: z.number().int().optional() + }) + .passthrough() + }) + .passthrough(); + +const errorExceptionDataSchema = z.union([ + normalizedErrorExceptionDataSchema, + cdpErrorExceptionDataSchema +]); const screenshotDataSchema = z .object({ @@ -558,30 +657,37 @@ const domSnapshotDataSchema = z .object({ snapshotId: z.string().min(1), contentHash: z.string().min(1), - source: z.enum(["cdp", "rrweb", "html"]), + source: z.enum(["cdp", "rrweb", "html"]).optional(), nodeCount: z.number().int().nonnegative().optional(), - computedStyles: z.array(z.string()).optional() + computedStyles: z.array(z.string()).optional(), + reason: z.string().min(1).optional() }) - .strict(); + .passthrough(); const storageSnapshotDataSchema = z .object({ mode: storageSnapshotModeSchema.optional(), hash: z.string().min(1).optional(), count: z.number().int().nonnegative().optional(), - redacted: z.boolean().optional() + redacted: z.boolean().optional(), + reason: z.string().min(1).optional() }) - .strict(); + .passthrough(); const perfVitalsDataSchema = z .object({ + metric: z.string().min(1).optional(), + name: z.string().optional(), + startTime: z.number().finite().optional(), + duration: z.number().finite().optional(), + value: z.number().finite().optional(), lcp: z.number().finite().optional(), cls: z.number().finite().optional(), inp: z.number().finite().optional(), fid: z.number().finite().optional(), ttfb: z.number().finite().optional() }) - .strict(); + .passthrough(); const genericStrictDataSchema = z.union([ recordStringUnknown, diff --git a/packages/webblackbox/src/lite-materializer.test.ts b/packages/webblackbox/src/lite-materializer.test.ts index 83f8e01..f8485ba 100644 --- a/packages/webblackbox/src/lite-materializer.test.ts +++ b/packages/webblackbox/src/lite-materializer.test.ts @@ -341,7 +341,7 @@ describe("lite-materializer", () => { expect(putBlob).not.toHaveBeenCalled(); expect(result?.payload).toMatchObject({ count: 1, - mode: "counts-only", + mode: "schema-only", redacted: true }); expect(result?.payload).not.toHaveProperty("hash"); @@ -373,12 +373,12 @@ describe("lite-materializer", () => { expect(putBlob).not.toHaveBeenCalled(); expect(cookieResult?.payload).toMatchObject({ count: 2, - mode: "counts-only", + mode: "schema-only", redacted: true }); expect(idbResult?.payload).toMatchObject({ count: 1, - mode: "counts-only", + mode: "schema-only", redacted: true }); expect(JSON.stringify(cookieResult)).not.toContain("sessionSecret"); diff --git a/packages/webblackbox/src/lite-materializer.ts b/packages/webblackbox/src/lite-materializer.ts index 4eaee92..0f280c3 100644 --- a/packages/webblackbox/src/lite-materializer.ts +++ b/packages/webblackbox/src/lite-materializer.ts @@ -201,7 +201,7 @@ async function materializeLiteStorageSnapshot( ...rawEvent, payload: { count, - mode: "counts-only", + mode: "schema-only", redacted: true, reason, truncated: payload.truncated === true @@ -217,7 +217,7 @@ async function materializeLiteStorageSnapshot( ...rawEvent, payload: { count, - mode: "counts-only", + mode: "schema-only", redacted: true, reason, truncated: payload.truncated === true @@ -233,7 +233,7 @@ async function materializeLiteStorageSnapshot( ...rawEvent, payload: { count, - mode: "counts-only", + mode: "schema-only", redacted: true, reason, truncated: payload.truncated === true From a932b9774ad8040e06ad62ee46e3979acafdc7f7 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:06:27 +0800 Subject: [PATCH 025/181] ci(security): pin third-party actions by commit --- .github/workflows/changesets.yml | 8 ++++---- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/release-assets.yml | 12 ++++++------ .github/workflows/release.yml | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index 4d67bd7..d8c3642 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -19,15 +19,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm @@ -36,7 +36,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Create or update version PR - uses: changesets/action@v1 + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: version: pnpm version-packages title: Version Packages diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbf9297..0843d39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,13 +16,13 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm @@ -49,7 +49,7 @@ jobs: run: pnpm bench:ci - name: Upload Benchmark Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 if: always() with: name: benchmark-report @@ -97,13 +97,13 @@ jobs: needsChrome: true steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm @@ -112,7 +112,7 @@ jobs: if: ${{ matrix.needsChrome }} timeout-minutes: 5 id: setup-chrome - uses: browser-actions/setup-chrome@v2 + uses: browser-actions/setup-chrome@2e1d749697dd1612b833dba4a722266286fbefcd # v2.1.2 with: chrome-version: stable install-dependencies: true diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 1cae2db..107c976 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -21,16 +21,16 @@ jobs: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} steps: - name: Checkout released ref - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm @@ -84,16 +84,16 @@ jobs: PLAYER_SITE_URL: https://webllm.github.io/webblackbox/ steps: - name: Checkout released ref - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 cache: pnpm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 220de57..6d998cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,16 +25,16 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.ref || github.ref) || github.event.release.tag_name }} - name: Setup pnpm - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24 registry-url: https://registry.npmjs.org From 4c84c74d88a932850fffa5774235cb90769b4574 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:06:56 +0800 Subject: [PATCH 026/181] ci(release): pin trusted publishing npm version --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d998cb..f96f131 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,7 +40,7 @@ jobs: registry-url: https://registry.npmjs.org - name: Ensure npm supports trusted publishing - run: npm install -g npm@latest + run: npm install -g npm@12.0.0 - name: Install dependencies run: pnpm install --frozen-lockfile From 42c8769adf69729d5ed52ce6cc68e734b836270d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:07:28 +0800 Subject: [PATCH 027/181] fix(extension): enforce capture scope boundaries --- .../extension/scripts/lib/extension-build.mjs | 33 +- apps/extension/src/content/index.ts | 17 +- apps/extension/src/shared/chrome-api.ts | 23 +- .../src/shared/extension-build.test.mjs | 16 + apps/extension/src/shared/messages.ts | 3 + apps/extension/src/sw/capture-scope.test.ts | 196 +++-- apps/extension/src/sw/capture-scope.ts | 92 ++- .../src/sw/cdp-capture-scope.test.ts | 214 +++++ apps/extension/src/sw/cdp-capture-scope.ts | 419 ++++++++++ apps/extension/src/sw/index.ts | 757 ++++++++++++++---- docs/CHROME_WEB_STORE_DISCLOSURE.md | 2 +- docs/ENTERPRISE_ADMIN.md | 2 + docs/PRIVACY.md | 6 + docs/SECURITY.md | 14 +- packages/protocol/src/capture-scope.test.ts | 199 +++++ packages/protocol/src/capture-scope.ts | 296 +++++++ packages/protocol/src/index.ts | 1 + .../src/lite-capture-agent.test.ts | 84 +- .../webblackbox/src/lite-capture-agent.ts | 137 +++- 19 files changed, 2234 insertions(+), 277 deletions(-) create mode 100644 apps/extension/src/sw/cdp-capture-scope.test.ts create mode 100644 apps/extension/src/sw/cdp-capture-scope.ts create mode 100644 packages/protocol/src/capture-scope.test.ts create mode 100644 packages/protocol/src/capture-scope.ts diff --git a/apps/extension/scripts/lib/extension-build.mjs b/apps/extension/scripts/lib/extension-build.mjs index 060fcc7..c38ee2c 100644 --- a/apps/extension/scripts/lib/extension-build.mjs +++ b/apps/extension/scripts/lib/extension-build.mjs @@ -149,17 +149,6 @@ export function createExtensionManifest({ version, release = false, profile = "d } }; - if (!storeSafe) { - manifest.content_scripts = [ - { - matches: [...URL_MATCHES], - js: ["content.js"], - all_frames: true, - run_at: "document_start" - } - ]; - } - return manifest; } @@ -464,25 +453,15 @@ function validateDevManifest(manifest, issues) { } } - if (!Array.isArray(manifest?.host_permissions)) { - return; - } - - if (!manifest.host_permissions.includes("")) { + if ( + !Array.isArray(manifest?.host_permissions) || + !manifest.host_permissions.includes("") + ) { issues.push("Dev manifest must include host permission."); } - const contentScripts = Array.isArray(manifest?.content_scripts) ? manifest.content_scripts : []; - const hasAllSitesContentScript = contentScripts.some( - (entry) => - Array.isArray(entry?.matches) && - entry.matches.includes("") && - Array.isArray(entry?.js) && - entry.js.includes("content.js") - ); - - if (!hasAllSitesContentScript) { - issues.push("Dev manifest must include the all-sites content script."); + if (Array.isArray(manifest?.content_scripts) && manifest.content_scripts.length > 0) { + issues.push("Dev manifest must use policy-scoped dynamic content-script injection."); } } diff --git a/apps/extension/src/content/index.ts b/apps/extension/src/content/index.ts index 5fa63b8..9bb21c8 100644 --- a/apps/extension/src/content/index.ts +++ b/apps/extension/src/content/index.ts @@ -168,7 +168,8 @@ function flushPendingEventsChunk(): boolean { try { contentPort.postMessage({ kind: "content.events", - events: batch + events: batch, + documentUrl: readDocumentUrl() }); } catch (error) { debugPortSendFailure("content.events.replay", error); @@ -226,7 +227,8 @@ function emitMarker(message: string): void { try { contentPort.postMessage({ kind: "content.marker", - message + message, + documentUrl: readDocumentUrl() }); } catch (error) { debugPortSendFailure("content.marker", error); @@ -367,7 +369,8 @@ async function requestRecordingStatusOnce(): Promise { try { const response = await chromeApi.runtime.sendMessage({ - kind: "content.ready" + kind: "content.ready", + documentUrl: readDocumentUrl() }); if (isRecordingStatusMessage(response)) { @@ -470,3 +473,11 @@ function normalizeBodyCaptureBudget(value: unknown): number { return Math.max(4 * 1024, Math.min(8 * 1024 * 1024, Math.round(value))); } + +function readDocumentUrl(): string | undefined { + try { + return window.location.href; + } catch { + return undefined; + } +} diff --git a/apps/extension/src/shared/chrome-api.ts b/apps/extension/src/shared/chrome-api.ts index d3d00ab..78e8e12 100644 --- a/apps/extension/src/shared/chrome-api.ts +++ b/apps/extension/src/shared/chrome-api.ts @@ -2,14 +2,19 @@ export type PortMessageHandler = (message: unknown) => void; export type PortDisconnectHandler = () => void; +export type MessageSenderLike = { + frameId?: number; + url?: string; + origin?: string; + tab?: { + id?: number; + url?: string; + }; +}; + export type PortLike = { name: string; - sender?: { - frameId?: number; - tab?: { - id?: number; - }; - }; + sender?: MessageSenderLike; onMessage: { addListener(handler: PortMessageHandler): void; removeListener(handler: PortMessageHandler): void; @@ -101,7 +106,7 @@ export type ChromeApi = { addListener( callback: ( message: unknown, - sender: { tab?: { id?: number }; frameId?: number }, + sender: MessageSenderLike, sendResponse: (response: unknown) => void ) => boolean | void ): void; @@ -187,7 +192,7 @@ export type ChromeApi = { }; scripting?: { executeScript(options: { - target: { tabId: number; allFrames?: boolean }; + target: { tabId: number; allFrames?: boolean; frameIds?: number[] }; world?: "MAIN" | "ISOLATED"; files?: string[]; }): Promise; @@ -248,7 +253,7 @@ export type ChromeApi = { addListener(callback: (tabId: number) => void): void; }; reload?(tabId: number, reloadProperties?: { bypassCache?: boolean }): Promise; - sendMessage(tabId: number, message: unknown): Promise; + sendMessage(tabId: number, message: unknown, options?: { frameId?: number }): Promise; }; }; diff --git a/apps/extension/src/shared/extension-build.test.mjs b/apps/extension/src/shared/extension-build.test.mjs index 66ff5a2..9b2fbe0 100644 --- a/apps/extension/src/shared/extension-build.test.mjs +++ b/apps/extension/src/shared/extension-build.test.mjs @@ -50,6 +50,7 @@ describe("extension build manifest", () => { expect(manifest.permissions).not.toContain("activeTab"); expect(manifest.permissions).not.toContain("cookies"); expect(manifest.permissions).toContain("tabCapture"); + expect(manifest).not.toHaveProperty("content_scripts"); expect(manifest.content_security_policy?.extension_pages).toContain("script-src 'self'"); expect(manifest.content_security_policy?.extension_pages).not.toContain("'unsafe-inline'"); expect(validateExtensionManifest(manifest, { version: "1.2.3" })).toEqual([]); @@ -86,6 +87,21 @@ describe("extension build manifest", () => { expect(validateExtensionManifest(manifest, { version: "1.2.3", release: true })).toEqual([]); }); + it("rejects always-on content scripts in the development profile", () => { + const manifest = createExtensionManifest({ version: "1.2.3", profile: "dev" }); + manifest.content_scripts = [ + { + matches: [""], + js: ["content.js"], + all_frames: true + } + ]; + + expect(validateExtensionManifest(manifest, { version: "1.2.3", profile: "dev" })).toContain( + "Dev manifest must use policy-scoped dynamic content-script injection." + ); + }); + it("fails validation when explicit CSP is removed", () => { const manifest = createExtensionManifest({ version: "1.2.3", release: true }); diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index eee1117..743129f 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -65,15 +65,18 @@ export type UiRequestSessionListMessage = { export type ContentEventBatchMessage = { kind: "content.events"; events: RawRecorderEvent[]; + documentUrl?: string; }; export type ContentMarkerMessage = { kind: "content.marker"; message: string; + documentUrl?: string; }; export type ContentReadyMessage = { kind: "content.ready"; + documentUrl?: string; }; export type ContentStopDrainedMessage = { diff --git a/apps/extension/src/sw/capture-scope.test.ts b/apps/extension/src/sw/capture-scope.test.ts index 8953ed6..5c8ab9c 100644 --- a/apps/extension/src/sw/capture-scope.test.ts +++ b/apps/extension/src/sw/capture-scope.test.ts @@ -1,79 +1,183 @@ +import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; import { describe, expect, it } from "vitest"; import { - shouldStopForCaptureScopeOriginChange, + bindCapturePolicyToSession, + evaluateCaptureDocumentScope, + isCaptureEventFrameAllowed, + resolveContentScriptInjectionTarget, shouldStopForEnterpriseOriginPolicy } from "./capture-scope.js"; +const NOW = Date.parse("2026-07-11T00:00:00.000Z"); + +function policy(overrides: Partial = {}): CapturePolicy { + return { + ...DEFAULT_CAPTURE_POLICY, + consent: { + ...DEFAULT_CAPTURE_POLICY.consent, + grantedAt: "2026-07-10T00:00:00.000Z" + }, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + tabId: 7, + origin: "https://app.example", + ...overrides + } + }; +} + describe("capture-scope", () => { - it("honors capture policy origin-change stops outside activeTab builds", () => { - expect( - shouldStopForCaptureScopeOriginChange({ - scopeOrigin: "https://app.example", - nextOrigin: "https://admin.example", - stopOnOriginChange: true, - activeTabScopedBuild: false - }) - ).toBe(true); + it("binds tab and origin without weakening origin-change or frame policy", () => { + const base = policy({ + tabId: 0, + origin: "", + stopOnOriginChange: true, + includeSubframes: false, + allowedOrigins: ["https://app.example"], + deniedOrigins: ["https://blocked.example"], + excludedUrlPatterns: ["*/private/*"] + }); + const bound = bindCapturePolicyToSession(base, { + tabId: 19, + origin: "https://app.example" + }); + + expect(bound.scope).toEqual({ + ...base.scope, + tabId: 19, + origin: "https://app.example" + }); + expect(bound.scope.allowedOrigins).not.toBe(base.scope.allowedOrigins); + expect(bound.scope.deniedOrigins).not.toBe(base.scope.deniedOrigins); + expect(bound.scope.excludedUrlPatterns).not.toBe(base.scope.excludedUrlPatterns); }); - it("continues same-origin navigations", () => { + it("allows top-frame capture only in the bound tab and origin", () => { expect( - shouldStopForCaptureScopeOriginChange({ - scopeOrigin: "https://app.example", - nextOrigin: "https://app.example", - stopOnOriginChange: true, - activeTabScopedBuild: true - }) - ).toBe(false); + evaluateCaptureDocumentScope(policy(), { + url: "https://app.example/dashboard", + tabId: 7, + frameId: 0, + now: NOW + }).allowed + ).toBe(true); + expect( + evaluateCaptureDocumentScope(policy(), { + url: "https://app.example/dashboard", + tabId: 8, + frameId: 0, + now: NOW + }).reason + ).toBe("tab-mismatch"); }); - it("keeps tab-scoped captures active when origin-change stops are disabled", () => { + it("honors allow, deny, and URL exclusion on navigation", () => { + const capturePolicy = policy({ + stopOnOriginChange: false, + allowedOrigins: ["*.example"], + deniedOrigins: ["https://blocked.example"], + excludedUrlPatterns: ["*/private/*"] + }); + expect( - shouldStopForCaptureScopeOriginChange({ - scopeOrigin: "https://app.example", - nextOrigin: "https://admin.example", - stopOnOriginChange: false, - activeTabScopedBuild: true - }) - ).toBe(false); + evaluateCaptureDocumentScope(capturePolicy, { + url: "https://admin.example/home", + tabId: 7, + now: NOW + }).allowed + ).toBe(true); + expect( + evaluateCaptureDocumentScope(capturePolicy, { + url: "https://blocked.example/home", + tabId: 7, + now: NOW + }).reason + ).toBe("origin-denied"); + expect( + evaluateCaptureDocumentScope(capturePolicy, { + url: "https://admin.example/private/key", + tabId: 7, + now: NOW + }).reason + ).toBe("url-excluded"); }); - it("keeps tab-scoped captures active on opaque navigations when origin-change stops are disabled", () => { + it("rejects child and opaque frame senders by default", () => { expect( - shouldStopForCaptureScopeOriginChange({ - scopeOrigin: "https://app.example", - nextOrigin: null, - stopOnOriginChange: false, - activeTabScopedBuild: true - }) - ).toBe(false); + evaluateCaptureDocumentScope(policy(), { + url: "https://app.example/frame", + tabId: 7, + frameId: 3, + now: NOW + }).reason + ).toBe("subframes-disabled"); + expect( + evaluateCaptureDocumentScope(policy({ includeSubframes: true }), { + url: "about:blank", + tabId: 7, + frameId: 3, + now: NOW + }).reason + ).toBe("opaque-or-invalid-url"); }); - it("stops tab-scoped captures when enterprise policy rejects the next origin", () => { + it("allows an explicitly scoped child frame", () => { expect( - shouldStopForEnterpriseOriginPolicy({ - nextOrigin: "https://blocked.example", - isEnterpriseOriginAllowed: (origin) => origin !== "https://blocked.example" - }) + evaluateCaptureDocumentScope( + policy({ + includeSubframes: true, + allowedOrigins: ["https://widgets.example"] + }), + { + url: "https://widgets.example/embed", + tabId: 7, + frameId: 3, + now: NOW + } + ).allowed ).toBe(true); }); - it("continues tab-scoped captures when enterprise policy allows the next origin", () => { + it("drops marked child content and CDP events as a second-line defense", () => { expect( - shouldStopForEnterpriseOriginPolicy({ - nextOrigin: "https://app.example", - isEnterpriseOriginAllowed: (origin) => origin === "https://app.example" + isCaptureEventFrameAllowed(policy(), { + source: "content", + frame: "content-frame-3" + }) + ).toBe(false); + expect( + isCaptureEventFrameAllowed(policy(), { + source: "cdp", + cdpSessionId: "child-session" }) ).toBe(false); + expect( + isCaptureEventFrameAllowed(policy({ includeSubframes: true }), { + source: "cdp", + cdpSessionId: "child-session" + }) + ).toBe(true); }); - it("keeps tab-scoped captures active on opaque navigations for enterprise policy", () => { + it("injects only the top frame unless subframes are explicitly enabled", () => { + expect(resolveContentScriptInjectionTarget(policy(), 7)).toEqual({ + tabId: 7, + allFrames: false + }); + expect(resolveContentScriptInjectionTarget(policy({ includeSubframes: true }), 7)).toEqual({ + tabId: 7, + allFrames: true + }); + expect(resolveContentScriptInjectionTarget(undefined, 7).allFrames).toBe(false); + }); + + it("fails closed for opaque enterprise navigations", () => { expect( shouldStopForEnterpriseOriginPolicy({ nextOrigin: null, - isEnterpriseOriginAllowed: () => false + isEnterpriseOriginAllowed: () => true }) - ).toBe(false); + ).toBe(true); }); }); diff --git a/apps/extension/src/sw/capture-scope.ts b/apps/extension/src/sw/capture-scope.ts index d490a50..be656d3 100644 --- a/apps/extension/src/sw/capture-scope.ts +++ b/apps/extension/src/sw/capture-scope.ts @@ -1,8 +1,14 @@ -export type CaptureScopeOriginChangeInput = { - scopeOrigin: string | null; - nextOrigin: string | null; - stopOnOriginChange: boolean; - activeTabScopedBuild: boolean; +import { + evaluateCaptureScope, + type CapturePolicy, + type CaptureScopeDecision +} from "@webblackbox/protocol"; + +export type CaptureDocumentScopeInput = { + url: string | null | undefined; + tabId: number; + frameId?: number; + now?: number; }; export type CaptureScopeEnterpriseOriginPolicyInput = { @@ -10,26 +16,90 @@ export type CaptureScopeEnterpriseOriginPolicyInput = { isEnterpriseOriginAllowed: (origin: string) => boolean; }; -export function shouldStopForCaptureScopeOriginChange( - input: CaptureScopeOriginChangeInput +export function bindCapturePolicyToSession( + policy: CapturePolicy, + context: { tabId: number; origin: string } +): CapturePolicy { + return { + ...policy, + scope: { + ...policy.scope, + tabId: context.tabId, + origin: context.origin, + allowedOrigins: [...policy.scope.allowedOrigins], + deniedOrigins: [...policy.scope.deniedOrigins], + excludedUrlPatterns: [...policy.scope.excludedUrlPatterns] + } + }; +} + +/** Applies the protocol scope model to a Chrome document/frame sender. */ +export function evaluateCaptureDocumentScope( + policy: CapturePolicy | null | undefined, + input: CaptureDocumentScopeInput +): CaptureScopeDecision { + const frameId = normalizeFrameId(input.frameId); + + return evaluateCaptureScope(policy, { + url: input.url, + tabId: input.tabId, + frameId: frameId ?? -1, + topLevel: frameId === 0, + now: input.now + }); +} + +/** + * Second-line event defense for sources that carry a child-frame marker even + * after the Chrome sender boundary has been checked. + */ +export function isCaptureEventFrameAllowed( + policy: CapturePolicy | null | undefined, + input: { source: string; frame?: string; cdpSessionId?: string } ): boolean { - if (!input.stopOnOriginChange) { + if (!policy) { return false; } - if (!input.scopeOrigin) { + if (policy.scope.includeSubframes) { + return true; + } + + if (input.cdpSessionId) { return false; } - return input.nextOrigin !== input.scopeOrigin; + return input.frame === undefined || input.frame.length === 0; +} + +export function resolveContentScriptInjectionTarget( + policy: CapturePolicy | null | undefined, + tabId: number +): { tabId: number; allFrames: boolean } { + return { + tabId, + allFrames: policy?.scope.includeSubframes === true + }; } export function shouldStopForEnterpriseOriginPolicy( input: CaptureScopeEnterpriseOriginPolicyInput ): boolean { if (!input.nextOrigin) { - return false; + return true; } return !input.isEnterpriseOriginAllowed(input.nextOrigin); } + +function normalizeFrameId(value: number | undefined): number | null { + if (value === undefined) { + return 0; + } + + if (!Number.isFinite(value) || value < 0) { + return null; + } + + return Math.floor(value); +} diff --git a/apps/extension/src/sw/cdp-capture-scope.test.ts b/apps/extension/src/sw/cdp-capture-scope.test.ts new file mode 100644 index 0000000..b75394f --- /dev/null +++ b/apps/extension/src/sw/cdp-capture-scope.test.ts @@ -0,0 +1,214 @@ +import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; +import { describe, expect, it } from "vitest"; + +import { + createCdpCaptureScopeState, + evaluateCdpCaptureScopeEvent, + filterCdpSnapshotDocuments, + hasDisallowedCdpFrames, + isCdpSessionWithinScope, + primeCdpCaptureFrameTree +} from "./cdp-capture-scope.js"; + +function policy(overrides: Partial = {}): CapturePolicy { + return { + ...DEFAULT_CAPTURE_POLICY, + consent: { + ...DEFAULT_CAPTURE_POLICY.consent, + grantedAt: "2020-01-01T00:00:00.000Z" + }, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + tabId: 7, + origin: "https://app.example", + ...overrides + } + }; +} + +function state(capturePolicy: CapturePolicy = policy()) { + return createCdpCaptureScopeState(capturePolicy, 7, "https://app.example/home"); +} + +describe("CDP capture scope", () => { + it("primes root and child frames with independent scope decisions", () => { + const scope = state(); + + primeCdpCaptureFrameTree(scope, { + frame: { id: "root", url: "https://app.example/home" }, + childFrames: [ + { + frame: { id: "child", url: "https://widgets.example/embed" } + } + ] + }); + + expect(scope.rootFrameId).toBe("root"); + expect(scope.frameScopes.get("root")).toBe(true); + expect(scope.frameScopes.get("child")).toBe(false); + expect(hasDisallowedCdpFrames(scope)).toBe(true); + }); + + it("allows only explicitly scoped child frames", () => { + const scope = state( + policy({ + includeSubframes: true, + allowedOrigins: ["https://app.example", "https://widgets.example"], + deniedOrigins: ["https://blocked.example"] + }) + ); + + expect( + evaluateCdpCaptureScopeEvent(scope, "Page.frameNavigated", { + frame: { + id: "child-ok", + parentId: "root", + url: "https://widgets.example/embed" + } + }).allowed + ).toBe(true); + expect( + evaluateCdpCaptureScopeEvent(scope, "Page.frameNavigated", { + frame: { + id: "child-denied", + parentId: "root", + url: "https://blocked.example/embed" + } + }).allowed + ).toBe(false); + expect( + evaluateCdpCaptureScopeEvent(scope, "Page.frameNavigated", { + frame: { id: "child-opaque", parentId: "root", url: "about:blank" } + }).allowed + ).toBe(false); + }); + + it("stops a disallowed top-level navigation and accepts an allowed one", () => { + const stoppingScope = state(); + const stopped = evaluateCdpCaptureScopeEvent(stoppingScope, "Page.frameNavigated", { + frame: { id: "root", url: "https://admin.example/home" } + }); + + expect(stopped.allowed).toBe(false); + expect(stopped.topNavigation?.decision.reason).toBe("origin-changed"); + + const continuingScope = state( + policy({ + stopOnOriginChange: false, + allowedOrigins: ["https://app.example", "https://admin.example"] + }) + ); + const continued = evaluateCdpCaptureScopeEvent(continuingScope, "Page.frameNavigated", { + frame: { id: "root", url: "https://admin.example/home" } + }); + + expect(continued.allowed).toBe(true); + expect(continuingScope.captureUrl).toBe("https://admin.example/home"); + }); + + it("binds child target sessions to their target URL", () => { + const scope = state( + policy({ + includeSubframes: true, + allowedOrigins: ["https://app.example", "https://widgets.example"] + }) + ); + + expect( + evaluateCdpCaptureScopeEvent(scope, "Target.attachedToTarget", { + sessionId: "allowed-session", + targetInfo: { url: "https://widgets.example/worker.js" } + }).allowed + ).toBe(true); + expect(isCdpSessionWithinScope(scope, "allowed-session")).toBe(true); + expect( + evaluateCdpCaptureScopeEvent(scope, "Target.attachedToTarget", { + sessionId: "opaque-session", + targetInfo: { url: "" } + }).allowed + ).toBe(false); + }); + + it("carries a frame decision through the network request lifecycle", () => { + const scope = state(); + primeCdpCaptureFrameTree(scope, { + frame: { id: "root", url: "https://app.example/home" }, + childFrames: [{ frame: { id: "child", url: "https://widgets.example/embed" } }] + }); + + expect( + evaluateCdpCaptureScopeEvent(scope, "Network.requestWillBeSent", { + requestId: "R-child", + frameId: "child", + documentURL: "https://widgets.example/embed" + }).allowed + ).toBe(false); + expect( + evaluateCdpCaptureScopeEvent(scope, "Network.responseReceived", { + requestId: "R-child" + }).allowed + ).toBe(false); + expect( + evaluateCdpCaptureScopeEvent(scope, "Network.loadingFailed", { + requestId: "R-child" + }).allowed + ).toBe(false); + expect(scope.requestScopes.has("R-child")).toBe(false); + + expect( + evaluateCdpCaptureScopeEvent(scope, "Network.requestWillBeSent", { + requestId: "R-root", + frameId: "root", + documentURL: "https://app.example/home" + }).allowed + ).toBe(true); + expect( + evaluateCdpCaptureScopeEvent(scope, "Network.loadingFinished", { + requestId: "R-root" + }).allowed + ).toBe(true); + }); + + it("carries frame scope through Runtime execution contexts", () => { + const scope = state(); + primeCdpCaptureFrameTree(scope, { + frame: { id: "root", url: "https://app.example/home" }, + childFrames: [{ frame: { id: "child", url: "https://app.example/embed" } }] + }); + + expect( + evaluateCdpCaptureScopeEvent(scope, "Runtime.executionContextCreated", { + context: { id: 11, origin: "https://app.example", auxData: { frameId: "child" } } + }).allowed + ).toBe(false); + expect( + evaluateCdpCaptureScopeEvent(scope, "Runtime.consoleAPICalled", { + executionContextId: 11 + }).allowed + ).toBe(false); + }); + + it("filters DOM snapshot documents to allowed frame IDs", () => { + const scope = state(); + primeCdpCaptureFrameTree(scope, { + frame: { id: "root", url: "https://app.example/home" }, + childFrames: [{ frame: { id: "child", url: "https://app.example/embed" } }] + }); + + expect( + filterCdpSnapshotDocuments(scope, [ + { frameId: "root", marker: "keep" }, + { frameId: "child", marker: "drop" }, + { marker: "ambiguous" } + ]) + ).toEqual([{ frameId: "root", marker: "keep" }]); + }); + + it("drops unbound Network, Runtime, and Log events", () => { + const scope = state(); + + expect(evaluateCdpCaptureScopeEvent(scope, "Network.loadingFinished", {}).allowed).toBe(false); + expect(evaluateCdpCaptureScopeEvent(scope, "Runtime.consoleAPICalled", {}).allowed).toBe(false); + expect(evaluateCdpCaptureScopeEvent(scope, "Log.entryAdded", {}).allowed).toBe(false); + }); +}); diff --git a/apps/extension/src/sw/cdp-capture-scope.ts b/apps/extension/src/sw/cdp-capture-scope.ts new file mode 100644 index 0000000..0005b6f --- /dev/null +++ b/apps/extension/src/sw/cdp-capture-scope.ts @@ -0,0 +1,419 @@ +import { + evaluateCaptureScope, + type CapturePolicy, + type CaptureScopeDecision +} from "@webblackbox/protocol"; + +const CDP_SCOPE_MAX_REQUESTS = 2_000; + +export type CdpCaptureScopeState = { + policy: CapturePolicy | undefined; + tabId: number; + captureUrl: string; + rootFrameId?: string; + sessionScopes: Map; + frameScopes: Map; + executionContextScopes: Map; + requestScopes: Map; +}; + +export type CdpCaptureScopeEvaluation = { + allowed: boolean; + topNavigation?: { + url: string; + decision: CaptureScopeDecision; + }; +}; + +export function createCdpCaptureScopeState( + policy: CapturePolicy | undefined, + tabId: number, + captureUrl: string +): CdpCaptureScopeState { + return { + policy, + tabId, + captureUrl, + rootFrameId: undefined, + sessionScopes: new Map(), + frameScopes: new Map(), + executionContextScopes: new Map(), + requestScopes: new Map() + }; +} + +export function evaluateCdpCaptureScopeEvent( + state: CdpCaptureScopeState, + method: string, + params: unknown, + sessionId?: string +): CdpCaptureScopeEvaluation { + const payload = asRecord(params); + + if (method === "Target.attachedToTarget") { + const childSessionId = asString(payload?.sessionId); + const targetInfo = asRecord(payload?.targetInfo); + const allowed = evaluateFrameDocument(state, asString(targetInfo?.url), false).allowed; + + if (childSessionId) { + state.sessionScopes.set(childSessionId, allowed); + } + + return { allowed }; + } + + if (method === "Target.detachedFromTarget") { + const childSessionId = asString(payload?.sessionId); + + if (childSessionId) { + state.sessionScopes.delete(childSessionId); + } + + return { allowed: false }; + } + + if (method === "Page.frameNavigated") { + const frame = asRecord(payload?.frame); + const frameId = asString(frame?.id); + const frameUrl = asString(frame?.url); + const topLevel = + !sessionId && typeof frame?.parentId !== "string" && typeof frame?.parentFrameId !== "string"; + const decision = evaluateFrameDocument(state, frameUrl, topLevel); + + if (frameId) { + state.frameScopes.set(frameId, decision.allowed); + + if (topLevel) { + state.rootFrameId = frameId; + } + } + + if (sessionId) { + state.sessionScopes.set(sessionId, decision.allowed); + } + + if (topLevel && frameUrl) { + if (decision.allowed) { + state.captureUrl = frameUrl; + } + + return { + allowed: decision.allowed, + topNavigation: { + url: frameUrl, + decision + } + }; + } + + return { allowed: decision.allowed }; + } + + if (method === "Page.navigatedWithinDocument") { + const frameId = asString(payload?.frameId); + const frameUrl = asString(payload?.url); + const topLevel = !sessionId && frameId !== null && frameId === state.rootFrameId; + const decision = evaluateFrameDocument(state, frameUrl, topLevel); + + if (frameId) { + state.frameScopes.set(frameId, decision.allowed); + } + + if (sessionId) { + state.sessionScopes.set(sessionId, decision.allowed); + } + + if (topLevel && frameUrl) { + if (decision.allowed) { + state.captureUrl = frameUrl; + } + + return { + allowed: decision.allowed, + topNavigation: { + url: frameUrl, + decision + } + }; + } + + return { allowed: decision.allowed }; + } + + if (method === "Page.frameDetached") { + const frameId = asString(payload?.frameId); + + if (frameId) { + state.frameScopes.delete(frameId); + } + + return { allowed: false }; + } + + if (method === "Runtime.executionContextsCleared") { + state.executionContextScopes.clear(); + return { allowed: false }; + } + + if (method === "Runtime.executionContextDestroyed") { + const executionContextId = asFiniteNumber(payload?.executionContextId); + + if (executionContextId !== null) { + state.executionContextScopes.delete(executionContextId); + } + + return { allowed: false }; + } + + if (sessionId && state.sessionScopes.get(sessionId) !== true) { + return { allowed: false }; + } + + if (method === "Runtime.executionContextCreated") { + const context = asRecord(payload?.context); + const contextId = asFiniteNumber(context?.id); + const auxData = asRecord(context?.auxData); + const frameId = asString(auxData?.frameId); + const contextOrigin = asString(context?.origin); + const allowed = sessionId + ? state.sessionScopes.get(sessionId) === true + : frameId && state.frameScopes.has(frameId) + ? state.frameScopes.get(frameId) === true + : evaluateFrameDocument( + state, + contextOrigin, + frameId !== null && frameId === state.rootFrameId + ).allowed; + + if (contextId !== null) { + state.executionContextScopes.set(contextId, allowed); + } + + return { allowed }; + } + + const executionContextId = resolveExecutionContextId(method, payload); + + if (executionContextId !== null) { + const contextScope = state.executionContextScopes.get(executionContextId); + + if (contextScope !== undefined) { + return { allowed: contextScope }; + } + + if (method.startsWith("Runtime.")) { + return { allowed: false }; + } + } + + const requestId = asString(payload?.requestId); + const requestScopeKey = requestId ? buildRequestScopeKey(requestId, sessionId) : null; + + if (requestScopeKey && method !== "Network.requestWillBeSent") { + const requestScope = state.requestScopes.get(requestScopeKey); + + if (requestScope !== undefined) { + if (method === "Network.loadingFinished" || method === "Network.loadingFailed") { + state.requestScopes.delete(requestScopeKey); + } + + return { allowed: requestScope }; + } + } + + const frameId = resolveFrameId(payload); + + if (frameId) { + const knownScope = state.frameScopes.get(frameId); + + if (knownScope !== undefined) { + return { + allowed: rememberRequestScope(state, method, requestScopeKey, knownScope) + }; + } + + const documentUrl = asString(payload?.documentURL) ?? asString(asRecord(payload?.frame)?.url); + + if (!documentUrl) { + const allowed = frameId === state.rootFrameId; + state.frameScopes.set(frameId, allowed); + return { + allowed: rememberRequestScope(state, method, requestScopeKey, allowed) + }; + } + + const allowed = evaluateFrameDocument( + state, + documentUrl, + frameId === state.rootFrameId + ).allowed; + state.frameScopes.set(frameId, allowed); + return { + allowed: rememberRequestScope(state, method, requestScopeKey, allowed) + }; + } + + if ( + method.startsWith("Network.") || + method.startsWith("Runtime.") || + method === "Log.entryAdded" + ) { + return { allowed: false }; + } + + return { + allowed: evaluateFrameDocument(state, state.captureUrl, true).allowed + }; +} + +export function primeCdpCaptureFrameTree(state: CdpCaptureScopeState, value: unknown): void { + function visit(candidate: unknown, topLevel: boolean): void { + const tree = asRecord(candidate); + const frame = asRecord(tree?.frame); + const frameId = asString(frame?.id); + + if (frameId) { + state.frameScopes.set( + frameId, + evaluateFrameDocument(state, asString(frame?.url), topLevel).allowed + ); + + if (topLevel) { + state.rootFrameId = frameId; + } + } + + const children = Array.isArray(tree?.childFrames) ? tree.childFrames : []; + + for (const child of children) { + visit(child, false); + } + } + + visit(value, true); +} + +export function isCdpSessionWithinScope(state: CdpCaptureScopeState, sessionId: string): boolean { + return state.sessionScopes.get(sessionId) === true; +} + +export function forgetCdpSessionScope(state: CdpCaptureScopeState, sessionId: string): void { + state.sessionScopes.delete(sessionId); +} + +export function forgetCdpRequestScope( + state: CdpCaptureScopeState, + requestId: string, + sessionId?: string +): void { + state.requestScopes.delete(buildRequestScopeKey(requestId, sessionId)); +} + +export function clearCdpCaptureScopeState(state: CdpCaptureScopeState): void { + state.rootFrameId = undefined; + state.sessionScopes.clear(); + state.frameScopes.clear(); + state.executionContextScopes.clear(); + state.requestScopes.clear(); +} + +export function hasDisallowedCdpFrames(state: CdpCaptureScopeState): boolean { + for (const [frameId, allowed] of state.frameScopes) { + if (frameId !== state.rootFrameId && !allowed) { + return true; + } + } + + return false; +} + +export function filterCdpSnapshotDocuments( + state: CdpCaptureScopeState, + value: unknown +): Record[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((entry): entry is Record => { + const document = asRecord(entry); + const frameId = asString(document?.frameId); + + if (!document || !frameId) { + return false; + } + + return frameId === state.rootFrameId || state.frameScopes.get(frameId) === true; + }); +} + +function evaluateFrameDocument( + state: CdpCaptureScopeState, + url: string | null | undefined, + topLevel: boolean +): CaptureScopeDecision { + return evaluateCaptureScope(state.policy, { + url, + tabId: state.tabId, + frameId: topLevel ? 0 : 1, + topLevel + }); +} + +function resolveFrameId(payload: Record | null): string | null { + return ( + asString(payload?.frameId) ?? + asString(asRecord(payload?.frame)?.id) ?? + asString(asRecord(payload?.request)?.frameId) + ); +} + +function resolveExecutionContextId( + method: string, + payload: Record | null +): number | null { + if (method === "Runtime.exceptionThrown") { + return asFiniteNumber(asRecord(payload?.exceptionDetails)?.executionContextId); + } + + return asFiniteNumber(payload?.executionContextId); +} + +function rememberRequestScope( + state: CdpCaptureScopeState, + method: string, + requestScopeKey: string | null, + allowed: boolean +): boolean { + if (requestScopeKey && method === "Network.requestWillBeSent") { + if (state.requestScopes.size >= CDP_SCOPE_MAX_REQUESTS) { + const oldestKey = state.requestScopes.keys().next().value; + + if (typeof oldestKey === "string") { + state.requestScopes.delete(oldestKey); + } + } + + state.requestScopes.set(requestScopeKey, allowed); + } + + return allowed; +} + +function buildRequestScopeKey(requestId: string, sessionId?: string): string { + return sessionId ? `cdp:${sessionId}:${requestId}` : requestId; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function asFiniteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index c932c76..b63cd9f 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -27,7 +27,7 @@ import { WebBlackboxRecorder } from "@webblackbox/recorder"; -import { getChromeApi, type PortLike } from "../shared/chrome-api.js"; +import { getChromeApi, type MessageSenderLike, type PortLike } from "../shared/chrome-api.js"; import { PORT_NAMES, type ExportPrivacyWarning, @@ -62,9 +62,24 @@ import { transformResponseBodyForCapture } from "./body-capture-utils.js"; import { - shouldStopForCaptureScopeOriginChange, + bindCapturePolicyToSession, + evaluateCaptureDocumentScope, + isCaptureEventFrameAllowed, + resolveContentScriptInjectionTarget, shouldStopForEnterpriseOriginPolicy as shouldStopForEnterpriseOriginPolicyInput } from "./capture-scope.js"; +import { + clearCdpCaptureScopeState, + createCdpCaptureScopeState, + evaluateCdpCaptureScopeEvent, + filterCdpSnapshotDocuments, + forgetCdpRequestScope, + forgetCdpSessionScope, + hasDisallowedCdpFrames, + isCdpSessionWithinScope, + primeCdpCaptureFrameTree, + type CdpCaptureScopeState +} from "./cdp-capture-scope.js"; import { withCdpCommandTimeout, type CdpCommandOutcome } from "./cdp-command.js"; import { buildLiteNetworkFailureRawEvent, @@ -92,7 +107,7 @@ type SessionRuntime = { tabId: number; mode: CaptureMode; url: string; - scopeOrigin: string | null; + captureUrl: string; title?: string; tags: string[]; note?: string; @@ -103,6 +118,8 @@ type SessionRuntime = { pipeline: SessionPipelineClient; cdpRouter: CdpRouter | null; enabledCdpSessions: Set; + cdpScope: CdpCaptureScopeState; + authorizedContentFrames: Set; requestMeta: Map; screenshotInterval: ReturnType | null; screenRecording: ScreenRecordingRuntime | null; @@ -134,6 +151,7 @@ type SessionRuntime = { removeCdpListeners: Array<() => void>; heapSnapshotCapture: HeapSnapshotCaptureState | null; cleanupTimer: ReturnType | null; + consentExpiryTimer: ReturnType | null; }; type ScreenRecordingRuntime = { @@ -529,8 +547,20 @@ async function syncContentPortStateOnConnect(port: PortLike): Promise { return; } + if (!authorizeContentSender(runtime, port.sender)) { + sendContentPortRecordingState(port, runtime, false); + + if (normalizeSenderFrameId(port.sender) === 0) { + await stopSession(tabId); + } else { + runtime.authorizedContentFrames.delete(normalizeSenderFrameId(port.sender)); + } + + return; + } + if (shouldInjectHooksForMode(runtime.mode)) { - await ensureInjectedHooks(tabId); + await ensureInjectedHooks(tabId, normalizeSenderFrameId(port.sender)); } syncContentPortRecordingState(port); @@ -549,12 +579,26 @@ function syncContentPortRecordingState(port: PortLike): void { return; } + if (!authorizeContentSender(runtime, port.sender)) { + sendContentPortRecordingState(port, runtime, false); + runtime.authorizedContentFrames.delete(normalizeSenderFrameId(port.sender)); + return; + } + + sendContentPortRecordingState(port, runtime, true); +} + +function sendContentPortRecordingState( + port: PortLike, + runtime: SessionRuntime, + active: boolean +): void { const sampling = toStatusSampling(runtime); try { port.postMessage({ kind: "sw.recording-status", - active: true, + active, sid: runtime.sid, mode: runtime.mode, sampling, @@ -562,7 +606,7 @@ function syncContentPortRecordingState(port: PortLike): void { }); } catch (error) { logPortSendFailure("sw.recording-status", error, { - tabId, + tabId: runtime.tabId, sid: runtime.sid, mode: runtime.mode }); @@ -576,7 +620,7 @@ chromeApi?.runtime?.onMessage.addListener((rawMessage, sender, sendResponse) => return; } - void handleInboundMessage(message, undefined, sender.tab?.id, sender.frameId) + void handleInboundMessage(message, undefined, sender) .then((result) => { sendResponse(result ?? { ok: true }); }) @@ -597,13 +641,12 @@ chromeApi?.runtime?.onMessage.addListener((rawMessage, sender, sendResponse) => function dispatchInboundMessage( message: ExtensionInboundMessage, port?: PortLike, - senderTabId?: number, - senderFrameId?: number + sender: MessageSenderLike | undefined = port?.sender ): void { - void handleInboundMessage(message, port, senderTabId, senderFrameId).catch((error) => { + void handleInboundMessage(message, port, sender).catch((error) => { logInboundMessageFailure(message.kind, error, port, { - tabId: senderTabId, - frameId: senderFrameId + tabId: sender?.tab?.id, + frameId: sender?.frameId }); }); } @@ -638,8 +681,7 @@ chromeApi?.tabs?.onRemoved?.addListener((tabId) => { async function handleInboundMessage( message: ExtensionInboundMessage, port?: PortLike, - senderTabId?: number, - senderFrameId?: number + sender: MessageSenderLike | undefined = port?.sender ): Promise { if (message.kind === "ui.start") { const tabId = await resolveUiActionTabId(message.tabId); @@ -704,15 +746,16 @@ async function handleInboundMessage( } if (message.kind === "content.marker") { - const tabId = senderTabId ?? port?.sender?.tab?.id; - const frame = normalizeContentFrameId(senderFrameId ?? port?.sender?.frameId); + const tabId = sender?.tab?.id; + const frame = normalizeContentFrameId(sender?.frameId); + const runtime = typeof tabId === "number" ? sessionsByTab.get(tabId) : undefined; - if (typeof tabId === "number") { + if (runtime && authorizeContentSender(runtime, sender, message.documentUrl)) { ingestRawEvent({ source: "content", rawType: "marker", - tabId, - sid: sessionsByTab.get(tabId)?.sid ?? "", + tabId: runtime.tabId, + sid: runtime.sid, t: Date.now(), mono: monotonicTime(), frame, @@ -720,13 +763,15 @@ async function handleInboundMessage( message: message.message } }); + } else if (runtime) { + await deactivateDeniedContentFrame(runtime, sender, port); } return; } if (message.kind === "content.ready") { - const tabId = senderTabId ?? port?.sender?.tab?.id; + const tabId = sender?.tab?.id; if (typeof tabId !== "number") { return { @@ -744,8 +789,24 @@ async function handleInboundMessage( }; } + if (!authorizeContentSender(runtime, sender, message.documentUrl)) { + if (normalizeSenderFrameId(sender) === 0) { + await stopSession(tabId); + } else { + await deactivateDeniedContentFrame(runtime, sender, port); + } + + return { + kind: "sw.recording-status", + active: false, + sid: runtime.sid, + mode: runtime.mode, + capturePolicy: runtime.config.capturePolicy + }; + } + if (shouldInjectHooksForMode(runtime.mode)) { - await ensureInjectedHooks(tabId); + await ensureInjectedHooks(tabId, normalizeSenderFrameId(sender)); } const sampling = toStatusSampling(runtime); @@ -768,13 +829,15 @@ async function handleInboundMessage( } if (message.kind === "content.stop-drained") { - markStopDrainAckReceived(message.sid); + if (normalizeSenderFrameId(sender) === 0) { + markStopDrainAckReceived(message.sid); + } return; } if (message.kind === "content.events") { - const tabId = senderTabId ?? port?.sender?.tab?.id; - const frame = normalizeContentFrameId(senderFrameId ?? port?.sender?.frameId); + const tabId = sender?.tab?.id; + const frame = normalizeContentFrameId(sender?.frameId); if ( typeof tabId !== "number" || @@ -784,6 +847,18 @@ async function handleInboundMessage( return; } + const runtime = sessionsByTab.get(tabId); + + if (!runtime || !authorizeContentSender(runtime, sender, message.documentUrl)) { + if (runtime && normalizeSenderFrameId(sender) === 0) { + await stopSession(tabId); + } else if (runtime) { + await deactivateDeniedContentFrame(runtime, sender, port); + } + + return; + } + adjustInFlightContentMessages(tabId, 1); let sliceStartedAt = perfNow(); @@ -792,7 +867,8 @@ async function handleInboundMessage( ingestRawEvent({ ...rawEvent, tabId, - frame: rawEvent.frame ?? frame + sid: runtime.sid, + frame }); if (perfNow() - sliceStartedAt >= CONTENT_EVENT_SLICE_BUDGET_MS) { @@ -839,18 +915,12 @@ async function startSession( await stopSession(tabId); } - await ensureOffscreenDocument(); - const sid = createSessionId(); const startedAt = Date.now(); const tabMetadata = await resolveTabSessionMetadata(tabId); - const sessionOrigin = resolveUrlOrigin(sanitizeUrlForPrivacy(tabMetadata.url)) ?? ""; + const sessionOrigin = resolveUrlOrigin(tabMetadata.captureUrl) ?? ""; const enterprisePolicy = await loadEnterprisePolicy(); - if (!isEnterpriseOriginAllowed(sessionOrigin, enterprisePolicy)) { - throw new Error("Recording is blocked by enterprise site policy."); - } - const loadedRecorderConfig = applyFullModeVisualCapture( await loadRecorderConfig(mode), mode, @@ -864,6 +934,19 @@ async function startSession( }), enterprisePolicy ); + const startScope = evaluateCaptureDocumentScope(recorderConfig.capturePolicy, { + url: tabMetadata.captureUrl, + tabId, + frameId: 0, + now: startedAt + }); + + if (!startScope.allowed) { + throw new Error(`Recording is outside the permitted capture scope (${startScope.reason}).`); + } + + await ensureOffscreenDocument(); + const performanceBudget = await loadPerformanceBudgetConfig(); const annotation = getSessionAnnotation(sid); const metadata: SessionMetadata = { @@ -885,7 +968,7 @@ async function startSession( tabId, mode, url: metadata.url, - scopeOrigin: resolveUrlOrigin(metadata.url), + captureUrl: tabMetadata.captureUrl, title: metadata.title, tags: [...annotation.tags], note: annotation.note, @@ -904,6 +987,12 @@ async function startSession( pipeline, cdpRouter: null, enabledCdpSessions: new Set(), + cdpScope: createCdpCaptureScopeState( + recorderConfig.capturePolicy, + tabId, + tabMetadata.captureUrl + ), + authorizedContentFrames: new Set([0]), requestMeta: new Map(), screenshotInterval: null, screenRecording: null, @@ -934,7 +1023,8 @@ async function startSession( queue: Promise.resolve(), removeCdpListeners: [], heapSnapshotCapture: null, - cleanupTimer: null + cleanupTimer: null, + consentExpiryTimer: null }; runtime.recorder = new WebBlackboxRecorder( @@ -959,6 +1049,7 @@ async function startSession( sessionsByTab.set(tabId, runtime); sessionsBySid.set(sid, runtime); + scheduleConsentExpiry(runtime); if (mode === "lite") { installLiteWebRequestCapture(); @@ -975,8 +1066,8 @@ async function startSession( }); if (shouldInjectHooksForMode(mode)) { - await ensureContentScriptInjected(tabId); - await ensureInjectedHooks(tabId); + await ensureContentScriptInjected(runtime); + await ensureInjectedHooks(tabId, 0); } if (mode === "full" && recorderConfig.capturePolicy?.categories.cdp !== "off") { @@ -995,7 +1086,7 @@ async function startSession( const sampling = toStatusSampling(runtime); await setRecordingBadge(); - await notifyTabStatus(tabId, true, sid, mode, sampling, recorderConfig.capturePolicy); + await notifyAuthorizedContentFrames(runtime, true); broadcast({ kind: "sw.recording-status", active: true, @@ -1029,16 +1120,21 @@ async function restoreTabInstrumentationAfterNavigation(tabId: number): Promise< return; } - await ensureContentScriptInjected(tabId); - await ensureInjectedHooks(tabId); - await notifyTabStatus( - tabId, - true, - runtime.sid, - runtime.mode, - toStatusSampling(runtime), - runtime.config.capturePolicy - ); + const tabMetadata = await resolveTabSessionMetadata(tabId); + + if (!(await isRuntimeTopDocumentAllowed(runtime, tabMetadata.captureUrl))) { + await stopSession(tabId); + return; + } + + runtime.captureUrl = tabMetadata.captureUrl; + runtime.cdpScope.captureUrl = tabMetadata.captureUrl; + runtime.url = tabMetadata.url; + resetAuthorizedContentFrames(runtime); + + await ensureContentScriptInjected(runtime); + await ensureInjectedHooks(tabId, 0); + await notifyAuthorizedContentFrames(runtime, true); } async function stopSession(tabId: number): Promise { @@ -1055,6 +1151,7 @@ async function stopSession(tabId: number): Promise { }); await flushBufferedPipelineEvents(runtime); await teardownCaptureInstrumentation(runtime); + await notifyAuthorizedContentFrames(runtime, false); sessionsByTab.delete(runtime.tabId); uninstallLiteWebRequestCaptureIfUnused(); runtime.stoppedAt = Date.now(); @@ -1066,14 +1163,6 @@ async function stopSession(tabId: number): Promise { await setRecordingBadge(); } - await notifyTabStatus( - tabId, - false, - runtime.sid, - runtime.mode, - toStatusSampling(runtime), - runtime.config.capturePolicy - ); broadcast({ kind: "sw.recording-status", active: false, @@ -1227,6 +1316,24 @@ function ingestRawEvent(rawEvent: RawRecorderEvent): void { return; } + const runtimeScope = evaluateCaptureDocumentScope(runtime.config.capturePolicy, { + url: runtime.captureUrl, + tabId: runtime.tabId, + frameId: 0 + }); + + if (!runtimeScope.allowed) { + if (!runtime.stopping && !runtime.stoppedAt) { + void stopSession(runtime.tabId); + } + + return; + } + + if (!isCaptureEventFrameAllowed(runtime.config.capturePolicy, rawEvent)) { + return; + } + if ( runtime.stopping && rawEvent.source !== "system" && @@ -1348,6 +1455,10 @@ async function materializeLiteContentEvent( runtime: SessionRuntime, rawEvent: RawRecorderEvent ): Promise { + if (!isRuntimeDocumentWithinScope(runtime)) { + return null; + } + if (rawEvent.rawType === "screenshot") { return materializeLiteScreenshot(runtime, rawEvent); } @@ -2200,8 +2311,28 @@ async function attachCdp(runtime: SessionRuntime): Promise { const unsubscribeEvent = router.onEvent((event) => { const normalizedPayload = normalizeFullModePayload(event.method, event.params ?? {}); + const scopeEvaluation = evaluateCdpCaptureScopeEvent( + runtime.cdpScope, + event.method, + event.params ?? {}, + event.sessionId + ); + const scopeAllowed = scopeEvaluation.allowed; + + if (scopeEvaluation.topNavigation) { + const { url, decision } = scopeEvaluation.topNavigation; + + if (!decision.allowed) { + void stopSession(runtime.tabId); + } else { + runtime.captureUrl = url; + runtime.url = sanitizeUrlForPrivacy(url); + resetAuthorizedContentFrames(runtime); + void handleTabUrlChanged(runtime.tabId, url); + } + } - if (event.method === "HeapProfiler.addHeapSnapshotChunk") { + if (scopeAllowed && event.method === "HeapProfiler.addHeapSnapshotChunk") { const payload = asRecord(normalizedPayload); const chunk = typeof payload?.chunk === "string" ? payload.chunk : undefined; @@ -2218,18 +2349,25 @@ async function attachCdp(runtime: SessionRuntime): Promise { } } - ingestRawEvent({ - source: "cdp", - rawType: event.method, - tabId: runtime.tabId, - sid: runtime.sid, - t: Date.now(), - mono: monotonicTime(), - cdpSessionId: event.sessionId, - payload: normalizedPayload - }); + if (scopeAllowed) { + ingestRawEvent({ + source: "cdp", + rawType: event.method, + tabId: runtime.tabId, + sid: runtime.sid, + t: Date.now(), + mono: monotonicTime(), + cdpSessionId: event.sessionId, + payload: normalizedPayload + }); + } - if (!FULL_MODE_FOLLOWUP_METHODS.has(event.method)) { + if ( + !FULL_MODE_FOLLOWUP_METHODS.has(event.method) || + (!scopeAllowed && + event.method !== "Target.attachedToTarget" && + event.method !== "Target.detachedFromTarget") + ) { return; } @@ -2251,15 +2389,20 @@ async function attachCdp(runtime: SessionRuntime): Promise { runtime.removeCdpListeners.push(unsubscribeEvent, unsubscribeDetach); await router.attach(runtime.tabId); + runtime.cdpRouter = router; runtime.enabledCdpSessions.clear(); await router.enableBaseline(runtime.tabId); runtime.enabledCdpSessions.add("root"); - await router.enableAutoAttach(runtime.tabId); + await primeCdpFrameTreeScopes(runtime); + runtime.cdpScope.executionContextScopes.clear(); + await router.send({ tabId: runtime.tabId }, "Runtime.disable").catch(() => undefined); + await router.send({ tabId: runtime.tabId }, "Runtime.enable").catch(() => undefined); + if (runtime.config.capturePolicy?.scope.includeSubframes === true) { + await router.enableAutoAttach(runtime.tabId); + } await router.send({ tabId: runtime.tabId }, "DOMStorage.enable").catch(() => undefined); await router.send({ tabId: runtime.tabId }, "Performance.enable").catch(() => undefined); - runtime.cdpRouter = router; - enqueue( runtime, async () => { @@ -2295,6 +2438,38 @@ async function attachCdp(runtime: SessionRuntime): Promise { } } +async function primeCdpFrameTreeScopes(runtime: SessionRuntime): Promise { + if (!runtime.cdpRouter) { + return; + } + + const response = await runtime.cdpRouter + .send<{ frameTree?: unknown }>({ tabId: runtime.tabId }, "Page.getFrameTree") + .catch(() => undefined); + + primeCdpCaptureFrameTree(runtime.cdpScope, response?.frameTree); +} + +function evaluateRuntimeFrameDocument( + runtime: SessionRuntime, + url: string | null | undefined, + topLevel: boolean +): boolean { + return evaluateCaptureDocumentScope(runtime.config.capturePolicy, { + url, + tabId: runtime.tabId, + frameId: topLevel ? 0 : 1 + }).allowed; +} + +function isRuntimeCaptureScopeActive(runtime: SessionRuntime): boolean { + return !runtime.stopping && !runtime.stoppedAt && isRuntimeDocumentWithinScope(runtime); +} + +function isRuntimeDocumentWithinScope(runtime: SessionRuntime): boolean { + return evaluateRuntimeFrameDocument(runtime, runtime.captureUrl, true); +} + async function processFullModeEvent( runtime: SessionRuntime, method: string, @@ -2310,7 +2485,7 @@ async function processFullModeEvent( if (method === "Target.attachedToTarget") { const childSessionId = typeof payload?.sessionId === "string" ? payload.sessionId : undefined; - if (childSessionId) { + if (childSessionId && isCdpSessionWithinScope(runtime.cdpScope, childSessionId)) { await primeChildCdpSession(runtime, childSessionId); } @@ -2322,6 +2497,7 @@ async function processFullModeEvent( if (childSessionId) { runtime.enabledCdpSessions.delete(childSessionId); + forgetCdpSessionScope(runtime.cdpScope, childSessionId); } return; @@ -2353,6 +2529,7 @@ async function processFullModeEvent( if (requestId) { deleteRequestMeta(runtime.requestMeta, buildRequestMetaKey(requestId, sessionId)); + forgetCdpRequestScope(runtime.cdpScope, requestId, sessionId); } return; @@ -2364,6 +2541,7 @@ async function processFullModeEvent( if (requestId) { deleteRequestMeta(runtime.requestMeta, buildRequestMetaKey(requestId, sessionId)); + forgetCdpRequestScope(runtime.cdpScope, requestId, sessionId); } } @@ -2374,7 +2552,14 @@ async function processFullModeEvent( return; } - if (method === "Page.frameNavigated" && shouldCaptureNavigationSnapshot(runtime)) { + const frame = asRecord(payload?.frame); + const isTopFrameNavigation = + method === "Page.frameNavigated" && + !sessionId && + typeof frame?.parentId !== "string" && + typeof frame?.parentFrameId !== "string"; + + if (isTopFrameNavigation && shouldCaptureNavigationSnapshot(runtime)) { await captureDomSnapshot(runtime, "navigation"); } } @@ -2408,7 +2593,7 @@ async function captureResponseBody( requestId: string, sessionId?: string ): Promise { - if (!runtime.cdpRouter || runtime.stopping) { + if (!runtime.cdpRouter || !isRuntimeCaptureScopeActive(runtime)) { return; } @@ -2422,6 +2607,10 @@ async function captureResponseBody( return; } + if (!isRuntimeCaptureScopeActive(runtime)) { + return; + } + const metadata = getRequestMeta(runtime.requestMeta, buildRequestMetaKey(requestId, sessionId)); const normalizedMime = normalizeMimeType(metadata?.mimeType ?? null); const captureRule = resolveFullBodyCaptureRule(runtime, metadata?.url ?? "", normalizedMime); @@ -2615,6 +2804,11 @@ function handleFreezeNotice(runtime: SessionRuntime, reason: FreezeReason): void } async function captureFullModeArtifacts(runtime: SessionRuntime, reason: string): Promise { + if (!isRuntimeCaptureScopeActive(runtime) || hasDisallowedCdpFrames(runtime.cdpScope)) { + await stopScreenRecording(runtime, "capture-scope").catch(() => undefined); + return; + } + const tasks: Array> = [ captureScreenshot(runtime, reason), captureTraceMetrics(runtime, reason) @@ -2632,7 +2826,11 @@ async function captureFullModeArtifacts(runtime: SessionRuntime, reason: string) } async function captureScreenshot(runtime: SessionRuntime, reason: string): Promise { - if (!runtime.cdpRouter) { + if ( + !runtime.cdpRouter || + !isRuntimeCaptureScopeActive(runtime) || + hasDisallowedCdpFrames(runtime.cdpScope) + ) { return; } @@ -2655,6 +2853,10 @@ async function captureScreenshot(runtime: SessionRuntime, reason: string): Promi return; } + if (!isRuntimeCaptureScopeActive(runtime) || hasDisallowedCdpFrames(runtime.cdpScope)) { + return; + } + const bytes = decodeBase64(screenshot.data); const hash = await runtime.pipeline.putBlob("image/webp", bytes); const viewport = runtime.lastViewport; @@ -2699,7 +2901,10 @@ async function captureScreenshot(runtime: SessionRuntime, reason: string): Promi function shouldStartScreenRecording(runtime: SessionRuntime): boolean { return ( - runtime.mode === "full" && runtime.config.capturePolicy?.categories.screenRecordings === "allow" + runtime.mode === "full" && + isRuntimeCaptureScopeActive(runtime) && + !hasDisallowedCdpFrames(runtime.cdpScope) && + runtime.config.capturePolicy?.categories.screenRecordings === "allow" ); } @@ -2723,6 +2928,10 @@ async function startScreenRecording(runtime: SessionRuntime): Promise { throw new Error("Chrome did not grant a tab capture stream."); } + if (!isRuntimeCaptureScopeActive(runtime) || hasDisallowedCdpFrames(runtime.cdpScope)) { + return; + } + const recording: ScreenRecordingRuntime = { recordingId, source: SCREEN_RECORDING_OFFSCREEN_SOURCE, @@ -2810,6 +3019,11 @@ async function handleOffscreenScreenRecordingChunk( return; } + if (!isRuntimeCaptureScopeActive(runtime) || hasDisallowedCdpFrames(runtime.cdpScope)) { + await stopScreenRecording(runtime, "capture-scope").catch(() => undefined); + return; + } + const bytes = asUint8Array(message.bytes); if (!bytes || bytes.byteLength === 0) { @@ -2980,6 +3194,10 @@ function createScreenRecordingId(sid: string): string { } async function captureDomSnapshot(runtime: SessionRuntime, reason: string): Promise { + if (!isRuntimeCaptureScopeActive(runtime) || hasDisallowedCdpFrames(runtime.cdpScope)) { + return; + } + if (!runtime.cdpRouter) { return; } @@ -3003,10 +3221,18 @@ async function captureDomSnapshot(runtime: SessionRuntime, reason: string): Prom return; } - const bytes = new TextEncoder().encode(JSON.stringify(snapshot)); + if (!isRuntimeCaptureScopeActive(runtime)) { + return; + } + + const scopedDocuments = filterCdpSnapshotDocuments(runtime.cdpScope, snapshot.documents); + const scopedSnapshot = { + ...snapshot, + documents: scopedDocuments + }; + const bytes = new TextEncoder().encode(JSON.stringify(scopedSnapshot)); const hash = await runtime.pipeline.putBlob("application/json", bytes); - const documents = Array.isArray(snapshot.documents) ? snapshot.documents : []; - const firstDocument = documents[0] as Record | undefined; + const firstDocument = scopedDocuments[0] as Record | undefined; const nodes = firstDocument ? asRecord(firstDocument.nodes) : null; const nodeNameArray = Array.isArray(nodes?.nodeName) ? nodes.nodeName : []; @@ -3028,6 +3254,10 @@ async function captureDomSnapshot(runtime: SessionRuntime, reason: string): Prom } async function captureStorageSnapshots(runtime: SessionRuntime, reason: string): Promise { + if (!isRuntimeCaptureScopeActive(runtime)) { + return; + } + if (!runtime.cdpRouter) { return; } @@ -3039,11 +3269,14 @@ async function captureStorageSnapshots(runtime: SessionRuntime, reason: string): ? await sendCdpCommand<{ cookies?: unknown[] }>( runtime, { tabId: runtime.tabId }, - "Storage.getCookies" + "Network.getCookies", + { + urls: [runtime.captureUrl] + } ) : null; - if (cookies?.cookies) { + if (cookies?.cookies && isRuntimeCaptureScopeActive(runtime)) { const cookieNames = cookies.cookies .map((entry) => asString(asRecord(entry)?.name)) .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) @@ -3074,7 +3307,7 @@ async function captureStorageSnapshots(runtime: SessionRuntime, reason: string): ? await evaluateExpression(runtime, buildLocalStorageSnapshotExpression(localStorageMode)) : null; - if (typeof localStorageData === "string") { + if (typeof localStorageData === "string" && isRuntimeCaptureScopeActive(runtime)) { const bytes = new TextEncoder().encode(localStorageData); const hash = await runtime.pipeline.putBlob("application/json", bytes); const parsed = parseStorageSnapshotMeta(localStorageData); @@ -3103,7 +3336,7 @@ async function captureStorageSnapshots(runtime: SessionRuntime, reason: string): ? await evaluateExpression(runtime, "location.origin") : null; - if (typeof origin === "string") { + if (typeof origin === "string" && isRuntimeCaptureScopeActive(runtime)) { const dbNames = await sendCdpCommand<{ databaseNames?: string[] }>( runtime, { tabId: runtime.tabId }, @@ -3113,7 +3346,7 @@ async function captureStorageSnapshots(runtime: SessionRuntime, reason: string): } ); - if (dbNames?.databaseNames) { + if (dbNames?.databaseNames && isRuntimeCaptureScopeActive(runtime)) { const bytes = new TextEncoder().encode(JSON.stringify(dbNames.databaseNames)); const hash = await runtime.pipeline.putBlob("application/json", bytes); @@ -3146,6 +3379,10 @@ function resolveLocalStorageSnapshotMode( } async function captureTraceMetrics(runtime: SessionRuntime, reason: string): Promise { + if (!isRuntimeCaptureScopeActive(runtime)) { + return; + } + if (!runtime.cdpRouter) { return; } @@ -3164,6 +3401,10 @@ async function captureTraceMetrics(runtime: SessionRuntime, reason: string): Pro return; } + if (!isRuntimeCaptureScopeActive(runtime)) { + return; + } + const bytes = new TextEncoder().encode(JSON.stringify(metrics)); const hash = await runtime.pipeline.putBlob("application/json", bytes); @@ -3192,7 +3433,11 @@ async function captureAdvancedProfiles(runtime: SessionRuntime, reason: string): } async function captureCpuProfile(runtime: SessionRuntime, reason: string): Promise { - if (!runtime.cdpRouter) { + if ( + !runtime.cdpRouter || + !isRuntimeCaptureScopeActive(runtime) || + hasDisallowedCdpFrames(runtime.cdpScope) + ) { return; } @@ -3221,7 +3466,11 @@ async function captureCpuProfile(runtime: SessionRuntime, reason: string): Promi "Profiler.stop" ); - if (!profileResult?.profile) { + if ( + !profileResult?.profile || + !isRuntimeCaptureScopeActive(runtime) || + hasDisallowedCdpFrames(runtime.cdpScope) + ) { return; } @@ -3248,7 +3497,11 @@ async function captureCpuProfile(runtime: SessionRuntime, reason: string): Promi } async function captureHeapSnapshot(runtime: SessionRuntime, reason: string): Promise { - if (!runtime.cdpRouter) { + if ( + !runtime.cdpRouter || + !isRuntimeCaptureScopeActive(runtime) || + hasDisallowedCdpFrames(runtime.cdpScope) + ) { return; } @@ -3281,7 +3534,13 @@ async function captureHeapSnapshot(runtime: SessionRuntime, reason: string): Pro const snapshot = runtime.heapSnapshotCapture; runtime.heapSnapshotCapture = null; - if (!completed.ok || !snapshot || snapshot.chunks.length === 0) { + if ( + !completed.ok || + !snapshot || + snapshot.chunks.length === 0 || + !isRuntimeCaptureScopeActive(runtime) || + hasDisallowedCdpFrames(runtime.cdpScope) + ) { await sendCdpCommand(runtime, { tabId: runtime.tabId }, "HeapProfiler.disable"); return; } @@ -3548,6 +3807,90 @@ function normalizeContentFrameId(value: unknown): string | undefined { return `content-frame-${frameId}`; } +function normalizeSenderFrameId(sender: MessageSenderLike | undefined): number { + const candidate = asFiniteNumber(sender?.frameId); + + if (candidate === null) { + return sender?.frameId === undefined ? 0 : -1; + } + + return candidate >= 0 ? Math.floor(candidate) : -1; +} + +function authorizeContentSender( + runtime: SessionRuntime, + sender: MessageSenderLike | undefined, + documentUrl?: string +): boolean { + const tabId = sender?.tab?.id; + const frameId = normalizeSenderFrameId(sender); + + if (typeof tabId !== "number" || tabId !== runtime.tabId || frameId < 0) { + return false; + } + + const senderUrl = + typeof documentUrl === "string" && documentUrl.length > 0 + ? documentUrl + : typeof sender?.url === "string" && sender.url.length > 0 + ? sender.url + : frameId === 0 + ? runtime.captureUrl + : null; + const decision = evaluateCaptureDocumentScope(runtime.config.capturePolicy, { + url: senderUrl, + tabId, + frameId + }); + + if (!decision.allowed) { + return false; + } + + if (frameId === 0 && senderUrl && senderUrl !== runtime.captureUrl) { + runtime.captureUrl = senderUrl; + runtime.cdpScope.captureUrl = senderUrl; + runtime.url = sanitizeUrlForPrivacy(senderUrl); + resetAuthorizedContentFrames(runtime); + } + + runtime.authorizedContentFrames.add(frameId); + return true; +} + +async function deactivateDeniedContentFrame( + runtime: SessionRuntime, + sender: MessageSenderLike | undefined, + port?: PortLike +): Promise { + const frameId = normalizeSenderFrameId(sender); + + if (frameId < 0) { + return; + } + + if (port?.name === PORT_NAMES.content) { + sendContentPortRecordingState(port, runtime, false); + } else { + await notifyTabStatus( + runtime.tabId, + false, + runtime.sid, + runtime.mode, + toStatusSampling(runtime), + runtime.config.capturePolicy, + frameId + ); + } + + runtime.authorizedContentFrames.delete(frameId); +} + +function resetAuthorizedContentFrames(runtime: SessionRuntime): void { + runtime.authorizedContentFrames.clear(); + runtime.authorizedContentFrames.add(0); +} + function asString(value: unknown): string | null { return typeof value === "string" ? value : null; } @@ -3920,39 +4263,28 @@ function normalizeHashesManifest(value: unknown): HashesManifest { }; } -async function ensureInjectedHooks(tabId: number): Promise { +async function ensureInjectedHooks(tabId: number, frameId: number): Promise { await chromeApi?.scripting ?.executeScript({ - target: { tabId }, + target: { tabId, frameIds: [frameId] }, world: "MAIN", files: ["injected.js"] }) .catch(() => undefined); } -async function ensureContentScriptInjected(tabId: number): Promise { - if (manifestDeclaresStaticContentScript()) { - return; - } - +async function ensureContentScriptInjected(runtime: SessionRuntime): Promise { await chromeApi?.scripting ?.executeScript({ - target: { tabId, allFrames: true }, + target: { + ...resolveContentScriptInjectionTarget(runtime.config.capturePolicy, runtime.tabId) + }, world: "ISOLATED", files: ["content.js"] }) .catch(() => undefined); } -function manifestDeclaresStaticContentScript(): boolean { - const manifest = chromeApi?.runtime?.getManifest?.(); - const contentScripts = Array.isArray(manifest?.content_scripts) ? manifest.content_scripts : []; - - return contentScripts.some( - (entry) => Array.isArray(entry?.js) && entry.js.includes("content.js") - ); -} - function installLiteWebRequestCapture(): void { if (!chromeApi?.webRequest || liteWebRequestCaptureCleanup) { return; @@ -3967,7 +4299,7 @@ function installLiteWebRequestCapture(): void { url: string; timeStamp?: number; }) => { - const runtime = resolveLiteRuntimeForWebRequest(details.tabId); + const runtime = resolveLiteRuntimeForWebRequest(details.tabId, details.frameId); if (!runtime) { return; @@ -4007,7 +4339,7 @@ function installLiteWebRequestCapture(): void { statusLine?: string; timeStamp?: number; }) => { - const runtime = resolveLiteRuntimeForWebRequest(details.tabId); + const runtime = resolveLiteRuntimeForWebRequest(details.tabId, details.frameId); if (!runtime) { return; @@ -4050,7 +4382,7 @@ function installLiteWebRequestCapture(): void { error?: string; timeStamp?: number; }) => { - const runtime = resolveLiteRuntimeForWebRequest(details.tabId); + const runtime = resolveLiteRuntimeForWebRequest(details.tabId, details.frameId); if (!runtime) { return; @@ -4113,7 +4445,10 @@ function hasActiveLiteRuntime(): boolean { return false; } -function resolveLiteRuntimeForWebRequest(tabId: number): SessionRuntime | undefined { +function resolveLiteRuntimeForWebRequest( + tabId: number, + frameIdCandidate?: number +): SessionRuntime | undefined { if (!Number.isFinite(tabId) || tabId < 0) { return undefined; } @@ -4124,6 +4459,25 @@ function resolveLiteRuntimeForWebRequest(tabId: number): SessionRuntime | undefi return undefined; } + const frameId = + typeof frameIdCandidate === "number" && Number.isFinite(frameIdCandidate) + ? Math.max(0, Math.floor(frameIdCandidate)) + : 0; + + if (frameId > 0 && !runtime.authorizedContentFrames.has(frameId)) { + return undefined; + } + + if ( + !evaluateCaptureDocumentScope(runtime.config.capturePolicy, { + url: runtime.captureUrl, + tabId, + frameId: 0 + }).allowed + ) { + return undefined; + } + return runtime; } @@ -4208,6 +4562,11 @@ function enqueueWithResult( } async function teardownCaptureInstrumentation(runtime: SessionRuntime): Promise { + if (runtime.consentExpiryTimer !== null) { + clearTimeout(runtime.consentExpiryTimer); + runtime.consentExpiryTimer = null; + } + if (runtime.pipelineFlushTimer !== null) { clearTimeout(runtime.pipelineFlushTimer); runtime.pipelineFlushTimer = null; @@ -4216,6 +4575,40 @@ async function teardownCaptureInstrumentation(runtime: SessionRuntime): Promise< await cleanupCdpInstrumentation(runtime, runtime.cdpRouter); } +function scheduleConsentExpiry(runtime: SessionRuntime): void { + if (runtime.consentExpiryTimer !== null) { + clearTimeout(runtime.consentExpiryTimer); + runtime.consentExpiryTimer = null; + } + + const expiresAt = runtime.config.capturePolicy?.consent.expiresAt; + + if (!expiresAt) { + return; + } + + const delay = Date.parse(expiresAt) - Date.now(); + + if (!Number.isFinite(delay) || delay <= 0) { + void stopSession(runtime.tabId); + return; + } + + runtime.consentExpiryTimer = setTimeout( + () => { + runtime.consentExpiryTimer = null; + + if (Date.now() >= Date.parse(expiresAt)) { + void stopSession(runtime.tabId); + return; + } + + scheduleConsentExpiry(runtime); + }, + Math.min(delay, 2_147_000_000) + ); +} + async function cleanupCdpInstrumentation( runtime: SessionRuntime, router: CdpRouter | null @@ -4240,6 +4633,7 @@ async function cleanupCdpInstrumentation( } runtime.enabledCdpSessions.clear(); + clearCdpCaptureScopeState(runtime.cdpScope); runtime.requestMeta.clear(); runtime.responseBodyCaptureTimestamps.length = 0; runtime.heapSnapshotCapture = null; @@ -4349,31 +4743,32 @@ function toSessionMetadata(runtime: SessionRuntime): SessionMetadata { async function resolveTabSessionMetadata( tabId: number -): Promise> { +): Promise & { captureUrl: string }> { const fallbackUrl = `tab:${tabId}`; if (!chromeApi?.tabs?.get) { return { - url: fallbackUrl + url: fallbackUrl, + captureUrl: fallbackUrl }; } try { const tab = await chromeApi.tabs.get(tabId); - const url = - typeof tab?.url === "string" && tab.url.length > 0 - ? sanitizeUrlForPrivacy(tab.url) - : fallbackUrl; + const captureUrl = typeof tab?.url === "string" && tab.url.length > 0 ? tab.url : fallbackUrl; + const url = sanitizeUrlForPrivacy(captureUrl); const title = typeof tab?.title === "string" && tab.title.trim().length > 0 ? tab.title.trim() : undefined; return { url, + captureUrl, title }; } catch { return { - url: fallbackUrl + url: fallbackUrl, + captureUrl: fallbackUrl }; } } @@ -4408,23 +4803,21 @@ async function updateSessionMetadataFromEventAsync( const nextTitle = asString(payload?.title) ?? asString(payload?.documentTitle); let changed = false; - const sanitizedNextUrl = nextUrl ? sanitizeUrlForPrivacy(nextUrl) : undefined; - - if (sanitizedNextUrl && sanitizedNextUrl !== runtime.url) { - const nextOrigin = resolveUrlOrigin(sanitizedNextUrl); - - if (shouldStopOnOriginChange(runtime, nextOrigin)) { + if (nextUrl) { + if (!(await isRuntimeTopDocumentAllowed(runtime, nextUrl))) { await stopSession(runtime.tabId); return; } - if (await shouldStopForEnterpriseOriginPolicy(nextOrigin)) { - await stopSession(runtime.tabId); - return; - } + const sanitizedNextUrl = sanitizeUrlForPrivacy(nextUrl); - runtime.url = sanitizedNextUrl; - changed = true; + if (sanitizedNextUrl !== runtime.url || nextUrl !== runtime.captureUrl) { + runtime.url = sanitizedNextUrl; + runtime.captureUrl = nextUrl; + runtime.cdpScope.captureUrl = nextUrl; + resetAuthorizedContentFrames(runtime); + changed = true; + } } if (nextTitle && nextTitle.trim().length > 0 && nextTitle !== runtime.title) { @@ -4467,32 +4860,37 @@ async function handleTabUrlChanged(tabId: number, rawUrl: string): Promise return; } - const nextUrl = sanitizeUrlForPrivacy(rawUrl); - const nextOrigin = resolveUrlOrigin(nextUrl); - - if (shouldStopOnOriginChange(runtime, nextOrigin)) { + if (!(await isRuntimeTopDocumentAllowed(runtime, rawUrl))) { await stopSession(tabId); return; } - if (await shouldStopForEnterpriseOriginPolicy(nextOrigin)) { - await stopSession(tabId); - return; - } + const nextUrl = sanitizeUrlForPrivacy(rawUrl); - if (nextUrl !== runtime.url) { + if (nextUrl !== runtime.url || rawUrl !== runtime.captureUrl) { runtime.url = nextUrl; + runtime.captureUrl = rawUrl; + runtime.cdpScope.captureUrl = rawUrl; + resetAuthorizedContentFrames(runtime); pushSessionList(); } } -function shouldStopOnOriginChange(runtime: SessionRuntime, nextOrigin: string | null): boolean { - return shouldStopForCaptureScopeOriginChange({ - scopeOrigin: runtime.scopeOrigin, - nextOrigin, - stopOnOriginChange: runtime.config.capturePolicy?.scope.stopOnOriginChange === true, - activeTabScopedBuild: isActiveTabScopedBuild() +async function isRuntimeTopDocumentAllowed( + runtime: SessionRuntime, + rawUrl: string +): Promise { + const decision = evaluateCaptureDocumentScope(runtime.config.capturePolicy, { + url: rawUrl, + tabId: runtime.tabId, + frameId: 0 }); + + if (!decision.allowed) { + return false; + } + + return !(await shouldStopForEnterpriseOriginPolicy(decision.origin)); } async function shouldStopForEnterpriseOriginPolicy(nextOrigin: string | null): Promise { @@ -4504,14 +4902,6 @@ async function shouldStopForEnterpriseOriginPolicy(nextOrigin: string | null): P }); } -function isActiveTabScopedBuild(): boolean { - const manifest = chromeApi?.runtime?.getManifest?.(); - const permissions = new Set(manifest?.permissions ?? []); - const hostPermissions = manifest?.host_permissions ?? []; - - return permissions.has("activeTab") && hostPermissions.length === 0; -} - function resolveUrlOrigin(value: string): string | null { try { const url = new URL(value); @@ -4523,6 +4913,10 @@ function resolveUrlOrigin(value: string): string | null { function broadcast(message: ExtensionOutboundMessage): void { for (const port of connectedPorts) { + if (port.name === PORT_NAMES.content && message.kind === "sw.recording-status") { + continue; + } + sendPortMessage(port, message); } } @@ -4618,19 +5012,16 @@ function withSessionCapturePolicy( ): typeof DEFAULT_RECORDER_CONFIG { const basePolicy = config.capturePolicy ?? DEFAULT_RECORDER_CONFIG.capturePolicy ?? DEFAULT_CAPTURE_POLICY; + const sessionPolicy = bindCapturePolicyToSession(basePolicy, { + tabId: context.tabId, + origin: context.origin + }); const capturePolicy: CapturePolicy = { - ...basePolicy, + ...sessionPolicy, consent: { - ...basePolicy.consent, + ...sessionPolicy.consent, grantedAt: new Date(context.startedAt).toISOString() }, - scope: { - ...basePolicy.scope, - tabId: context.tabId, - origin: context.origin, - allowedOrigins: [...basePolicy.scope.allowedOrigins], - stopOnOriginChange: false - }, redaction: config.redaction }; @@ -4938,24 +5329,50 @@ async function notifyTabStatus( sid?: string, mode?: CaptureMode, sampling?: RecordingSampling, - capturePolicy?: CapturePolicy + capturePolicy?: CapturePolicy, + frameId?: number ): Promise { if (!chromeApi?.tabs?.sendMessage) { return; } await chromeApi.tabs - .sendMessage(tabId, { - kind: "sw.recording-status", - active, - sid, - mode, - sampling, - capturePolicy - }) + .sendMessage( + tabId, + { + kind: "sw.recording-status", + active, + sid, + mode, + sampling, + capturePolicy + }, + frameId === undefined ? undefined : { frameId } + ) .catch(() => undefined); } +async function notifyAuthorizedContentFrames( + runtime: SessionRuntime, + active: boolean +): Promise { + const frameIds = [...runtime.authorizedContentFrames]; + + await Promise.all( + frameIds.map((frameId) => + notifyTabStatus( + runtime.tabId, + active, + runtime.sid, + runtime.mode, + toStatusSampling(runtime), + runtime.config.capturePolicy, + frameId + ) + ) + ); +} + function adjustInFlightContentMessages(tabId: number, delta: 1 | -1): void { const next = (inFlightContentMessagesByTab.get(tabId) ?? 0) + delta; @@ -5049,7 +5466,9 @@ async function relayMarkerCommand(): Promise { return; } - await chromeApi?.tabs?.sendMessage(tabId, { kind: "sw.marker-command" }).catch(() => undefined); + await chromeApi?.tabs + ?.sendMessage(tabId, { kind: "sw.marker-command" }, { frameId: 0 }) + .catch(() => undefined); } async function resolveUiActionTabId(tabId?: number): Promise { diff --git a/docs/CHROME_WEB_STORE_DISCLOSURE.md b/docs/CHROME_WEB_STORE_DISCLOSURE.md index 1562c06..7b1c801 100644 --- a/docs/CHROME_WEB_STORE_DISCLOSURE.md +++ b/docs/CHROME_WEB_STORE_DISCLOSURE.md @@ -7,7 +7,7 @@ WebBlackbox records browser debugging metadata for sessions that the user explic ## Permission Rationale - `activeTab`: grants temporary access to the active tab after a user gesture. -- `scripting`: injects capture code only after recording starts. +- `scripting`: injects a passive scope-checking bootstrap only after recording starts; capture instrumentation activates only in frames permitted by the effective capture scope. - `storage`: stores local settings, local session metadata, and local audit records. - `downloads`: saves user-requested archive exports. diff --git a/docs/ENTERPRISE_ADMIN.md b/docs/ENTERPRISE_ADMIN.md index 1d4bb46..41dad44 100644 --- a/docs/ENTERPRISE_ADMIN.md +++ b/docs/ENTERPRISE_ADMIN.md @@ -26,6 +26,8 @@ Enterprise deployments can provide a managed policy object through `chrome.stora `siteDenylist` wins over `siteAllowlist`. If `siteAllowlist` is non-empty, recording is denied outside the allowlist. `disableLabMode` forces lab-only categories such as full CDP and heap profiles off. +Exact origins and `*.example.com` host wildcards are supported. Managed allow/deny rules are merged into the effective capture scope and are checked at session start, navigation, frame activation, and event ingestion. Opaque frame URLs are rejected. Cross-frame capture additionally requires the effective policy to set `includeSubframes: true`; otherwise dynamic injection, content events, webRequest events, and child CDP sessions remain top-frame-only. + ## Self-Hosted Share Server Recommended production settings: diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index e397472..903379f 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -14,6 +14,12 @@ By default, WebBlackbox records metadata needed to debug a session: By default, WebBlackbox does not collect raw input values, DOM text, screenshots, storage values, cookies, raw headers, request bodies, or response bodies. +## Capture Scope And Consent + +The extension binds each session to the selected tab and starting origin. The same effective scope is evaluated before startup, after top-level navigation, before frame capture instrumentation is activated, and again when content, webRequest, or CDP events arrive. Denied origins and excluded URL patterns always win; a non-empty origin allowlist is restrictive; opaque or invalid document URLs fail closed. + +`stopOnOriginChange` is preserved from the configured policy and defaults to `true`. Child-frame capture is disabled unless `includeSubframes` is explicitly enabled, and enabled child frames must independently pass the origin, URL, tab, and consent checks. Capture stops when time-bounded consent expires. + ## Local Storage Captured sessions remain local until the user exports or shares an archive. Under the required local-at-rest policy, persisted event chunks and blobs are authenticated and encrypted with AES-GCM; raw persistent storage is rejected. Extension upgrades purge the former plaintext cache, and a missing managed key causes unverifiable cached payloads to be purged instead of silently reopened. Query indexes and session metadata remain plaintext and must not contain captured payload values. Local stopped sessions are subject to retention controls, and enterprise policies can cap local retention. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5fedbf9..6acb03a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,14 +4,18 @@ The store-safe Chrome profile uses `activeTab` and programmatic injection after a user gesture. It does not include `debugger`, persistent `` host permissions, or always-on all-sites content scripts. -The dev/enterprise profile can enable deeper diagnostics, including CDP, but these controls are separated from the store-safe profile and remain governed by capture policy. +The dev/enterprise profile can enable deeper diagnostics, including CDP, but these controls are separated from the store-safe profile and remain governed by the same capture-scope evaluator. Both profiles use a passive programmatic bootstrap and activate capture instrumentation only after a frame passes the scope gate; neither profile installs an always-on all-frame capture script. ## Data Flow -1. Capture adapters sanitize data before it enters the recorder pipeline. -2. The ingest gate rejects or replaces policy-violating artifacts with `privacy.violation` events. -3. Archives include `privacy/manifest.json` with policy, categories, encryption status, and pre-encryption scanner result. -4. Exports and shares recompute policy eligibility instead of trusting imported archive metadata. +1. A fail-closed scope gate validates tab, origin allow/deny rules, excluded URLs, frame inclusion, origin-change behavior, and consent lifetime before capture instrumentation is activated. +2. Content, webRequest, and CDP ingress repeat the scope check so a stale or spoofed frame cannot expand consent. +3. Capture adapters sanitize data before it enters the recorder pipeline. +4. The ingest gate rejects or replaces policy-violating artifacts with `privacy.violation` events. +5. Archives include `privacy/manifest.json` with policy, categories, encryption status, and pre-encryption scanner result. +6. Exports and shares recompute policy eligibility instead of trusting imported archive metadata. + +Global visual and profiler artifacts fail closed when the page contains a child frame that is outside the effective scope. CDP DOM snapshots are filtered to frame IDs that independently passed the scope gate. ## Encryption diff --git a/packages/protocol/src/capture-scope.test.ts b/packages/protocol/src/capture-scope.test.ts new file mode 100644 index 0000000..1946716 --- /dev/null +++ b/packages/protocol/src/capture-scope.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_CAPTURE_POLICY } from "./defaults.js"; +import { evaluateCaptureScope, matchesCaptureOrigin } from "./capture-scope.js"; +import type { CapturePolicy } from "./types.js"; + +const NOW = Date.parse("2026-07-11T00:00:00.000Z"); + +function policy(overrides: Partial = {}): CapturePolicy { + return { + ...DEFAULT_CAPTURE_POLICY, + consent: { + ...DEFAULT_CAPTURE_POLICY.consent, + grantedAt: "2026-07-10T00:00:00.000Z", + expiresAt: "2026-07-12T00:00:00.000Z" + }, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + tabId: 7, + origin: "https://app.example", + ...overrides + } + }; +} + +describe("evaluateCaptureScope", () => { + it("allows the bound top-level origin and same-origin navigation", () => { + expect( + evaluateCaptureScope(policy(), { + url: "https://app.example/projects/123?token=secret", + tabId: 7, + frameId: 0, + topLevel: true, + now: NOW + }) + ).toMatchObject({ allowed: true, reason: "allowed", origin: "https://app.example" }); + }); + + it("stops cross-origin top-level navigation when configured", () => { + expect( + evaluateCaptureScope(policy(), { + url: "https://admin.example/dashboard", + tabId: 7, + frameId: 0, + topLevel: true, + now: NOW + }) + ).toMatchObject({ allowed: false, reason: "origin-changed" }); + }); + + it("allows a policy-approved origin change only when stopOnOriginChange is disabled", () => { + const capturePolicy = policy({ + stopOnOriginChange: false, + allowedOrigins: ["https://app.example", "https://admin.example"] + }); + + expect( + evaluateCaptureScope(capturePolicy, { + url: "https://admin.example/dashboard", + tabId: 7, + topLevel: true, + now: NOW + }).allowed + ).toBe(true); + }); + + it("applies deny before allow and supports enterprise wildcard origins", () => { + const capturePolicy = policy({ + stopOnOriginChange: false, + allowedOrigins: ["*.example.com"], + deniedOrigins: ["https://blocked.example.com"] + }); + + expect( + evaluateCaptureScope(capturePolicy, { + url: "https://api.example.com/v1", + topLevel: true, + now: NOW + }).allowed + ).toBe(true); + expect( + evaluateCaptureScope(capturePolicy, { + url: "https://blocked.example.com/v1", + topLevel: true, + now: NOW + }).reason + ).toBe("origin-denied"); + expect( + evaluateCaptureScope(capturePolicy, { + url: "https://outside.test/v1", + topLevel: true, + now: NOW + }).reason + ).toBe("origin-not-allowed"); + }); + + it("rejects excluded paths before capture", () => { + const capturePolicy = policy({ + excludedUrlPatterns: ["https://app.example/private/*", "/billing/*"] + }); + + expect( + evaluateCaptureScope(capturePolicy, { + url: "https://app.example/private/token", + topLevel: true, + now: NOW + }).reason + ).toBe("url-excluded"); + expect( + evaluateCaptureScope(capturePolicy, { + url: "https://app.example/billing/card", + topLevel: true, + now: NOW + }).reason + ).toBe("url-excluded"); + }); + + it("denies child frames unless includeSubframes is explicitly enabled", () => { + expect( + evaluateCaptureScope(policy(), { + url: "https://app.example/embed", + frameId: 2, + topLevel: false, + now: NOW + }).reason + ).toBe("subframes-disabled"); + + expect( + evaluateCaptureScope( + policy({ includeSubframes: true, allowedOrigins: ["https://widgets.example"] }), + { + url: "https://widgets.example/embed", + frameId: 2, + topLevel: false, + now: NOW + } + ).allowed + ).toBe(true); + }); + + it("rejects inconsistent frame identity", () => { + expect( + evaluateCaptureScope(policy({ includeSubframes: true }), { + url: "https://app.example/frame", + frameId: 3, + topLevel: true, + now: NOW + }).reason + ).toBe("frame-context-invalid"); + }); + + it.each(["about:blank", "data:text/html,hello", "file:///tmp/private.html", "not a url"])( + "denies opaque or invalid URL %s", + (url) => { + expect( + evaluateCaptureScope(policy({ stopOnOriginChange: false }), { + url, + topLevel: true, + now: NOW + }).reason + ).toBe("opaque-or-invalid-url"); + } + ); + + it("denies expired, malformed, and not-yet-granted consent", () => { + const expired = policy(); + const malformed = policy(); + const future = policy(); + expired.consent = { ...expired.consent, expiresAt: "2026-07-10T12:00:00.000Z" }; + malformed.consent = { ...malformed.consent, expiresAt: "invalid" }; + future.consent = { ...future.consent, grantedAt: "2026-07-12T00:00:00.000Z" }; + + const context = { url: "https://app.example", topLevel: true, now: NOW }; + expect(evaluateCaptureScope(expired, context).reason).toBe("consent-expired"); + expect(evaluateCaptureScope(malformed, context).reason).toBe("consent-invalid"); + expect(evaluateCaptureScope(future, context).reason).toBe("consent-invalid"); + }); + + it("rejects a session used from a different tab", () => { + expect( + evaluateCaptureScope(policy(), { + url: "https://app.example", + tabId: 8, + topLevel: true, + now: NOW + }).reason + ).toBe("tab-mismatch"); + }); +}); + +describe("matchesCaptureOrigin", () => { + it("supports exact, host wildcard, and scheme-qualified host wildcard patterns", () => { + expect(matchesCaptureOrigin("https://app.example.com", "https://app.example.com")).toBe(true); + expect(matchesCaptureOrigin("https://app.example.com", "*.example.com")).toBe(true); + expect(matchesCaptureOrigin("https://example.com", "*.example.com")).toBe(true); + expect(matchesCaptureOrigin("https://app.example.com", "https://*.example.com")).toBe(true); + expect(matchesCaptureOrigin("http://app.example.com", "https://*.example.com")).toBe(false); + }); +}); diff --git a/packages/protocol/src/capture-scope.ts b/packages/protocol/src/capture-scope.ts new file mode 100644 index 0000000..b491220 --- /dev/null +++ b/packages/protocol/src/capture-scope.ts @@ -0,0 +1,296 @@ +import type { CapturePolicy } from "./types.js"; + +export type CaptureScopeDecisionReason = + | "allowed" + | "missing-policy" + | "tab-mismatch" + | "consent-invalid" + | "consent-expired" + | "frame-context-invalid" + | "subframes-disabled" + | "opaque-or-invalid-url" + | "origin-changed" + | "origin-denied" + | "origin-not-allowed" + | "url-excluded"; + +export type CaptureScopeContext = { + url: string | null | undefined; + tabId?: number; + frameId?: number; + topLevel: boolean; + now?: number; +}; + +export type CaptureScopeDecision = { + allowed: boolean; + reason: CaptureScopeDecisionReason; + origin: string | null; +}; + +/** + * Evaluates a document against the complete capture scope. Invalid, opaque, or + * ambiguous inputs are denied so all runtimes can use the same fail-closed + * boundary at start, navigation, injection, and event ingestion. + */ +export function evaluateCaptureScope( + policy: CapturePolicy | null | undefined, + context: CaptureScopeContext +): CaptureScopeDecision { + if (!policy) { + return denied("missing-policy"); + } + + const now = normalizeNow(context.now); + const consentReason = validateConsentWindow(policy, now); + + if (consentReason) { + return denied(consentReason); + } + + const frameId = normalizeFrameId(context.frameId); + + if (frameId === null) { + return denied("frame-context-invalid"); + } + + if (context.topLevel !== (frameId === 0)) { + return denied("frame-context-invalid"); + } + + if ( + typeof context.tabId === "number" && + Number.isFinite(context.tabId) && + policy.scope.tabId > 0 && + Math.floor(context.tabId) !== policy.scope.tabId + ) { + return denied("tab-mismatch"); + } + + if ((!context.topLevel || frameId > 0) && !policy.scope.includeSubframes) { + return denied("subframes-disabled"); + } + + const parsedUrl = parseCaptureUrl(context.url); + + if (!parsedUrl) { + return denied("opaque-or-invalid-url"); + } + + const origin = parsedUrl.origin; + const scopeOrigin = normalizeOrigin(policy.scope.origin); + + if (context.topLevel && policy.scope.stopOnOriginChange && scopeOrigin !== null) { + if (origin !== scopeOrigin) { + return denied("origin-changed", origin); + } + } + + if (policy.scope.deniedOrigins.some((pattern) => matchesCaptureOrigin(origin, pattern))) { + return denied("origin-denied", origin); + } + + if ( + policy.scope.allowedOrigins.length > 0 && + !policy.scope.allowedOrigins.some((pattern) => matchesCaptureOrigin(origin, pattern)) + ) { + return denied("origin-not-allowed", origin); + } + + if ( + policy.scope.excludedUrlPatterns.some((pattern) => matchesCaptureUrlPattern(parsedUrl, pattern)) + ) { + return denied("url-excluded", origin); + } + + return { + allowed: true, + reason: "allowed", + origin + }; +} + +/** Matches exact origins plus the wildcard forms used by enterprise policy. */ +export function matchesCaptureOrigin(origin: string, rawPattern: string): boolean { + const pattern = rawPattern.trim(); + + if (!pattern) { + return false; + } + + if (origin === normalizeOrigin(pattern)) { + return true; + } + + let originUrl: URL; + + try { + originUrl = new URL(origin); + } catch { + return false; + } + + if (pattern.startsWith("*.")) { + return matchesHostSuffix(originUrl.hostname, pattern.slice(2)); + } + + const schemeHostWildcard = /^([a-z][a-z\d+.-]*:\/\/)\*\.(.+)$/i.exec(pattern); + + if (schemeHostWildcard) { + const [, scheme, suffixWithPort] = schemeHostWildcard; + + if (!scheme || !suffixWithPort || originUrl.protocol + "//" !== scheme.toLowerCase()) { + return false; + } + + const [suffix, port] = splitHostAndPort(suffixWithPort); + + return ( + matchesHostSuffix(originUrl.hostname, suffix) && + (port === undefined || originUrl.port === port) + ); + } + + return pattern.includes("*") && wildcardMatch(origin, pattern); +} + +function validateConsentWindow( + policy: CapturePolicy, + now: number +): "consent-invalid" | "consent-expired" | null { + const grantedAt = Date.parse(policy.consent.grantedAt); + + if (!Number.isFinite(grantedAt) || grantedAt > now) { + return "consent-invalid"; + } + + if (policy.consent.expiresAt === undefined) { + return null; + } + + const expiresAt = Date.parse(policy.consent.expiresAt); + + if (!Number.isFinite(expiresAt) || expiresAt <= grantedAt) { + return "consent-invalid"; + } + + return now >= expiresAt ? "consent-expired" : null; +} + +function parseCaptureUrl(value: string | null | undefined): URL | null { + if (typeof value !== "string" || value.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(value); + + if (parsed.origin === "null") { + return null; + } + + return parsed; + } catch { + return null; + } +} + +function normalizeOrigin(value: string): string | null { + const parsed = parseCaptureUrl(value); + return parsed?.origin ?? null; +} + +function matchesCaptureUrlPattern(url: URL, rawPattern: string): boolean { + const pattern = rawPattern.trim(); + + if (!pattern) { + return false; + } + + if (pattern.startsWith("/")) { + return wildcardMatch(`${url.pathname}${url.search}${url.hash}`, pattern); + } + + return wildcardMatch(url.href, pattern) || wildcardMatch(url.origin + url.pathname, pattern); +} + +function matchesHostSuffix(hostname: string, rawSuffix: string): boolean { + const suffix = rawSuffix + .trim() + .toLowerCase() + .replace(/^\.+|\.+$/g, ""); + const host = hostname.toLowerCase(); + + return suffix.length > 0 && (host === suffix || host.endsWith(`.${suffix}`)); +} + +function splitHostAndPort(value: string): [string, string | undefined] { + const separator = value.lastIndexOf(":"); + + if (separator <= 0 || !/^\d+$/.test(value.slice(separator + 1))) { + return [value, undefined]; + } + + return [value.slice(0, separator), value.slice(separator + 1)]; +} + +function wildcardMatch(value: string, pattern: string): boolean { + let valueIndex = 0; + let patternIndex = 0; + let wildcardIndex = -1; + let wildcardValueIndex = -1; + + while (valueIndex < value.length) { + if (patternIndex < pattern.length && pattern[patternIndex] === value[valueIndex]) { + valueIndex += 1; + patternIndex += 1; + continue; + } + + if (patternIndex < pattern.length && pattern[patternIndex] === "*") { + wildcardIndex = patternIndex; + wildcardValueIndex = valueIndex; + patternIndex += 1; + continue; + } + + if (wildcardIndex >= 0) { + patternIndex = wildcardIndex + 1; + wildcardValueIndex += 1; + valueIndex = wildcardValueIndex; + continue; + } + + return false; + } + + while (patternIndex < pattern.length && pattern[patternIndex] === "*") { + patternIndex += 1; + } + + return patternIndex === pattern.length; +} + +function normalizeFrameId(value: number | undefined): number | null { + if (value === undefined) { + return 0; + } + + if (!Number.isFinite(value) || value < 0) { + return null; + } + + return Math.floor(value); +} + +function normalizeNow(value: number | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : Date.now(); +} + +function denied(reason: CaptureScopeDecisionReason, origin: string | null = null) { + return { + allowed: false, + reason, + origin + } satisfies CaptureScopeDecision; +} diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 8706657..fea39ae 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,5 +1,6 @@ export * from "./blob.js"; export * from "./archive-validation.js"; +export * from "./capture-scope.js"; export * from "./constants.js"; export * from "./defaults.js"; export * from "./ids.js"; diff --git a/packages/webblackbox/src/lite-capture-agent.test.ts b/packages/webblackbox/src/lite-capture-agent.test.ts index b4d04f3..58aa0d8 100644 --- a/packages/webblackbox/src/lite-capture-agent.test.ts +++ b/packages/webblackbox/src/lite-capture-agent.test.ts @@ -34,6 +34,14 @@ const MASKED_SCREENSHOT_TEST_CAPTURE_POLICY: CapturePolicy = { } }; +const SUBFRAME_CAPTURE_POLICY: CapturePolicy = { + ...SCREENSHOT_TEST_CAPTURE_POLICY, + scope: { + ...SCREENSHOT_TEST_CAPTURE_POLICY.scope, + includeSubframes: true + } +}; + function capturePolicyWithInputs(inputs: CapturePolicy["categories"]["inputs"]): CapturePolicy { return { ...DEFAULT_CAPTURE_POLICY, @@ -266,6 +274,25 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("does not capture root screenshots containing an out-of-scope child frame", async () => { + document.body.insertAdjacentHTML( + "beforeend", + '' + ); + const { agent } = createAgent({ + capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY, + sampling: { + screenshotIdleMs: 1_000 + } + }); + + clickTarget(); + await vi.advanceTimersByTimeAsync(3_000); + + expect(snapdomToBlobMock).not.toHaveBeenCalled(); + agent.dispose(); + }); + it("captures a deferred start screenshot when screenshot sampling is enabled", async () => { const { agent } = createAgent({ capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY, @@ -524,7 +551,7 @@ describe("LiteCaptureAgent", () => { it("keeps child-frame capture lightweight", async () => { const { agent, emitBatch } = createAgent( { - capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY, + capturePolicy: SUBFRAME_CAPTURE_POLICY, sampling: { screenshotIdleMs: 1_000 } @@ -559,6 +586,61 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("does not install or emit capture from a child frame when subframes are disabled", () => { + const { agent, emitBatch } = createAgent( + { + capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY + }, + { + frameScope: "child" + } + ); + + clickTarget(); + agent.flush(); + + expect(emitBatch).not.toHaveBeenCalled(); + agent.dispose(); + }); + + it("fails closed when the current document URL is excluded", () => { + const excludedPolicy: CapturePolicy = { + ...DEFAULT_CAPTURE_POLICY, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + excludedUrlPatterns: ["*"] + } + }; + const { agent, emitBatch } = createAgent({ capturePolicy: excludedPolicy }); + + clickTarget(); + agent.flush(); + + expect(emitBatch).not.toHaveBeenCalled(); + agent.dispose(); + }); + + it("tears down capture when consent expires", async () => { + vi.setSystemTime(new Date("2026-07-11T00:00:00.000Z")); + const expiringPolicy: CapturePolicy = { + ...DEFAULT_CAPTURE_POLICY, + consent: { + ...DEFAULT_CAPTURE_POLICY.consent, + grantedAt: "2026-07-10T00:00:00.000Z", + expiresAt: "2026-07-11T00:00:01.000Z" + } + }; + const { agent, emitBatch } = createAgent({ capturePolicy: expiringPolicy }); + + await vi.advanceTimersByTimeAsync(1_001); + emitBatch.mockClear(); + clickTarget(); + agent.flush(); + + expect(emitBatch).not.toHaveBeenCalled(); + agent.dispose(); + }); + it("emits a counts-only localStorage snapshot when stopping before idle storage capture runs", () => { localStorage.setItem("demo", "local-storage-secret-token"); const { agent, emitBatch } = createAgent(); diff --git a/packages/webblackbox/src/lite-capture-agent.ts b/packages/webblackbox/src/lite-capture-agent.ts index 4ed8547..5a22cd9 100644 --- a/packages/webblackbox/src/lite-capture-agent.ts +++ b/packages/webblackbox/src/lite-capture-agent.ts @@ -1,5 +1,6 @@ import { DEFAULT_CAPTURE_POLICY, + evaluateCaptureScope, sanitizeUrlForPrivacy, type CapturePolicy } from "@webblackbox/protocol"; @@ -201,6 +202,7 @@ export class LiteCaptureAgent { private trailingScrollTimer = 0; private mutationFlushTimer = 0; private flushTimer = 0; + private scopeExpiryTimer = 0; private lastScrollTime = 0; private lastPointerTime = Number.NEGATIVE_INFINITY; private screenshotInFlight = false; @@ -250,6 +252,10 @@ export class LiteCaptureAgent { } const wasRecording = this.recordingActive; + const nextPolicy = state.capturePolicy ?? this.capturePolicy; + const nextActive = state.active && this.isDocumentWithinScope(nextPolicy); + + this.capturePolicy = nextPolicy; if (state.active && !wasRecording) { this.hasDomSnapshot = false; @@ -270,10 +276,9 @@ export class LiteCaptureAgent { this.emitLocalStorageSnapshot("stop"); } - this.recordingActive = state.active; + this.recordingActive = nextActive; this.mode = state.mode ?? this.mode; this.sampling = sanitizeSamplingConfig(state.sampling); - this.capturePolicy = state.capturePolicy ?? this.capturePolicy; if (typeof state.sid === "string") { this.sid = state.sid; @@ -284,6 +289,7 @@ export class LiteCaptureAgent { } if (this.recordingActive) { + this.scheduleScopeExpiry(); this.ensureCaptureInstalled(); if (!wasRecording) { @@ -295,6 +301,7 @@ export class LiteCaptureAgent { return; } + this.clearScopeExpiryTimer(); this.stopMutationAndSnapshots(); this.teardownCapture(); this.removeIndicator(); @@ -342,7 +349,8 @@ export class LiteCaptureAgent { /** Completes any in-flight screenshot and captures one final frame if none was recorded yet. */ public async prepareStopCapture(): Promise { - if (this.disposed || !this.recordingActive) { + if (this.disposed || !this.recordingActive || !this.isDocumentWithinScope()) { + this.deactivateForScope(); return; } @@ -368,6 +376,7 @@ export class LiteCaptureAgent { } this.disposed = true; + this.clearScopeExpiryTimer(); this.stopMutationAndSnapshots(); this.removeIndicator(); @@ -854,6 +863,8 @@ export class LiteCaptureAgent { return ( this.mode !== "full" && this.isTopLevelFrame && + this.isDocumentWithinScope() && + !hasDisallowedEmbeddedFrame(this.capturePolicy) && this.sampling.screenshotIdleMs > 0 && this.capturePolicy.categories.screenshots === "allow" ); @@ -861,13 +872,19 @@ export class LiteCaptureAgent { private shouldCaptureMutationSignals(): boolean { return ( - this.mode !== "full" && this.isTopLevelFrame && this.capturePolicy.categories.dom !== "off" + this.mode !== "full" && + this.isTopLevelFrame && + this.isDocumentWithinScope() && + this.capturePolicy.categories.dom !== "off" ); } private shouldCaptureDomSnapshots(): boolean { return ( - this.mode !== "full" && this.isTopLevelFrame && this.capturePolicy.categories.dom !== "off" + this.mode !== "full" && + this.isTopLevelFrame && + this.isDocumentWithinScope() && + this.capturePolicy.categories.dom !== "off" ); } @@ -875,6 +892,7 @@ export class LiteCaptureAgent { return ( this.mode !== "full" && this.isTopLevelFrame && + this.isDocumentWithinScope() && (this.capturePolicy.categories.storage !== "off" || this.capturePolicy.categories.indexedDb !== "off" || this.capturePolicy.categories.cookies !== "off") @@ -1845,6 +1863,11 @@ export class LiteCaptureAgent { return; } + if (this.recordingActive && !this.isDocumentWithinScope()) { + this.deactivateForScope(); + return; + } + if (!this.recordingActive) { if (shouldBufferBeforeRecording(event)) { this.preRecordingBuffer.push(event); @@ -1870,6 +1893,66 @@ export class LiteCaptureAgent { ); } + private isDocumentWithinScope(policy: CapturePolicy = this.capturePolicy): boolean { + return evaluateCaptureScope(policy, { + url: readDocumentUrl(), + topLevel: this.isTopLevelFrame, + frameId: this.isTopLevelFrame ? 0 : 1 + }).allowed; + } + + private scheduleScopeExpiry(): void { + this.clearScopeExpiryTimer(); + + const expiresAt = this.capturePolicy.consent.expiresAt; + + if (!expiresAt) { + return; + } + + const delay = Date.parse(expiresAt) - Date.now(); + + if (!Number.isFinite(delay) || delay <= 0) { + this.deactivateForScope(); + return; + } + + this.scopeExpiryTimer = window.setTimeout( + () => { + this.scopeExpiryTimer = 0; + + if (this.isDocumentWithinScope()) { + this.scheduleScopeExpiry(); + return; + } + + this.deactivateForScope(); + }, + Math.min(delay, 2_147_000_000) + ); + } + + private clearScopeExpiryTimer(): void { + if (this.scopeExpiryTimer > 0) { + clearTimeout(this.scopeExpiryTimer); + this.scopeExpiryTimer = 0; + } + } + + private deactivateForScope(): void { + if (!this.recordingActive) { + return; + } + + this.recordingActive = false; + this.clearScopeExpiryTimer(); + this.stopMutationAndSnapshots(); + this.teardownCapture(); + this.removeIndicator(); + this.flush(); + this.preRecordingBuffer.length = 0; + } + private createClickPayload(event: MouseEvent): Record { const navigationTarget = resolveNavigationTarget(event.target); @@ -2225,6 +2308,50 @@ function monotonicTime(): number { return performance.timeOrigin + performance.now(); } +function readDocumentUrl(): string | null { + try { + return window.location.href; + } catch { + return null; + } +} + +function hasDisallowedEmbeddedFrame(policy: CapturePolicy): boolean { + const frames = document.querySelectorAll("iframe, frame"); + + for (const frame of frames) { + if (!policy.scope.includeSubframes) { + return true; + } + + let frameUrl: string; + + try { + const contentWindow = (frame as HTMLIFrameElement | HTMLFrameElement).contentWindow; + + if (!contentWindow) { + return true; + } + + frameUrl = contentWindow.location.href; + } catch { + return true; + } + + if ( + !evaluateCaptureScope(policy, { + url: frameUrl, + topLevel: false, + frameId: 1 + }).allowed + ) { + return true; + } + } + + return false; +} + function resolveContentFrameContext(scope: LiteCaptureAgentOptions["frameScope"] = "auto"): { marker: string | undefined; isTopLevel: boolean; From 57ba5dca69966c76b2d3947968ecb99d4c9cbfb5 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:12:35 +0800 Subject: [PATCH 028/181] ci(release): verify immutable tagged release sources --- .github/workflows/release-assets.yml | 53 ++++++++++++- .github/workflows/release.yml | 53 +++++++++++-- package.json | 1 + scripts/verify-release-ref.mjs | 110 +++++++++++++++++++++++++++ scripts/verify-release-ref.test.mjs | 88 +++++++++++++++++++++ 5 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 scripts/verify-release-ref.mjs create mode 100644 scripts/verify-release-ref.test.mjs diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 107c976..173ea49 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -11,12 +11,58 @@ on: required: true type: string -permissions: - contents: write +permissions: {} jobs: + verify-release: + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + steps: + - name: Checkout released ref + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + - name: Verify immutable release source + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + node scripts/verify-release-ref.mjs "$RELEASE_TAG" origin/main + + - name: Verify successful CI for release commit + env: + GH_TOKEN: ${{ github.token }} + run: | + release_sha=$(git rev-parse HEAD) + successful_runs=$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow ci.yml \ + --commit "$release_sha" \ + --branch main \ + --event push \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq 'length') + if [ "$successful_runs" -ne 1 ]; then + echo "No successful CI workflow found for $release_sha." >&2 + exit 1 + fi + chrome-extension: runs-on: ubuntu-latest + needs: verify-release + permissions: + contents: write env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} steps: @@ -79,6 +125,9 @@ jobs: player-pages: runs-on: ubuntu-latest + needs: verify-release + permissions: + contents: write env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} PLAYER_SITE_URL: https://webllm.github.io/webblackbox/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f96f131..7087756 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,29 +6,57 @@ on: - published workflow_dispatch: inputs: - ref: - description: Git ref, tag, or SHA to publish from (defaults to the selected branch/ref) - required: false + tag: + description: Exact semantic version tag to publish (for example v1.2.3) + required: true type: string permissions: + actions: read contents: read id-token: write concurrency: - group: release-${{ github.event_name == 'workflow_dispatch' && (inputs.ref || github.ref) || github.event.release.tag_name }} + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} cancel-in-progress: false jobs: publish: runs-on: ubuntu-latest timeout-minutes: 20 + env: + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} steps: - name: Checkout uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && (inputs.ref || github.ref) || github.event.release.tag_name }} + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + + - name: Verify immutable release source + run: | + git fetch --no-tags origin main:refs/remotes/origin/main + node scripts/verify-release-ref.mjs "$RELEASE_TAG" origin/main + + - name: Verify successful CI for release commit + env: + GH_TOKEN: ${{ github.token }} + run: | + release_sha=$(git rev-parse HEAD) + successful_runs=$(gh run list \ + --repo "$GITHUB_REPOSITORY" \ + --workflow ci.yml \ + --commit "$release_sha" \ + --branch main \ + --event push \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq 'length') + if [ "$successful_runs" -ne 1 ]; then + echo "No successful CI workflow found for $release_sha." >&2 + exit 1 + fi - name: Setup pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -45,9 +73,22 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Production dependency audit + run: pnpm audit --prod --audit-level=high + + - name: Verify source quality + run: | + pnpm format:check + pnpm lint + pnpm typecheck + pnpm test + - name: Build workspace run: pnpm build + - name: Verify bundle budgets + run: pnpm bundle:size + - name: Verify publish artifacts run: > pnpm @@ -71,5 +112,5 @@ jobs: echo "## NPM Publish Triggered" echo "" echo "- Source: \`${{ github.event_name }}\`" - echo "- Ref: \`${{ github.event_name == 'workflow_dispatch' && (inputs.ref || github.ref) || github.event.release.tag_name }}\`" + echo "- Ref: \`$RELEASE_TAG\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index cafffa7..db024fd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "changeset": "changeset", "version-packages": "changeset version", "release": "changeset publish", + "release:verify-ref:test": "node --test scripts/verify-release-ref.test.mjs", "commit": "cz", "prepare": "husky" }, diff --git a/scripts/verify-release-ref.mjs b/scripts/verify-release-ref.mjs new file mode 100644 index 0000000..daed51d --- /dev/null +++ b/scripts/verify-release-ref.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node + +import { execFileSync, spawnSync } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const releaseTagPattern = + /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +export async function verifyReleaseRef({ root, tag, mainRef = "origin/main" }) { + if (!releaseTagPattern.test(tag)) { + throw new Error(`Release tag must be an exact semantic version tag (vX.Y.Z): ${tag}`); + } + + const tagRef = `refs/tags/${tag}^{commit}`; + const tagCommit = runGit(root, ["rev-parse", "--verify", tagRef]); + const headCommit = runGit(root, ["rev-parse", "HEAD"]); + + if (tagCommit !== headCommit) { + throw new Error(`Checked-out commit ${headCommit} does not match ${tag} (${tagCommit})`); + } + + const ancestor = spawnSync("git", ["merge-base", "--is-ancestor", tagCommit, mainRef], { + cwd: root, + encoding: "utf8" + }); + if (ancestor.status !== 0) { + throw new Error(`Release commit ${tagCommit} is not contained in ${mainRef}`); + } + + const expectedVersion = tag.slice(1); + const packages = await readPublicPackages(root); + if (packages.length === 0) { + throw new Error("No public packages were found for release verification"); + } + + const mismatches = packages.filter((entry) => entry.version !== expectedVersion); + if (mismatches.length > 0) { + throw new Error( + `Public package versions must match ${tag}: ${mismatches + .map((entry) => `${entry.name}@${entry.version}`) + .join(", ")}` + ); + } + + return { + tag, + commit: tagCommit, + version: expectedVersion, + packages: packages.map((entry) => entry.name).sort() + }; +} + +async function readPublicPackages(root) { + const packages = []; + for (const parent of ["packages", "apps"]) { + const parentPath = resolve(root, parent); + const entries = await readdir(parentPath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + + const packagePath = resolve(parentPath, entry.name, "package.json"); + let manifest; + try { + manifest = JSON.parse(await readFile(packagePath, "utf8")); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { + continue; + } + throw error; + } + + if (manifest.private === true) { + continue; + } + if (typeof manifest.name !== "string" || typeof manifest.version !== "string") { + throw new Error(`Invalid public package manifest: ${packagePath}`); + } + packages.push({ name: manifest.name, version: manifest.version }); + } + } + return packages; +} + +function runGit(root, args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }).trim(); +} + +const scriptPath = fileURLToPath(import.meta.url); +if (process.argv[1] && resolve(process.argv[1]) === scriptPath) { + const root = resolve(dirname(scriptPath), ".."); + const tag = process.argv[2] ?? ""; + const mainRef = process.argv[3] ?? "origin/main"; + + verifyReleaseRef({ root, tag, mainRef }) + .then((result) => { + console.log(JSON.stringify({ ok: true, ...result }, null, 2)); + }) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-release-ref.test.mjs b/scripts/verify-release-ref.test.mjs new file mode 100644 index 0000000..c74a683 --- /dev/null +++ b/scripts/verify-release-ref.test.mjs @@ -0,0 +1,88 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { verifyReleaseRef } from "./verify-release-ref.mjs"; + +test("accepts an exact release tag on the main history with matching package versions", async () => { + const root = await createRepository("1.2.3"); + git(root, "tag", "v1.2.3"); + + const result = await verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }); + + assert.equal(result.version, "1.2.3"); + assert.deepEqual(result.packages, ["@example/library"]); +}); + +test("rejects mutable or malformed release refs", async () => { + const root = await createRepository("1.2.3"); + + await assert.rejects( + verifyReleaseRef({ root, tag: "main", mainRef: "main" }), + /exact semantic version tag/ + ); +}); + +test("rejects a checkout that is newer than the requested tag", async () => { + const root = await createRepository("1.2.3"); + git(root, "tag", "v1.2.3"); + await writeFile(resolve(root, "after-tag.txt"), "newer\n"); + git(root, "add", "."); + git(root, "commit", "-m", "after tag"); + + await assert.rejects( + verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }), + /does not match/ + ); +}); + +test("rejects a tagged commit outside the main branch history", async () => { + const root = await createRepository("1.2.3"); + git(root, "checkout", "-b", "release-candidate"); + await writeFile(resolve(root, "candidate.txt"), "candidate\n"); + git(root, "add", "."); + git(root, "commit", "-m", "candidate"); + git(root, "tag", "v1.2.3"); + + await assert.rejects( + verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }), + /not contained in main/ + ); +}); + +test("rejects tags whose public package versions do not match", async () => { + const root = await createRepository("1.2.4"); + git(root, "tag", "v1.2.3"); + + await assert.rejects( + verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }), + /Public package versions must match/ + ); +}); + +async function createRepository(version) { + const root = await mkdtemp(resolve(tmpdir(), "webblackbox-release-ref-")); + await mkdir(resolve(root, "packages", "library"), { recursive: true }); + await mkdir(resolve(root, "apps"), { recursive: true }); + await writeFile( + resolve(root, "packages", "library", "package.json"), + `${JSON.stringify({ name: "@example/library", version }, null, 2)}\n` + ); + git(root, "init", "--initial-branch=main"); + git(root, "config", "user.name", "Release Test"); + git(root, "config", "user.email", "release-test@example.invalid"); + git(root, "add", "."); + git(root, "commit", "-m", "initial"); + return root; +} + +function git(root, ...args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }).trim(); +} From d810206eda66d0425ef0962550fd5c05fcb8b32e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:14:02 +0800 Subject: [PATCH 029/181] fix(player): verify exact Pages deployment commit --- apps/player/README.md | 3 +- apps/player/scripts/deploy-gh-pages.mjs | 53 +++++------- apps/player/scripts/lib/pages-deployment.mjs | 81 +++++++++++++++++++ .../scripts/lib/pages-deployment.test.mjs | 59 ++++++++++++++ 4 files changed, 160 insertions(+), 36 deletions(-) create mode 100644 apps/player/scripts/lib/pages-deployment.mjs create mode 100644 apps/player/scripts/lib/pages-deployment.test.mjs diff --git a/apps/player/README.md b/apps/player/README.md index 423558f..a5461de 100644 --- a/apps/player/README.md +++ b/apps/player/README.md @@ -61,7 +61,8 @@ The deploy script will: - build the Player - prepare the Pages artifact - publish `apps/player/build` to the `gh-pages` branch -- verify `https://webllm.github.io/webblackbox/` is serving the Player +- publish a commit-bound `deployment.json` marker +- verify `https://webllm.github.io/webblackbox/` serves that exact source commit From the repo root you can also run: diff --git a/apps/player/scripts/deploy-gh-pages.mjs b/apps/player/scripts/deploy-gh-pages.mjs index 5fb0491..8c5ff01 100644 --- a/apps/player/scripts/deploy-gh-pages.mjs +++ b/apps/player/scripts/deploy-gh-pages.mjs @@ -4,6 +4,8 @@ import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; +import { createDeploymentMarker, waitForSiteDeployment } from "./lib/pages-deployment.mjs"; + const scriptDir = dirname(fileURLToPath(import.meta.url)); const appRoot = resolve(scriptDir, ".."); const workspaceRoot = resolve(appRoot, "..", ".."); @@ -15,6 +17,12 @@ const branchName = readFlagValue(args, "--branch") ?? "gh-pages"; const siteUrl = readFlagValue(args, "--site-url") ?? "https://webllm.github.io/webblackbox/"; const skipBuild = args.includes("--skip-build"); const skipVerify = args.includes("--skip-verify"); +const sourceCommit = ( + await runCommand("git", ["rev-parse", "HEAD"], workspaceRoot, { + captureStdout: true + }) +).trim(); +const deploymentMarker = createDeploymentMarker(sourceCommit, process.env.RELEASE_TAG); const commitMessage = readFlagValue(args, "--message") ?? `Deploy player to ${branchName}${await resolveHeadSuffix()}`; @@ -71,6 +79,11 @@ try { await clearDirectoryExceptGit(repoDir); await copyDirectory(buildDir, repoDir); await writeFile(resolve(repoDir, ".nojekyll"), "", "utf8"); + await writeFile( + resolve(repoDir, "deployment.json"), + `${JSON.stringify(deploymentMarker, null, 2)}\n`, + "utf8" + ); await runCommand("git", ["add", "-A"], repoDir); @@ -98,7 +111,10 @@ try { } if (!skipVerify) { - await waitForSite(siteUrl, 180_000); + await waitForSiteDeployment({ + siteUrl, + expectedCommit: sourceCommit + }); } console.info( @@ -109,6 +125,7 @@ try { remote: remoteName, branch: branchName, siteUrl, + sourceCommit, verified: !skipVerify }, null, @@ -237,40 +254,6 @@ function normalizeGitHubHttpsRemote(remoteUrl) { } } -async function waitForSite(url, timeoutMs) { - const startedAt = Date.now(); - let lastStatus = "unknown"; - - while (Date.now() - startedAt < timeoutMs) { - try { - const response = await fetch(url, { - redirect: "follow", - headers: { - "cache-control": "no-cache" - } - }); - const body = await response.text(); - lastStatus = `${response.status}`; - - if (response.ok && body.includes("WebBlackbox Player")) { - return; - } - } catch (error) { - lastStatus = error instanceof Error ? error.message : String(error); - } - - await sleep(5_000); - } - - throw new Error(`Timed out waiting for ${url} to serve the player. Last status: ${lastStatus}`); -} - -function sleep(ms) { - return new Promise((resolvePromise) => { - setTimeout(resolvePromise, ms); - }); -} - function readFlagValue(argv, flagName) { const inline = argv.find((entry) => entry.startsWith(`${flagName}=`)); diff --git a/apps/player/scripts/lib/pages-deployment.mjs b/apps/player/scripts/lib/pages-deployment.mjs new file mode 100644 index 0000000..800e289 --- /dev/null +++ b/apps/player/scripts/lib/pages-deployment.mjs @@ -0,0 +1,81 @@ +export function createDeploymentMarker(commit, tag = null) { + if (!/^[0-9a-f]{40}$/.test(commit)) { + throw new Error(`Invalid deployment commit: ${commit}`); + } + + return { + schemaVersion: 1, + commit, + tag: typeof tag === "string" && tag.length > 0 ? tag : null + }; +} + +export async function waitForSiteDeployment({ + siteUrl, + expectedCommit, + timeoutMs = 180_000, + pollIntervalMs = 5_000, + fetchImplementation = fetch +}) { + const startedAt = Date.now(); + let lastStatus = "unknown"; + + while (Date.now() - startedAt < timeoutMs) { + const cacheBuster = `${expectedCommit}-${Date.now()}`; + const pageUrl = new URL(siteUrl); + pageUrl.searchParams.set("deployment", cacheBuster); + const markerUrl = new URL("deployment.json", ensureTrailingSlash(siteUrl)); + markerUrl.searchParams.set("deployment", cacheBuster); + + try { + const [pageResponse, markerResponse] = await Promise.all([ + fetchImplementation(pageUrl, requestOptions), + fetchImplementation(markerUrl, requestOptions) + ]); + const [pageBody, markerBody] = await Promise.all([ + pageResponse.text(), + markerResponse.json() + ]); + const markerCommit = + markerBody && typeof markerBody === "object" && typeof markerBody.commit === "string" + ? markerBody.commit + : "missing"; + + lastStatus = `page=${pageResponse.status} marker=${markerResponse.status} commit=${markerCommit}`; + if ( + pageResponse.ok && + markerResponse.ok && + pageBody.includes("WebBlackbox Player") && + markerCommit === expectedCommit + ) { + return; + } + } catch (error) { + lastStatus = error instanceof Error ? error.message : String(error); + } + + await sleep(pollIntervalMs); + } + + throw new Error( + `Timed out waiting for ${siteUrl} to serve commit ${expectedCommit}. Last status: ${lastStatus}` + ); +} + +const requestOptions = { + cache: "no-store", + redirect: "follow", + headers: { + "cache-control": "no-cache" + } +}; + +function ensureTrailingSlash(url) { + return url.endsWith("/") ? url : `${url}/`; +} + +function sleep(ms) { + return new Promise((resolvePromise) => { + setTimeout(resolvePromise, ms); + }); +} diff --git a/apps/player/scripts/lib/pages-deployment.test.mjs b/apps/player/scripts/lib/pages-deployment.test.mjs new file mode 100644 index 0000000..e964d32 --- /dev/null +++ b/apps/player/scripts/lib/pages-deployment.test.mjs @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createDeploymentMarker, waitForSiteDeployment } from "./pages-deployment.mjs"; + +const expectedCommit = "a".repeat(40); + +describe("Pages deployment verification", () => { + it("creates a deterministic marker bound to the source commit", () => { + expect(createDeploymentMarker(expectedCommit, "v1.2.3")).toEqual({ + schemaVersion: 1, + commit: expectedCommit, + tag: "v1.2.3" + }); + }); + + it("waits until the deployed marker matches instead of accepting stale HTML", async () => { + let markerRequests = 0; + const fetchImplementation = vi.fn(async (url) => { + if (new URL(url).pathname.endsWith("deployment.json")) { + markerRequests += 1; + return Response.json({ + schemaVersion: 1, + commit: markerRequests === 1 ? "b".repeat(40) : expectedCommit, + tag: "v1.2.3" + }); + } + return new Response("WebBlackbox Player"); + }); + + await waitForSiteDeployment({ + siteUrl: "https://example.test/webblackbox/", + expectedCommit, + timeoutMs: 100, + pollIntervalMs: 0, + fetchImplementation + }); + + expect(markerRequests).toBe(2); + }); + + it("fails when the site never serves the expected commit", async () => { + const fetchImplementation = vi.fn(async (url) => { + if (new URL(url).pathname.endsWith("deployment.json")) { + return Response.json({ commit: "b".repeat(40) }); + } + return new Response("WebBlackbox Player"); + }); + + await expect( + waitForSiteDeployment({ + siteUrl: "https://example.test/webblackbox/", + expectedCommit, + timeoutMs: 10, + pollIntervalMs: 0, + fetchImplementation + }) + ).rejects.toThrow(`serve commit ${expectedCommit}`); + }); +}); From 9a45b91861031c70a480a21d88e4aa22eefb268e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:14:59 +0800 Subject: [PATCH 030/181] fix(packages): rebuild artifacts before packing --- packages/cdp-router/package.json | 1 + packages/pipeline/package.json | 1 + packages/player-sdk/package.json | 1 + packages/protocol/package.json | 1 + packages/recorder/package.json | 1 + packages/webblackbox/package.json | 1 + 6 files changed, 6 insertions(+) diff --git a/packages/cdp-router/package.json b/packages/cdp-router/package.json index 4d2cdfc..0383447 100644 --- a/packages/cdp-router/package.json +++ b/packages/cdp-router/package.json @@ -40,6 +40,7 @@ "scripts": { "dev": "tsup src/index.ts --format esm --dts --watch --clean", "build": "tsup src/index.ts --format esm --dts --clean", + "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests" diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index ee94df2..a7d1020 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -40,6 +40,7 @@ "scripts": { "dev": "tsup src/index.ts --format esm --dts --watch --clean", "build": "tsup src/index.ts --format esm --dts --clean", + "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests", diff --git a/packages/player-sdk/package.json b/packages/player-sdk/package.json index dde0fd9..ecb01fe 100644 --- a/packages/player-sdk/package.json +++ b/packages/player-sdk/package.json @@ -40,6 +40,7 @@ "scripts": { "dev": "tsup src/index.ts --format esm --dts --watch --clean", "build": "tsup src/index.ts --format esm --dts --clean", + "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index c97cf30..aad1bdb 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -40,6 +40,7 @@ "scripts": { "dev": "tsup src/index.ts --format esm --dts --watch --clean", "build": "tsup src/index.ts --format esm --dts --clean", + "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests" diff --git a/packages/recorder/package.json b/packages/recorder/package.json index 26ce60f..18ce233 100644 --- a/packages/recorder/package.json +++ b/packages/recorder/package.json @@ -40,6 +40,7 @@ "scripts": { "dev": "tsup src/index.ts --format esm --dts --watch --clean", "build": "tsup src/index.ts --format esm --dts --clean", + "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests", diff --git a/packages/webblackbox/package.json b/packages/webblackbox/package.json index 292007e..d1f27d0 100644 --- a/packages/webblackbox/package.json +++ b/packages/webblackbox/package.json @@ -61,6 +61,7 @@ "scripts": { "dev": "tsup src/index.ts src/injected-hooks.ts src/lite-capture-agent.ts src/lite-materializer.ts src/lite-sdk.ts src/types.ts --format esm --dts --watch --clean", "build": "tsup src/index.ts src/injected-hooks.ts src/lite-capture-agent.ts src/lite-materializer.ts src/lite-sdk.ts src/types.ts --format esm --dts --clean", + "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests" From 08c789b2e50d67472c48ebc5e9a85a5e8bffefa4 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:15:41 +0800 Subject: [PATCH 031/181] ci(lint): check workspace automation scripts --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index db024fd..aba2aa0 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "player:pages:build": "pnpm --filter @webblackbox/player pages:build", "player:pages:deploy": "pnpm --filter @webblackbox/player pages:deploy", "player:deploy": "pnpm player:pages:build && pnpm player:pages:deploy", - "lint": "turbo run lint", + "lint": "turbo run lint && pnpm lint:scripts", + "lint:scripts": "eslint \"scripts/**/*.mjs\" \"apps/*/scripts/**/*.mjs\"", "typecheck": "turbo run typecheck", "test": "turbo run test", "coverage:core": "pnpm --filter @webblackbox/recorder test:coverage && pnpm --filter @webblackbox/pipeline test:coverage && pnpm --filter @webblackbox/player-sdk test:coverage", From b5ed29077acc296cdf5d935441fc32af287f4d70 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:15:49 +0800 Subject: [PATCH 032/181] fix(pipeline): serialize chunk finalization --- packages/pipeline/src/chunker.test.ts | 118 ++++++++++++++++++++++++++ packages/pipeline/src/chunker.ts | 113 ++++++++++++++++++++---- packages/pipeline/src/index.test.ts | 72 +++++++++++++++- packages/pipeline/src/pipeline.ts | 95 ++++++++++++--------- 4 files changed, 338 insertions(+), 60 deletions(-) create mode 100644 packages/pipeline/src/chunker.test.ts diff --git a/packages/pipeline/src/chunker.test.ts b/packages/pipeline/src/chunker.test.ts new file mode 100644 index 0000000..f95837b --- /dev/null +++ b/packages/pipeline/src/chunker.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WebBlackboxEvent } from "@webblackbox/protocol"; + +import { EventChunker, type FinalizedChunk } from "./chunker.js"; + +function createEvent(index: number): WebBlackboxEvent { + return { + v: 1, + sid: "S-chunker-concurrency", + tab: 1, + t: index, + mono: index, + type: "user.marker", + id: `E-${index}`, + privacy: { + category: "actions", + sensitivity: "low", + redacted: true + }, + data: { + index + } + }; +} + +function finalized(chunks: Array): FinalizedChunk[] { + return chunks.filter((chunk): chunk is FinalizedChunk => chunk !== null); +} + +describe("EventChunker concurrency", () => { + it("serializes concurrent threshold-crossing appends without duplicates or gaps", async () => { + const chunker = new EventChunker(1, "none"); + const chunks = finalized( + await Promise.all( + Array.from({ length: 40 }, (_, index) => chunker.append(createEvent(index))) + ) + ); + + expect(chunks.map((chunk) => chunk.meta.seq)).toEqual( + Array.from({ length: 40 }, (_, index) => index + 1) + ); + expect(chunks.flatMap((chunk) => chunk.events.map((event) => event.id))).toEqual( + Array.from({ length: 40 }, (_, index) => `E-${index}`) + ); + expect(new Set(chunks.map((chunk) => chunk.meta.chunkId)).size).toBe(40); + }); + + it("preserves invocation order when append and flush calls are interleaved", async () => { + const chunker = new EventChunker(1024 * 1024, "none"); + const chunks = finalized( + await Promise.all([ + chunker.append(createEvent(1)), + chunker.flush(), + chunker.append(createEvent(2)), + chunker.append(createEvent(3)), + chunker.flush() + ]) + ); + + expect(chunks.map((chunk) => chunk.meta.seq)).toEqual([1, 2]); + expect(chunks.map((chunk) => chunk.events.map((event) => event.id))).toEqual([ + ["E-1"], + ["E-2", "E-3"] + ]); + }); + + it("uses close as an ordered barrier and rejects later appends", async () => { + const chunker = new EventChunker(1024 * 1024, "none"); + const first = chunker.append(createEvent(1)); + const second = chunker.append(createEvent(2)); + const closing = chunker.close(); + + await expect(chunker.append(createEvent(3))).rejects.toThrow(/after close\(\) has started/i); + + const chunks = finalized(await Promise.all([first, second, closing])); + + expect(chunks).toHaveLength(1); + expect(chunks[0]?.meta.seq).toBe(1); + expect(chunks[0]?.events.map((event) => event.id)).toEqual(["E-1", "E-2"]); + await expect(chunker.close()).resolves.toBeNull(); + await expect(chunker.flush()).resolves.toBeNull(); + }); + + it("rolls back a failed append finalization without poisoning later operations", async () => { + const commit = vi + .fn<(chunk: FinalizedChunk) => Promise>() + .mockRejectedValueOnce(new Error("simulated commit failure")) + .mockResolvedValue(undefined); + const chunker = new EventChunker(1, "none", commit); + + await expect(chunker.append(createEvent(1))).rejects.toThrow("simulated commit failure"); + + const retried = await chunker.append(createEvent(1)); + + expect(retried?.meta.seq).toBe(1); + expect(retried?.events.map((event) => event.id)).toEqual(["E-1"]); + expect(commit).toHaveBeenCalledTimes(2); + expect(commit.mock.calls.map(([chunk]) => chunk.meta.seq)).toEqual([1, 1]); + }); + + it("retains pending events when close finalization fails and permits close retry", async () => { + const chunker = new EventChunker(1024 * 1024, "none"); + await chunker.append(createEvent(1)); + const digest = vi + .spyOn(globalThis.crypto.subtle, "digest") + .mockRejectedValueOnce(new Error("simulated close failure")); + + await expect(chunker.close()).rejects.toThrow("simulated close failure"); + digest.mockRestore(); + await expect(chunker.append(createEvent(2))).rejects.toThrow(/after close\(\) has started/i); + + const retried = await chunker.close(); + + expect(retried?.meta.seq).toBe(1); + expect(retried?.events.map((event) => event.id)).toEqual(["E-1"]); + }); +}); diff --git a/packages/pipeline/src/chunker.ts b/packages/pipeline/src/chunker.ts index b6feed9..bcd219b 100644 --- a/packages/pipeline/src/chunker.ts +++ b/packages/pipeline/src/chunker.ts @@ -11,6 +11,14 @@ export type FinalizedChunk = { events: WebBlackboxEvent[]; }; +/** + * Persists a finalized chunk while the chunker's exclusive operation queue is held. + * The callback must not call back into this EventChunker. A rejection does not + * advance the sequence: append rolls back its event, while flush/close retain the + * pending buffer, so the failed operation can be retried safely. + */ +export type FinalizedChunkCommit = (chunk: FinalizedChunk) => Promise; + export class EventChunker { private readonly pending: WebBlackboxEvent[] = []; @@ -18,28 +26,76 @@ export class EventChunker { private sequence = 0; + private operationTail: Promise = Promise.resolve(); + + private acceptingEvents = true; + + private closed = false; + + private closePromise: Promise | null = null; + public constructor( private readonly maxChunkBytes: number, - private readonly codec: ChunkCodec + private readonly codec: ChunkCodec, + private readonly commit?: FinalizedChunkCommit ) {} public async append(event: WebBlackboxEvent): Promise { - this.pending.push(event); - this.pendingBytes += estimateEventNdjsonBytes(event); - - if (this.pendingBytes < this.maxChunkBytes) { - return null; + if (!this.acceptingEvents) { + throw new Error("EventChunker cannot append events after close() has started."); } - return this.finalize(); + const eventBytes = estimateEventNdjsonBytes(event); + + return this.enqueue(async () => { + this.pending.push(event); + this.pendingBytes += eventBytes; + + if (this.pendingBytes < this.maxChunkBytes) { + return null; + } + + try { + return await this.finalize(); + } catch (error) { + this.pending.pop(); + this.pendingBytes -= eventBytes; + throw error; + } + }); } public async flush(): Promise { - if (this.pending.length === 0) { + if (this.closed) { return null; } - return this.finalize(); + return this.enqueue(() => this.finalizePending()); + } + + public close(): Promise { + if (this.closed) { + return Promise.resolve(null); + } + + if (this.closePromise) { + return this.closePromise; + } + + this.acceptingEvents = false; + const attempt = this.enqueue(async () => { + const chunk = await this.finalizePending(); + this.closed = true; + return chunk; + }); + const tracked = attempt.finally(() => { + if (this.closePromise === tracked) { + this.closePromise = null; + } + }); + + this.closePromise = tracked; + return tracked; } public restoreSequence(sequence: number): void { @@ -50,23 +106,36 @@ export class EventChunker { this.sequence = Math.floor(sequence); } - private async finalize(): Promise { - this.sequence += 1; + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation); + // A failed operation rejects its own caller but must not poison the queue. + this.operationTail = result.then( + () => undefined, + () => undefined + ); + return result; + } + + private async finalizePending(): Promise { + if (this.pending.length === 0) { + return null; + } + return this.finalize(); + } + + private async finalize(): Promise { const events = [...this.pending]; const encoded = await encodeChunkEvents(events, this.codec); const bytes = encoded.bytes; const first = events[0]; const last = events[events.length - 1]; const hash = await sha256Hex(bytes); - - this.pending.length = 0; - this.pendingBytes = 0; - - return { + const sequence = this.sequence + 1; + const chunk: FinalizedChunk = { meta: { - chunkId: createChunkId(this.sequence), - seq: this.sequence, + chunkId: createChunkId(sequence), + seq: sequence, tStart: first?.t ?? 0, tEnd: last?.t ?? 0, monoStart: first?.mono ?? 0, @@ -79,6 +148,14 @@ export class EventChunker { bytes, events }; + + await this.commit?.(chunk); + + this.pending.length = 0; + this.pendingBytes = 0; + this.sequence = sequence; + + return chunk; } } diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index 4831b36..7715d8e 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -9,13 +9,15 @@ import { type WebBlackboxEvent } from "@webblackbox/protocol"; +import { decodeChunkEvents } from "./codec.js"; import { readWebBlackboxArchive } from "./exporter.js"; import { FlightRecorderPipeline, type FlightRecorderPipelineOptions } from "./pipeline.js"; import { derivePipelineStorageKey, EncryptedPipelineStorage, IndexedDbPipelineStorage, - MemoryPipelineStorage + MemoryPipelineStorage, + type StoredChunk } from "./storage.js"; const SESSION: SessionMetadata = { @@ -53,6 +55,19 @@ function createTestPipeline(options: FlightRecorderPipelineOptions): FlightRecor }); } +class FailOnceChunkStorage extends MemoryPipelineStorage { + private shouldFail = true; + + public override async putChunk(chunk: StoredChunk): Promise { + if (this.shouldFail) { + this.shouldFail = false; + throw new Error("simulated chunk persistence failure"); + } + + await super.putChunk(chunk); + } +} + function createEvent( id: string, type: WebBlackboxEvent["type"], @@ -212,6 +227,61 @@ describe("pipeline", () => { expect(indexes.request.some((entry) => entry.reqId === "R-1")).toBe(true); }); + it("serializes concurrent ingests with close and persists each event exactly once", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1 + }); + const events = Array.from({ length: 30 }, (_, index) => + createEvent(`E-concurrent-${index}`, "user.marker", index) + ); + + await pipeline.start(); + const ingests = events.map((event) => pipeline.ingest(event)); + const closing = pipeline.close(); + + await expect( + pipeline.ingest(createEvent("E-after-close", "user.marker", events.length)) + ).rejects.toThrow(/after close\(\) has started/i); + await Promise.all([...ingests, closing]); + + const chunks = await storage.listChunks(SESSION.sid); + const persisted = ( + await Promise.all(chunks.map((chunk) => decodeChunkEvents(chunk.bytes, chunk.meta.codec))) + ).flat(); + + expect(chunks.map((chunk) => chunk.meta.seq)).toEqual( + Array.from({ length: events.length }, (_, index) => index + 1) + ); + expect(persisted.map((event) => event.id)).toEqual(events.map((event) => event.id)); + expect(new Set(persisted.map((event) => event.id)).size).toBe(events.length); + }); + + it("can retry an ingest after chunk persistence fails without a sequence gap or duplicate", async () => { + const storage = new FailOnceChunkStorage(); + const pipeline = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1 + }); + const event = createEvent("E-persistence-retry", "user.marker", 1); + + await pipeline.start(); + await expect(pipeline.ingest(event)).rejects.toThrow("simulated chunk persistence failure"); + await pipeline.ingest(event); + await pipeline.close(); + + const chunks = await storage.listChunks(SESSION.sid); + const persisted = ( + await Promise.all(chunks.map((chunk) => decodeChunkEvents(chunk.bytes, chunk.meta.codec))) + ).flat(); + + expect(chunks.map((chunk) => chunk.meta.seq)).toEqual([1]); + expect(persisted.map((item) => item.id)).toEqual([event.id]); + }); + it("indexes request ids from nested request payloads", async () => { const storage = new MemoryPipelineStorage(); const pipeline = createTestPipeline({ diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index ae0c6c4..1b7510f 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -101,12 +101,23 @@ export class FlightRecorderPipeline { private readonly chunker: EventChunker; private readonly chunkCodec: (typeof CHUNK_CODECS)[number]; private storageReadyPromise: Promise | null = null; + private chunkOperationTail: Promise = Promise.resolve(); + private acceptingEvents = true; + private closePromise: Promise | null = null; public constructor(private readonly options: FlightRecorderPipelineOptions) { const codec = resolveChunkCodec(options.chunkCodec); const maxChunkBytes = options.maxChunkBytes ?? 512 * 1024; this.chunkCodec = codec; - this.chunker = new EventChunker(maxChunkBytes, codec); + this.chunker = new EventChunker(maxChunkBytes, codec, async (chunk) => { + await this.persistChunk( + chunk.meta.chunkId, + chunk.meta.seq, + chunk.meta.codec, + chunk.events, + chunk.bytes + ); + }); } public async start(): Promise { @@ -120,19 +131,13 @@ export class FlightRecorderPipeline { public async ingest(event: WebBlackboxEvent): Promise { assertPrivacyClassifiedEvent(event); - const chunk = await this.chunker.append(event); - - if (!chunk) { - return; + if (!this.acceptingEvents) { + throw new Error("FlightRecorderPipeline cannot ingest events after close() has started."); } - await this.persistChunk( - chunk.meta.chunkId, - chunk.meta.seq, - chunk.meta.codec, - chunk.events, - chunk.bytes - ); + await this.enqueueChunkOperation(async () => { + await this.chunker.append(event); + }); } public async ingestBatch(events: WebBlackboxEvent[]): Promise { @@ -144,45 +149,44 @@ export class FlightRecorderPipeline { assertPrivacyClassifiedEvent(event); } - for (const event of events) { - const chunk = await this.chunker.append(event); + if (!this.acceptingEvents) { + throw new Error("FlightRecorderPipeline cannot ingest events after close() has started."); + } - if (!chunk) { - continue; + await this.enqueueChunkOperation(async () => { + for (const event of events) { + await this.chunker.append(event); } - - await this.persistChunk( - chunk.meta.chunkId, - chunk.meta.seq, - chunk.meta.codec, - chunk.events, - chunk.bytes - ); - } + }); } public async flush(): Promise { - const chunk = await this.chunker.flush(); + await this.enqueueChunkOperation(async () => { + await this.chunker.flush(); + }); + } - if (!chunk) { - return; + public close(options: { purge?: boolean } = {}): Promise { + if (this.closePromise) { + return this.closePromise; } - await this.persistChunk( - chunk.meta.chunkId, - chunk.meta.seq, - chunk.meta.codec, - chunk.events, - chunk.bytes - ); - } + this.acceptingEvents = false; + const attempt = this.enqueueChunkOperation(async () => { + await this.chunker.close(); - public async close(options: { purge?: boolean } = {}): Promise { - await this.flush(); + if (options.purge) { + await this.options.storage.deleteSession(this.options.session.sid); + } + }); + const tracked = attempt.finally(() => { + if (this.closePromise === tracked) { + this.closePromise = null; + } + }); - if (options.purge) { - await this.options.storage.deleteSession(this.options.session.sid); - } + this.closePromise = tracked; + return tracked; } public async putBlob(mime: string, bytes: Uint8Array): Promise { @@ -691,6 +695,15 @@ export class FlightRecorderPipeline { await this.options.storage.putChunk(chunk); } + private enqueueChunkOperation(operation: () => Promise): Promise { + const result = this.chunkOperationTail.then(operation); + this.chunkOperationTail = result.then( + () => undefined, + () => undefined + ); + return result; + } + private ensureStorageReady(): Promise { this.storageReadyPromise ??= assertLocalAtRestStorage( this.options.storage, From ae848d1da1aa4d529017cfe018398d6160a2c6ab Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:18:02 +0800 Subject: [PATCH 033/181] fix(extension): make E2E scripts cross-platform --- apps/extension/package.json | 15 ++++++++------- pnpm-lock.yaml | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/extension/package.json b/apps/extension/package.json index 8423774..e58d098 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -13,17 +13,17 @@ "verify": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run package:chrome", "e2e:check": "node scripts/e2e-extension-check.mjs", "e2e:fullchain": "node scripts/e2e-fullchain-demo.mjs", - "e2e:fullchain:lite": "WB_E2E_MODE=lite node scripts/e2e-fullchain-demo.mjs", - "e2e:fullchain:lite:reload": "WB_E2E_MODE=lite WB_E2E_RELOAD_AFTER_START=1 node scripts/e2e-fullchain-demo.mjs", - "e2e:fullchain:full": "WB_E2E_MODE=full node scripts/e2e-fullchain-demo.mjs", - "e2e:fullchain:full:defaults": "WB_E2E_MODE=full WB_E2E_CONFIGURE_OPTIONS=0 node scripts/e2e-fullchain-demo.mjs", + "e2e:fullchain:lite": "cross-env WB_E2E_MODE=lite node scripts/e2e-fullchain-demo.mjs", + "e2e:fullchain:lite:reload": "cross-env WB_E2E_MODE=lite WB_E2E_RELOAD_AFTER_START=1 node scripts/e2e-fullchain-demo.mjs", + "e2e:fullchain:full": "cross-env WB_E2E_MODE=full node scripts/e2e-fullchain-demo.mjs", + "e2e:fullchain:full:defaults": "cross-env WB_E2E_MODE=full WB_E2E_CONFIGURE_OPTIONS=0 node scripts/e2e-fullchain-demo.mjs", "e2e:realworld": "node scripts/e2e-realworld-stability.mjs", - "e2e:realworld:quick": "WB_E2E_REALWORLD_QUICK=1 node scripts/e2e-realworld-stability.mjs", + "e2e:realworld:quick": "cross-env WB_E2E_REALWORLD_QUICK=1 node scripts/e2e-realworld-stability.mjs", "e2e:realworld:ci": "node scripts/e2e-realworld-stability.mjs", "e2e:memory:full": "node scripts/e2e-full-memory-regression.mjs", - "e2e:memory:full:ci": "WB_E2E_STRESS_REQUESTS=96 WB_E2E_STRESS_CONCURRENCY=4 WB_E2E_MEMORY_SAMPLE_MS=1500 WB_E2E_MEMORY_SETTLE_SAMPLES=1 WB_E2E_MEMORY_TIMEOUT_MS=90000 node scripts/e2e-full-memory-regression.mjs", + "e2e:memory:full:ci": "cross-env WB_E2E_STRESS_REQUESTS=96 WB_E2E_STRESS_CONCURRENCY=4 WB_E2E_MEMORY_SAMPLE_MS=1500 WB_E2E_MEMORY_SETTLE_SAMPLES=1 WB_E2E_MEMORY_TIMEOUT_MS=90000 node scripts/e2e-full-memory-regression.mjs", "e2e:perf:lite": "node scripts/e2e-lite-perf-regression.mjs", - "e2e:perf:lite:ci": "WB_E2E_PERF_REQUESTS=32 WB_E2E_PERF_WARMUP_REQUESTS=12 WB_E2E_PERF_INTERACTION_ROUNDS=8 WB_E2E_PERF_IFRAME_ROUNDS=6 WB_E2E_PERF_EDITOR_ROUNDS=12 WB_E2E_PERF_NAV_ROUNDS=3 WB_E2E_PERF_NAV_WAIT_MS=6000 node scripts/e2e-lite-perf-regression.mjs", + "e2e:perf:lite:ci": "cross-env WB_E2E_PERF_REQUESTS=32 WB_E2E_PERF_WARMUP_REQUESTS=12 WB_E2E_PERF_INTERACTION_ROUNDS=8 WB_E2E_PERF_IFRAME_ROUNDS=6 WB_E2E_PERF_EDITOR_ROUNDS=12 WB_E2E_PERF_NAV_ROUNDS=3 WB_E2E_PERF_NAV_WAIT_MS=6000 node scripts/e2e-lite-perf-regression.mjs", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run" @@ -37,6 +37,7 @@ "webblackbox": "workspace:*" }, "devDependencies": { + "cross-env": "^10.1.0", "fake-indexeddb": "^6.2.5", "jszip": "^3.10.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b16b62c..a9ddbd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,6 +91,9 @@ importers: specifier: workspace:* version: link:../../packages/webblackbox devDependencies: + cross-env: + specifier: ^10.1.0 + version: 10.1.0 fake-indexeddb: specifier: ^6.2.5 version: 6.2.5 @@ -382,6 +385,9 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -1272,6 +1278,11 @@ packages: typescript: optional: true + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3184,6 +3195,8 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -4010,6 +4023,11 @@ snapshots: typescript: 5.9.3 optional: true + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 From fa76ad62df1b3850f8a76c6044c0a0bae2dbeb28 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:28:59 +0800 Subject: [PATCH 034/181] fix(extension): bound untrusted page-world messages --- docs/SECURITY.md | 2 + .../webblackbox/src/injected-hooks.test.ts | 71 +++++++ packages/webblackbox/src/injected-hooks.ts | 178 ++++++++++++++++-- .../src/lite-capture-agent.test.ts | 125 ++++++++++-- .../webblackbox/src/lite-capture-agent.ts | 72 ++++--- 5 files changed, 387 insertions(+), 61 deletions(-) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 6acb03a..583b9cf 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -15,6 +15,8 @@ The dev/enterprise profile can enable deeper diagnostics, including CDP, but the 5. Archives include `privacy/manifest.json` with policy, categories, encryption status, and pre-encryption scanner result. 6. Exports and shares recompute policy eligibility instead of trusting imported archive metadata. +MAIN-world capture hooks run in the page's JavaScript environment and are therefore treated as untrusted observations, not authenticated evidence. Their bridge accepts only bounded, allowlisted event payloads, ignores page-supplied timestamps, rate-limits input, and exposes no marker or other extension control operation. Privileged decisions remain in the isolated content script and service worker. + Global visual and profiler artifacts fail closed when the page contains a child frame that is outside the effective scope. CDP DOM snapshots are filtered to frame IDs that independently passed the scope gate. ## Encryption diff --git a/packages/webblackbox/src/injected-hooks.test.ts b/packages/webblackbox/src/injected-hooks.test.ts index ec18ece..21a29b7 100644 --- a/packages/webblackbox/src/injected-hooks.test.ts +++ b/packages/webblackbox/src/injected-hooks.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protoco import { INJECTED_CAPTURE_CONFIG_EVENT, INJECTED_MESSAGE_SOURCE, + parseInjectedCaptureWindowMessage, type InjectedCaptureConfig, type InjectedCaptureWindowMessage, installInjectedLiteCaptureHooks @@ -76,6 +77,76 @@ describe("injected-hooks", () => { sessionStorage.clear(); }); + it("accepts only bounded observation messages from the page-world bridge", () => { + const validEvent = { + rawType: "console", + payload: { method: "info", redacted: true }, + t: Date.now(), + mono: performance.timeOrigin + performance.now() + }; + + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-events", + events: [validEvent] + }) + ).toEqual([validEvent]); + + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "marker", + message: "forged control action", + t: Date.now(), + mono: performance.now() + }) + ).toBeNull(); + + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + ...validEvent, + rawType: "mutation" + }) + ).toBeNull(); + + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-events", + events: Array.from({ length: 25 }, () => validEvent) + }) + ).toBeNull(); + + let nestedPayload: Record = {}; + for (let depth = 0; depth < 8; depth += 1) { + nestedPayload = { nested: nestedPayload }; + } + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + ...validEvent, + payload: nestedPayload + }) + ).toBeNull(); + + expect( + parseInjectedCaptureWindowMessage( + new Proxy( + {}, + { + getPrototypeOf() { + throw new Error("hostile proxy"); + } + } + ) + ) + ).toBeNull(); + }); + it("omits IndexedDB names when policy is counts-only", async () => { const open = vi.fn(() => ({}) as IDBOpenDBRequest); vi.stubGlobal("indexedDB", { open }); diff --git a/packages/webblackbox/src/injected-hooks.ts b/packages/webblackbox/src/injected-hooks.ts index ee7c8df..543ad83 100644 --- a/packages/webblackbox/src/injected-hooks.ts +++ b/packages/webblackbox/src/injected-hooks.ts @@ -16,6 +16,10 @@ const SAFE_SERIALIZE_MAX_DEPTH = 3; const SAFE_SERIALIZE_MAX_PROPERTIES = 24; const SAFE_SERIALIZE_MAX_STRING_CHARS = 1_200; const EMIT_FLUSH_MAX_EVENTS = 24; +const INJECTED_MESSAGE_MAX_PAYLOAD_CHARS = 4 * 1024 * 1024 + 256 * 1024; +const INJECTED_MESSAGE_MAX_NODES = 512; +const INJECTED_MESSAGE_MAX_DEPTH = 6; +const INJECTED_MESSAGE_MAX_COLLECTION_ITEMS = 64; const NETWORK_HEADER_ALLOWLIST = new Set([ "accept", "accept-language", @@ -43,6 +47,13 @@ export type InjectedCaptureConfig = { capturePolicy?: CapturePolicy; }; +export type InjectedCaptureEvent = { + rawType: string; + payload: CapturePayload; + t: number; + mono: number; +}; + /** Message contract emitted by injected hooks into the page window. */ export type InjectedCaptureWindowMessage = | { @@ -56,21 +67,162 @@ export type InjectedCaptureWindowMessage = | { source: typeof INJECTED_MESSAGE_SOURCE; kind: "capture-events"; - events: Array<{ - rawType: string; - payload: CapturePayload; - t: number; - mono: number; - }>; - } - | { - source: typeof INJECTED_MESSAGE_SOURCE; - kind: "marker"; - message?: string; - t: number; - mono: number; + events: InjectedCaptureEvent[]; }; +const INJECTED_CAPTURE_RAW_TYPES = new Set([ + "console", + "fetch", + "fetchError", + "indexedDbOp", + "localStorageOp", + "networkBody", + "pageError", + "privacyViolation", + "resourceError", + "sessionStorageOp", + "sse", + "unhandledrejection", + "xhr" +]); + +/** + * Treats MAIN-world messages as hostile page input. This parser intentionally + * accepts observations only; it never exposes extension control operations. + */ +export function parseInjectedCaptureWindowMessage(value: unknown): InjectedCaptureEvent[] | null { + try { + return parseInjectedCaptureWindowMessageUnchecked(value); + } catch { + return null; + } +} + +function parseInjectedCaptureWindowMessageUnchecked(value: unknown): InjectedCaptureEvent[] | null { + const message = asPlainRecord(value); + + if (!message || message.source !== INJECTED_MESSAGE_SOURCE) { + return null; + } + + const candidates = + message.kind === "capture-event" + ? [message] + : message.kind === "capture-events" && Array.isArray(message.events) + ? message.events + : null; + + if (!candidates || candidates.length === 0 || candidates.length > EMIT_FLUSH_MAX_EVENTS) { + return null; + } + + const budget = { + nodes: 0, + chars: 0 + }; + const events: InjectedCaptureEvent[] = []; + + for (const candidate of candidates) { + const event = asPlainRecord(candidate); + const payload = asPlainRecord(event?.payload); + + if ( + !event || + typeof event.rawType !== "string" || + !INJECTED_CAPTURE_RAW_TYPES.has(event.rawType) || + !payload || + typeof event.t !== "number" || + !Number.isFinite(event.t) || + typeof event.mono !== "number" || + !Number.isFinite(event.mono) || + !isBoundedInjectedValue(payload, 0, budget) + ) { + return null; + } + + const safePayload = asPlainRecord(structuredClone(payload)); + if (!safePayload) { + return null; + } + + events.push({ + rawType: event.rawType, + payload: safePayload, + t: event.t, + mono: event.mono + }); + } + + return events; +} + +function isBoundedInjectedValue( + value: unknown, + depth: number, + budget: { nodes: number; chars: number } +): boolean { + budget.nodes += 1; + if (budget.nodes > INJECTED_MESSAGE_MAX_NODES || depth > INJECTED_MESSAGE_MAX_DEPTH) { + return false; + } + + if (value === null || value === undefined || typeof value === "boolean") { + return true; + } + + if (typeof value === "number") { + return Number.isFinite(value); + } + + if (typeof value === "string") { + budget.chars += value.length; + return budget.chars <= INJECTED_MESSAGE_MAX_PAYLOAD_CHARS; + } + + if (Array.isArray(value)) { + return ( + value.length <= INJECTED_MESSAGE_MAX_COLLECTION_ITEMS && + value.every((entry) => isBoundedInjectedValue(entry, depth + 1, budget)) + ); + } + + const record = asPlainRecord(value); + if (!record) { + return false; + } + + const entries = Object.entries(record); + if (entries.length > INJECTED_MESSAGE_MAX_COLLECTION_ITEMS) { + return false; + } + + for (const [key, entry] of entries) { + if (key.length > 128 || key === "__proto__" || key === "constructor" || key === "prototype") { + return false; + } + budget.chars += key.length; + if ( + budget.chars > INJECTED_MESSAGE_MAX_PAYLOAD_CHARS || + !isBoundedInjectedValue(entry, depth + 1, budget) + ) { + return false; + } + } + + return true; +} + +function asPlainRecord(value: unknown): Record | null { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null + ? (value as Record) + : null; +} + /** Options for installing browser-side injected hooks. */ export type InjectedHooksOptions = { /** Global flag name used to prevent duplicate hook installation. */ diff --git a/packages/webblackbox/src/lite-capture-agent.test.ts b/packages/webblackbox/src/lite-capture-agent.test.ts index 58aa0d8..e150629 100644 --- a/packages/webblackbox/src/lite-capture-agent.test.ts +++ b/packages/webblackbox/src/lite-capture-agent.test.ts @@ -16,6 +16,7 @@ import { INJECTED_MESSAGE_SOURCE } from "./injected-hooks.js"; import { LiteCaptureAgent } from "./lite-capture-agent.js"; import type { LiteCaptureAgentOptions, LiteCaptureState } from "./types.js"; import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; +import type { RawRecorderEvent } from "@webblackbox/recorder"; const SCREENSHOT_TEST_CAPTURE_POLICY: CapturePolicy = { ...DEFAULT_CAPTURE_POLICY, @@ -94,31 +95,28 @@ function createInactiveAgent(options: Partial = {}) { }; } -function dispatchInjectedEvents(rawType: string, count: number): void { +function queueSyntheticEvents(agent: LiteCaptureAgent, rawType: string, count: number): void { const startedAt = Date.now(); - const events = Array.from({ length: count }, (_, index) => { - const now = startedAt + index; + const queueRawEvent = ( + agent as unknown as { + queueRawEvent(event: RawRecorderEvent): void; + } + ).queueRawEvent.bind(agent); - return { + for (let index = 0; index < count; index += 1) { + const now = startedAt + index; + queueRawEvent({ + source: "content", rawType, + tabId: 7, + sid: "S-lite-agent-test", payload: { index }, t: now, mono: performance.timeOrigin + now - }; - }); - - window.dispatchEvent( - new MessageEvent("message", { - data: { - source: INJECTED_MESSAGE_SOURCE, - kind: "capture-events", - events - }, - source: window - }) - ); + }); + } } function clickTarget(): void { @@ -414,6 +412,93 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("treats page-world bridge messages as bounded observations only", () => { + const onMarker = vi.fn(); + const { agent, emitBatch } = createAgent({}, { onMarker }); + + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "marker", + message: "forged marker", + t: 1, + mono: 1 + }, + source: window + }) + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + rawType: "mutation", + payload: { count: 999 }, + t: 1, + mono: 1 + }, + source: window + }) + ); + + agent.flush(); + + expect(onMarker).not.toHaveBeenCalled(); + expect(countEmittedEvents(emitBatch)).toBe(0); + + const beforeReceipt = Date.now(); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + rawType: "console", + payload: { method: "info", redacted: true }, + t: 1, + mono: 1 + }, + source: window + }) + ); + agent.flush(); + + const captured = emitBatch.mock.calls.flatMap((call) => call[0] as RawRecorderEvent[]); + expect(captured).toHaveLength(1); + expect(captured[0]?.rawType).toBe("console"); + expect(captured[0]?.t).toBeGreaterThanOrEqual(beforeReceipt); + expect(captured[0]?.mono).not.toBe(1); + + agent.dispose(); + }); + + it("rate-limits untrusted page-world observation batches", () => { + const { agent, emitBatch } = createAgent(); + + for (let batch = 0; batch < 6; batch += 1) { + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-events", + events: Array.from({ length: 24 }, (_, index) => ({ + rawType: "console", + payload: { method: "info", batch, index, redacted: true }, + t: Date.now(), + mono: performance.timeOrigin + performance.now() + })) + }, + source: window + }) + ); + } + + agent.flush(); + + expect(countEmittedEvents(emitBatch)).toBe(120); + agent.dispose(); + }); + it("does not install page performance observers in full mode", () => { const observe = vi.fn(); const requestAnimationFrame = vi.fn(() => 1); @@ -1673,7 +1758,7 @@ describe("LiteCaptureAgent", () => { it("suppresses mousemove capture while the event buffer is under pressure", () => { const { agent, emitBatch } = createAgent(); - dispatchInjectedEvents("mutation", 130); + queueSyntheticEvents(agent, "mutation", 130); movePointer(); agent.flush(); @@ -1686,7 +1771,7 @@ describe("LiteCaptureAgent", () => { it("flushes buffered low-priority events asynchronously in chunks", async () => { const { agent, emitBatch } = createAgent(); - dispatchInjectedEvents("mutation", 130); + queueSyntheticEvents(agent, "mutation", 130); expect(emitBatch).not.toHaveBeenCalled(); @@ -1706,7 +1791,7 @@ describe("LiteCaptureAgent", () => { it("sheds low-priority overflow before draining the backlog", () => { const { agent, emitBatch } = createAgent(); - dispatchInjectedEvents("mutation", 1_300); + queueSyntheticEvents(agent, "mutation", 1_300); expect(emitBatch).not.toHaveBeenCalled(); diff --git a/packages/webblackbox/src/lite-capture-agent.ts b/packages/webblackbox/src/lite-capture-agent.ts index 5a22cd9..bd64205 100644 --- a/packages/webblackbox/src/lite-capture-agent.ts +++ b/packages/webblackbox/src/lite-capture-agent.ts @@ -8,7 +8,7 @@ import type { RawRecorderEvent } from "@webblackbox/recorder"; import { snapdom } from "@zumer/snapdom"; import type { LiteCaptureAgentOptions, LiteCaptureSampling, LiteCaptureState } from "./types.js"; -import { INJECTED_MESSAGE_SOURCE, type InjectedCaptureWindowMessage } from "./injected-hooks.js"; +import { parseInjectedCaptureWindowMessage } from "./injected-hooks.js"; const PRE_RECORDING_BUFFER_MAX = 400; const SCREENSHOT_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; @@ -63,6 +63,8 @@ const MUTATION_SAMPLE_TARGETS_MAX = 24; const MUTATION_SAMPLE_ATTRIBUTES_MAX = 16; const SELECTOR_CACHE_MAX = 1_500; const PERF_LOG_FLAG = "__WEBBLACKBOX_PERF__"; +const INJECTED_BRIDGE_MAX_EVENTS_PER_SECOND = 120; +const INJECTED_BRIDGE_MAX_PAYLOAD_CHARS_PER_MINUTE = 4 * 1024 * 1024 + 256 * 1024; const OBSERVED_MUTATION_ATTRIBUTES = [ "hidden", "open", @@ -237,6 +239,10 @@ export class LiteCaptureAgent { private droppedLowPriorityEvents = 0; private disposed = false; private pendingQuietRecoverySummary = false; + private injectedBridgeWindowStartedAt = 0; + private injectedBridgeEventCount = 0; + private injectedBridgePayloadWindowStartedAt = 0; + private injectedBridgePayloadChars = 0; /** Creates and installs capture hooks for the current page context. */ public constructor(private readonly options: LiteCaptureAgentOptions) { @@ -402,47 +408,57 @@ export class LiteCaptureAgent { return; } - const data = event.data as InjectedCaptureWindowMessage | undefined; - - if (!data || data.source !== INJECTED_MESSAGE_SOURCE) { + const events = parseInjectedCaptureWindowMessage(event.data); + const payloadChars = events ? JSON.stringify(events).length : 0; + if (!events || !this.consumeInjectedBridgeBudget(events.length, payloadChars)) { return; } - if (data.kind === "capture-event" && typeof data.rawType === "string") { - this.queueInjectedRawEvent(data); - return; + const receivedAt = Date.now(); + const receivedMono = monotonicTime(); + for (const [index, item] of events.entries()) { + this.queueInjectedRawEvent(item, receivedAt, receivedMono + index / 1_000); } + }); + } - if (data.kind === "capture-events" && Array.isArray(data.events)) { - for (const item of data.events) { - if (item && typeof item.rawType === "string") { - this.queueInjectedRawEvent(item); - } - } + private consumeInjectedBridgeBudget(eventCount: number, payloadChars: number): boolean { + const now = Date.now(); + if (now - this.injectedBridgeWindowStartedAt >= 1_000) { + this.injectedBridgeWindowStartedAt = now; + this.injectedBridgeEventCount = 0; + } - return; - } + if (now - this.injectedBridgePayloadWindowStartedAt >= 60_000) { + this.injectedBridgePayloadWindowStartedAt = now; + this.injectedBridgePayloadChars = 0; + } - if (data.kind === "marker") { - this.emitMarker(typeof data.message === "string" ? data.message : "Marker"); - } - }); + if ( + this.injectedBridgeEventCount + eventCount > INJECTED_BRIDGE_MAX_EVENTS_PER_SECOND || + this.injectedBridgePayloadChars + payloadChars > INJECTED_BRIDGE_MAX_PAYLOAD_CHARS_PER_MINUTE + ) { + return false; + } + + this.injectedBridgeEventCount += eventCount; + this.injectedBridgePayloadChars += payloadChars; + return true; } - private queueInjectedRawEvent(event: { - rawType: string; - payload?: Record; - t?: number; - mono?: number; - }): void { + private queueInjectedRawEvent( + event: { rawType: string; payload: Record }, + receivedAt = Date.now(), + receivedMono = monotonicTime() + ): void { this.queueRawEvent({ source: "content", rawType: event.rawType, tabId: this.tabId, sid: this.sid, - t: typeof event.t === "number" ? event.t : Date.now(), - mono: typeof event.mono === "number" ? event.mono : monotonicTime(), - payload: event.payload ?? {} + t: receivedAt, + mono: receivedMono, + payload: event.payload }); } From f0ce9b17c4e3776010e5101b1d46d0a8be07a453 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:33:24 +0800 Subject: [PATCH 035/181] ci(coverage): gate every published runtime --- apps/mcp-server/package.json | 3 ++- apps/mcp-server/vitest.config.ts | 15 ++++++++++++++ package.json | 2 +- packages/cdp-router/package.json | 3 ++- packages/cdp-router/vitest.config.ts | 29 +++++++++++++++++++++++++++ packages/pipeline/vitest.config.ts | 8 ++++---- packages/player-sdk/vitest.config.ts | 8 ++++---- packages/protocol/package.json | 3 ++- packages/protocol/vitest.config.ts | 19 ++++++++++++++++++ packages/recorder/vitest.config.ts | 8 ++++---- packages/webblackbox/package.json | 3 ++- packages/webblackbox/vitest.config.ts | 14 ++++++++++++- 12 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 packages/cdp-router/vitest.config.ts create mode 100644 packages/protocol/vitest.config.ts diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 6cee5bb..2a64ca5 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -53,7 +53,8 @@ "inspect": "pnpm run build && npx @modelcontextprotocol/inspector node dist/cli.js", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --config vitest.config.ts" + "test": "vitest run --config vitest.config.ts", + "test:coverage": "vitest run --config vitest.config.ts --coverage" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/apps/mcp-server/vitest.config.ts b/apps/mcp-server/vitest.config.ts index cd03562..5020bf0 100644 --- a/apps/mcp-server/vitest.config.ts +++ b/apps/mcp-server/vitest.config.ts @@ -11,5 +11,20 @@ export default defineConfig({ new URL("../../packages/protocol/src/index.ts", import.meta.url) ) } + }, + test: { + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.d.ts"], + thresholds: { + lines: 65, + statements: 65, + functions: 55, + branches: 45 + } + } } }); diff --git a/package.json b/package.json index aba2aa0..215558c 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "lint:scripts": "eslint \"scripts/**/*.mjs\" \"apps/*/scripts/**/*.mjs\"", "typecheck": "turbo run typecheck", "test": "turbo run test", - "coverage:core": "pnpm --filter @webblackbox/recorder test:coverage && pnpm --filter @webblackbox/pipeline test:coverage && pnpm --filter @webblackbox/player-sdk test:coverage", + "coverage:core": "pnpm --filter @webblackbox/protocol test:coverage && pnpm --filter @webblackbox/cdp-router test:coverage && pnpm --filter @webblackbox/recorder test:coverage && pnpm --filter @webblackbox/pipeline test:coverage && pnpm --filter webblackbox test:coverage && pnpm --filter @webblackbox/player-sdk test:coverage && pnpm --filter @webblackbox/mcp-server test:coverage", "bench": "pnpm run bench:recorder && pnpm run bench:pipeline", "bench:ci": "node scripts/bench-regression-check.mjs", "bench:recorder": "pnpm --filter @webblackbox/recorder bench", diff --git a/packages/cdp-router/package.json b/packages/cdp-router/package.json index 0383447..08f1030 100644 --- a/packages/cdp-router/package.json +++ b/packages/cdp-router/package.json @@ -43,7 +43,8 @@ "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@webblackbox/protocol": "workspace:*" diff --git a/packages/cdp-router/vitest.config.ts b/packages/cdp-router/vitest.config.ts new file mode 100644 index 0000000..5bea0ab --- /dev/null +++ b/packages/cdp-router/vitest.config.ts @@ -0,0 +1,29 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vitest/config"; + +const root = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@webblackbox/protocol": resolve(root, "../protocol/src/index.ts") + } + }, + test: { + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/index.ts", "src/types.ts"], + thresholds: { + lines: 55, + statements: 55, + functions: 45, + branches: 48 + } + } + } +}); diff --git a/packages/pipeline/vitest.config.ts b/packages/pipeline/vitest.config.ts index c25cac9..2863e32 100644 --- a/packages/pipeline/vitest.config.ts +++ b/packages/pipeline/vitest.config.ts @@ -19,10 +19,10 @@ export default defineConfig({ include: ["src/**/*.ts"], exclude: ["src/**/*.test.ts", "src/index.ts"], thresholds: { - lines: 80, - statements: 80, - functions: 80, - branches: 65 + lines: 84, + statements: 84, + functions: 90, + branches: 68 } } } diff --git a/packages/player-sdk/vitest.config.ts b/packages/player-sdk/vitest.config.ts index ed6e2b4..fbe8880 100644 --- a/packages/player-sdk/vitest.config.ts +++ b/packages/player-sdk/vitest.config.ts @@ -19,10 +19,10 @@ export default defineConfig({ include: ["src/**/*.ts"], exclude: ["src/**/*.test.ts"], thresholds: { - lines: 80, - statements: 80, - functions: 80, - branches: 65 + lines: 81, + statements: 81, + functions: 88, + branches: 66 } } } diff --git a/packages/protocol/package.json b/packages/protocol/package.json index aad1bdb..1355ecb 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -43,7 +43,8 @@ "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage" }, "dependencies": { "zod": "^4.1.12" diff --git a/packages/protocol/vitest.config.ts b/packages/protocol/vitest.config.ts new file mode 100644 index 0000000..eaca8b6 --- /dev/null +++ b/packages/protocol/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/index.ts", "src/types.ts"], + thresholds: { + lines: 60, + statements: 60, + functions: 58, + branches: 53 + } + } + } +}); diff --git a/packages/recorder/vitest.config.ts b/packages/recorder/vitest.config.ts index 6ad7a19..dc0b120 100644 --- a/packages/recorder/vitest.config.ts +++ b/packages/recorder/vitest.config.ts @@ -19,10 +19,10 @@ export default defineConfig({ include: ["src/**/*.ts"], exclude: ["src/**/*.test.ts", "src/index.ts", "src/types.ts"], thresholds: { - lines: 80, - statements: 80, - functions: 80, - branches: 65 + lines: 87, + statements: 87, + functions: 90, + branches: 78 } } } diff --git a/packages/webblackbox/package.json b/packages/webblackbox/package.json index d1f27d0..97087fa 100644 --- a/packages/webblackbox/package.json +++ b/packages/webblackbox/package.json @@ -64,7 +64,8 @@ "prepack": "pnpm run build", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage" }, "devDependencies": { "fake-indexeddb": "^6.2.5", diff --git a/packages/webblackbox/vitest.config.ts b/packages/webblackbox/vitest.config.ts index da1444d..3d2358c 100644 --- a/packages/webblackbox/vitest.config.ts +++ b/packages/webblackbox/vitest.config.ts @@ -14,6 +14,18 @@ export default defineConfig({ } }, test: { - environment: "node" + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/index.ts", "src/types.ts"], + thresholds: { + lines: 70, + statements: 70, + functions: 80, + branches: 60 + } + } } }); From 45831e13545a831c36f9cb74cb4e6342e8779c40 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:49:11 +0800 Subject: [PATCH 036/181] fix(dev-deps): eliminate high-risk toolchain advisories --- .github/workflows/ci.yml | 4 +- package.json | 23 +- pnpm-lock.yaml | 773 +++++++++++++++++++-------------------- 3 files changed, 398 insertions(+), 402 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0843d39..cc9d318 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Production dependency audit - run: pnpm audit --prod --audit-level=high + - name: Dependency audit + run: pnpm audit --audit-level=high - name: Format Check run: pnpm format:check diff --git a/package.json b/package.json index 215558c..666d8af 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,16 @@ "@hono/node-server": "1.19.10", "express-rate-limit": "8.5.2", "fast-uri": "3.1.2", + "flatted@<3.4.2": "3.4.2", "hono": "4.12.29", - "path-to-regexp": "8.4.2" + "minimatch@<3.1.4": "3.1.4", + "minimatch@>=9.0.0 <9.0.7": "9.0.7", + "path-to-regexp": "8.4.2", + "picomatch@<2.3.2": "2.3.2", + "picomatch@>=4.0.0 <4.0.4": "4.0.5", + "rollup@>=4.0.0 <4.59.0": "4.62.2", + "vite@>=7.0.0 <7.3.5": "7.3.5", + "ws@>=8.0.0 <8.21.0": "8.21.0" } }, "scripts": { @@ -25,7 +33,7 @@ "player:pages:deploy": "pnpm --filter @webblackbox/player pages:deploy", "player:deploy": "pnpm player:pages:build && pnpm player:pages:deploy", "lint": "turbo run lint && pnpm lint:scripts", - "lint:scripts": "eslint \"scripts/**/*.mjs\" \"apps/*/scripts/**/*.mjs\"", + "lint:scripts": "eslint \"scripts/*.mjs\" \"apps/*/scripts/*.mjs\" \"apps/*/scripts/*/*.mjs\"", "typecheck": "turbo run typecheck", "test": "turbo run test", "coverage:core": "pnpm --filter @webblackbox/protocol test:coverage && pnpm --filter @webblackbox/cdp-router test:coverage && pnpm --filter @webblackbox/recorder test:coverage && pnpm --filter @webblackbox/pipeline test:coverage && pnpm --filter webblackbox test:coverage && pnpm --filter @webblackbox/player-sdk test:coverage && pnpm --filter @webblackbox/mcp-server test:coverage", @@ -58,11 +66,11 @@ ] }, "devDependencies": { - "@changesets/cli": "^2.29.7", + "@changesets/cli": "^2.31.0", "@eslint/js": "^9.39.1", "@types/node": "^24.10.1", - "@vitest/coverage-v8": "^4.0.18", - "commitizen": "^4.3.1", + "@vitest/coverage-v8": "^4.1.10", + "commitizen": "^4.3.2", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.39.1", "globals": "^16.5.0", @@ -72,9 +80,10 @@ "tsup": "^8.5.1", "tsx": "^4.20.6", "turbo": "^2.6.1", - "typedoc": "^0.28.17", + "typedoc": "^0.28.20", "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", - "vitest": "^4.0.8" + "vite": "7.3.5", + "vitest": "^4.1.10" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9ddbd8..5309d5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,16 +8,24 @@ overrides: '@hono/node-server': 1.19.10 express-rate-limit: 8.5.2 fast-uri: 3.1.2 + flatted@<3.4.2: 3.4.2 hono: 4.12.29 + minimatch@<3.1.4: 3.1.4 + minimatch@>=9.0.0 <9.0.7: 9.0.7 path-to-regexp: 8.4.2 + picomatch@<2.3.2: 2.3.2 + picomatch@>=4.0.0 <4.0.4: 4.0.5 + rollup@>=4.0.0 <4.59.0: 4.62.2 + vite@>=7.0.0 <7.3.5: 7.3.5 + ws@>=8.0.0 <8.21.0: 8.21.0 importers: .: devDependencies: '@changesets/cli': - specifier: ^2.29.7 - version: 2.29.8(@types/node@24.10.13) + specifier: ^2.31.0 + version: 2.31.0(@types/node@24.10.13) '@eslint/js': specifier: ^9.39.1 version: 9.39.2 @@ -25,11 +33,11 @@ importers: specifier: ^24.10.1 version: 24.10.13 '@vitest/coverage-v8': - specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2)) + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) commitizen: - specifier: ^4.3.1 - version: 4.3.1(@types/node@24.10.13)(typescript@5.9.3) + specifier: ^4.3.2 + version: 4.3.2(@types/node@24.10.13)(typescript@5.9.3) cz-conventional-changelog: specifier: ^3.3.0 version: 3.3.0(@types/node@24.10.13)(typescript@5.9.3) @@ -50,7 +58,7 @@ importers: version: 3.8.1 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) tsx: specifier: ^4.20.6 version: 4.21.0 @@ -58,17 +66,20 @@ importers: specifier: ^2.6.1 version: 2.8.7 typedoc: - specifier: ^0.28.17 - version: 0.28.17(typescript@5.9.3) + specifier: ^0.28.20 + version: 0.28.20(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: specifier: ^8.46.4 version: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: 7.3.5 + version: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) vitest: - specifier: ^4.0.8 - version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.10.13)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) apps/extension: dependencies: @@ -253,10 +264,6 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} @@ -282,30 +289,30 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@changesets/apply-release-plan@7.0.14': - resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} - '@changesets/assemble-release-plan@6.0.9': - resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} '@changesets/changelog-git@0.2.1': resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} - '@changesets/cli@2.29.8': - resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} + '@changesets/cli@2.31.0': + resolution: {integrity: sha512-AhI4enNTgHu2IZr6K4WZyf0EPch4XVMn1yOMFmCD9gsfBGqMYaHXls5HyDv6/CL5axVQABz68eG30eCtbr2wFg==} hasBin: true - '@changesets/config@3.1.2': - resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==} + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} '@changesets/errors@0.2.0': resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} - '@changesets/get-dependents-graph@2.1.3': - resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} - '@changesets/get-release-plan@4.0.14': - resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==} + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} '@changesets/get-version-range-type@0.4.0': resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} @@ -316,14 +323,14 @@ packages: '@changesets/logger@0.1.1': resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} - '@changesets/parse@0.4.2': - resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==} + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} '@changesets/pre@2.0.2': resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} - '@changesets/read@0.6.6': - resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==} + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} '@changesets/should-skip-package@0.1.2': resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} @@ -657,128 +664,128 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@rollup/rollup-android-arm-eabi@4.57.1': - resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.57.1': - resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.57.1': - resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.57.1': - resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.57.1': - resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.57.1': - resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': - resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.57.1': - resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.57.1': - resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.57.1': - resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.57.1': - resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.57.1': - resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.57.1': - resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.57.1': - resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.57.1': - resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.57.1': - resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.57.1': - resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.57.1': - resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.57.1': - resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.57.1': - resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.57.1': - resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.57.1': - resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.57.1': - resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.57.1': - resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.57.1': - resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -845,6 +852,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -927,43 +937,43 @@ packages: resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.10 + vitest: 4.1.10 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: 7.3.5 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@zumer/snapdom@2.0.2': resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==} @@ -1063,8 +1073,8 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} - ast-v8-to-istanbul@0.3.12: - resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} at-least-node@1.0.0: resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} @@ -1073,6 +1083,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -1090,8 +1104,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1114,8 +1129,8 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cachedir@2.3.0: - resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==} + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} engines: {node: '>=6'} call-bind-apply-helpers@1.0.2: @@ -1142,9 +1157,6 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chardet@0.7.0: - resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} - chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} @@ -1152,10 +1164,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -1211,9 +1219,9 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - commitizen@4.3.1: - resolution: {integrity: sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==} - engines: {node: '>= 12'} + commitizen@4.3.2: + resolution: {integrity: sha512-1Zs37z9JPvAcuTSSricZZwBhOPVNNxJouuY4yDEt+eD70EoxT2TU9kViG8CuB/PmVg2G4XsAGQiK4YCst97aDQ==} + engines: {node: '>= 18'} hasBin: true concat-map@0.0.1: @@ -1246,6 +1254,9 @@ packages: engines: {node: '>=18'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1400,8 +1411,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -1509,10 +1520,6 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} - external-editor@3.1.0: - resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} - engines: {node: '>=4'} - fake-indexeddb@6.2.5: resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} engines: {node: '>=18'} @@ -1540,7 +1547,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: 4.0.5 peerDependenciesMeta: picomatch: optional: true @@ -1586,8 +1593,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} @@ -1730,18 +1737,10 @@ packages: engines: {node: '>=18'} hasBin: true - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -1789,8 +1788,8 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - inquirer@8.2.5: - resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==} + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} engines: {node: '>=12.0.0'} ip-address@10.2.0: @@ -1949,8 +1948,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - linkify-it@5.0.0: - resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} lint-staged@16.2.7: resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} @@ -1985,8 +1984,8 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -2020,8 +2019,8 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - markdown-it@14.1.1: - resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} hasBin: true math-intrinsics@1.1.0: @@ -2074,15 +2073,19 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.4: + resolution: {integrity: sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==} - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@9.0.7: + resolution: {integrity: sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg==} engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.7: - resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} @@ -2153,10 +2156,6 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} - os-tmpdir@1.0.2: - resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} - engines: {node: '>=0.10.0'} - outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -2238,12 +2237,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} pidtree@0.6.0: @@ -2404,8 +2403,8 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -2521,8 +2520,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} @@ -2614,8 +2613,8 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tldts-core@6.1.86: @@ -2625,10 +2624,6 @@ packages: resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} hasBin: true - tmp@0.0.33: - resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} - engines: {node: '>=0.6.0'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2731,12 +2726,12 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typedoc@0.28.17: - resolution: {integrity: sha512-ZkJ2G7mZrbxrKxinTQMjFqsCoYY6a5Luwv2GKbTnBCEgV2ihYm5CflA9JnJAwH0pZWavqfYxmDkFHPt4yx2oDQ==} + typedoc@0.28.20: + resolution: {integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: - typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x typescript-eslint@8.55.0: resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} @@ -2781,8 +2776,8 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2821,20 +2816,23 @@ packages: yaml: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' + vite: 7.3.5 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -2848,6 +2846,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -2897,9 +2899,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} @@ -2908,8 +2910,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2932,6 +2934,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -2958,7 +2965,7 @@ snapshots: '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 optional: true @@ -2971,8 +2978,6 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} '@babel/parser@7.29.0': @@ -2986,13 +2991,13 @@ snapshots: '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 '@bcoe/v8-coverage@1.0.2': {} - '@changesets/apply-release-plan@7.0.14': + '@changesets/apply-release-plan@7.1.1': dependencies: - '@changesets/config': 3.1.2 + '@changesets/config': 3.1.4 '@changesets/get-version-range-type': 0.4.0 '@changesets/git': 3.0.4 '@changesets/should-skip-package': 0.1.2 @@ -3006,10 +3011,10 @@ snapshots: resolve-from: 5.0.0 semver: 7.7.4 - '@changesets/assemble-release-plan@6.0.9': + '@changesets/assemble-release-plan@6.0.10': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -3019,30 +3024,28 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.8(@types/node@24.10.13)': + '@changesets/cli@2.31.0(@types/node@24.10.13)': dependencies: - '@changesets/apply-release-plan': 7.0.14 - '@changesets/assemble-release-plan': 6.0.9 + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 '@changesets/changelog-git': 0.2.1 - '@changesets/config': 3.1.2 + '@changesets/config': 3.1.4 '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 - '@changesets/get-release-plan': 4.0.14 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.6 + '@changesets/read': 0.6.7 '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 '@inquirer/external-editor': 1.0.3(@types/node@24.10.13) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 - ci-info: 3.9.0 enquirer: 2.4.1 fs-extra: 7.0.1 mri: 1.2.0 - p-limit: 2.3.0 package-manager-detector: 0.2.11 picocolors: 1.1.1 resolve-from: 5.0.0 @@ -3052,11 +3055,12 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@changesets/config@3.1.2': + '@changesets/config@3.1.4': dependencies: '@changesets/errors': 0.2.0 - '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-dependents-graph': 2.1.4 '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 @@ -3066,19 +3070,19 @@ snapshots: dependencies: extendable-error: 0.1.7 - '@changesets/get-dependents-graph@2.1.3': + '@changesets/get-dependents-graph@2.1.4': dependencies: '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 picocolors: 1.1.1 semver: 7.7.4 - '@changesets/get-release-plan@4.0.14': + '@changesets/get-release-plan@4.0.16': dependencies: - '@changesets/assemble-release-plan': 6.0.9 - '@changesets/config': 3.1.2 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 '@changesets/pre': 2.0.2 - '@changesets/read': 0.6.6 + '@changesets/read': 0.6.7 '@changesets/types': 6.1.0 '@manypkg/get-packages': 1.1.3 @@ -3096,7 +3100,7 @@ snapshots: dependencies: picocolors: 1.1.1 - '@changesets/parse@0.4.2': + '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 js-yaml: 4.1.1 @@ -3108,11 +3112,11 @@ snapshots: '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 - '@changesets/read@0.6.6': + '@changesets/read@0.6.7': dependencies: '@changesets/git': 3.0.4 '@changesets/logger': 0.1.1 - '@changesets/parse': 0.4.2 + '@changesets/parse': 0.4.3 '@changesets/types': 6.1.0 fs-extra: 7.0.1 p-filter: 2.1.0 @@ -3286,7 +3290,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.4 transitivePeerDependencies: - supports-color @@ -3307,7 +3311,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.4 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -3347,7 +3351,7 @@ snapshots: '@inquirer/external-editor@1.0.3(@types/node@24.10.13)': dependencies: chardet: 2.1.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 optionalDependencies: '@types/node': 24.10.13 @@ -3367,14 +3371,14 @@ snapshots: '@manypkg/find-root@1.1.0': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@types/node': 12.20.55 find-up: 4.1.0 fs-extra: 8.1.0 '@manypkg/get-packages@1.1.3': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@changesets/types': 4.1.0 '@manypkg/find-root': 1.1.0 fs-extra: 8.1.0 @@ -3415,79 +3419,79 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@rollup/rollup-android-arm-eabi@4.57.1': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.57.1': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.57.1': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.57.1': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.57.1': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.57.1': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.57.1': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.57.1': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.57.1': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.57.1': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.57.1': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.57.1': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.57.1': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.57.1': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.57.1': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.57.1': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.57.1': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.57.1': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-openbsd-x64@4.57.1': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-openharmony-arm64@4.57.1': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.57.1': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.57.1': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.57.1': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.57.1': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@shikijs/engine-oniguruma@3.23.0': @@ -3560,6 +3564,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -3649,7 +3655,7 @@ snapshots: '@typescript-eslint/types': 8.55.0 '@typescript-eslint/visitor-keys': 8.55.0 debug: 4.4.3 - minimatch: 9.0.5 + minimatch: 9.0.7 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -3673,58 +3679,60 @@ snapshots: '@typescript-eslint/types': 8.55.0 eslint-visitor-keys: 4.2.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.12 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.4 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2) + std-env: 4.2.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@24.10.13)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.10(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.10': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@zumer/snapdom@2.0.2': {} @@ -3811,7 +3819,7 @@ snapshots: assertion-error@2.0.1: {} - ast-v8-to-istanbul@0.3.12: + ast-v8-to-istanbul@1.0.4: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -3821,6 +3829,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} better-path-resolve@1.0.0: @@ -3852,9 +3862,9 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@5.0.7: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -3874,7 +3884,7 @@ snapshots: cac@6.7.14: {} - cachedir@2.3.0: {} + cachedir@2.4.0: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -3901,16 +3911,12 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chardet@0.7.0: {} - chardet@2.1.1: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 - ci-info@3.9.0: {} - class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -3954,9 +3960,9 @@ snapshots: commander@4.1.1: {} - commitizen@4.3.1(@types/node@24.10.13)(typescript@5.9.3): + commitizen@4.3.2(@types/node@24.10.13)(typescript@5.9.3): dependencies: - cachedir: 2.3.0 + cachedir: 2.4.0 cz-conventional-changelog: 3.3.0(@types/node@24.10.13)(typescript@5.9.3) dedent: 0.7.0 detect-indent: 6.1.0 @@ -3964,10 +3970,10 @@ snapshots: find-root: 1.1.0 fs-extra: 9.1.0 glob: 7.2.3 - inquirer: 8.2.5 + inquirer: 8.2.7(@types/node@24.10.13) is-utf8: 0.2.1 - lodash: 4.17.21 - minimist: 1.2.7 + lodash: 4.18.1 + minimist: 1.2.8 strip-bom: 4.0.0 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3994,6 +4000,8 @@ snapshots: meow: 13.2.0 optional: true + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -4046,7 +4054,7 @@ snapshots: cz-conventional-changelog@3.3.0(@types/node@24.10.13)(typescript@5.9.3): dependencies: chalk: 2.4.2 - commitizen: 4.3.1(@types/node@24.10.13)(typescript@5.9.3) + commitizen: 4.3.2(@types/node@24.10.13)(typescript@5.9.3) conventional-commit-types: 3.0.0 lodash.map: 4.6.0 longest: 2.0.1 @@ -4129,7 +4137,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.3.0: {} es-object-atoms@1.1.2: dependencies: @@ -4212,7 +4220,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.4 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: @@ -4240,7 +4248,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -4300,12 +4308,6 @@ snapshots: extendable-error@0.1.7: {} - external-editor@3.1.0: - dependencies: - chardet: 0.7.0 - iconv-lite: 0.4.24 - tmp: 0.0.33 - fake-indexeddb@6.2.5: {} fast-deep-equal@3.1.3: {} @@ -4328,9 +4330,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.5 figures@3.2.0: dependencies: @@ -4383,14 +4385,14 @@ snapshots: dependencies: magic-string: 0.30.21 mlly: 1.8.0 - rollup: 4.57.1 + rollup: 4.62.2 flat-cache@4.0.1: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 - flatted@3.3.3: {} + flatted@3.4.2: {} forwarded@0.2.0: {} @@ -4459,7 +4461,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.4 once: 1.4.0 path-is-absolute: 1.0.1 @@ -4547,18 +4549,10 @@ snapshots: husky@9.1.7: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -4595,15 +4589,15 @@ snapshots: ini@4.1.1: optional: true - inquirer@8.2.5: + inquirer@8.2.7(@types/node@24.10.13): dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@24.10.13) ansi-escapes: 4.3.2 chalk: 4.1.2 cli-cursor: 3.1.0 cli-width: 3.0.0 - external-editor: 3.1.0 figures: 3.2.0 - lodash: 4.17.21 + lodash: 4.18.1 mute-stream: 0.0.8 ora: 5.4.1 run-async: 2.4.1 @@ -4611,7 +4605,9 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 through: 2.3.8 - wrap-ansi: 7.0.0 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' ip-address@10.2.0: {} @@ -4710,7 +4706,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -4764,7 +4760,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.0: + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -4806,7 +4802,7 @@ snapshots: lodash.startcase@4.4.0: {} - lodash@4.17.21: {} + lodash@4.18.1: {} log-symbols@4.1.0: dependencies: @@ -4843,11 +4839,11 @@ snapshots: dependencies: semver: 7.7.4 - markdown-it@14.1.1: + markdown-it@14.3.0: dependencies: argparse: 2.0.1 entities: 4.5.0 - linkify-it: 5.0.0 + linkify-it: 5.0.2 mdurl: 2.0.0 punycode.js: 2.3.1 uc.micro: 2.1.0 @@ -4870,7 +4866,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.54.0: {} @@ -4884,15 +4880,19 @@ snapshots: min-indent@1.0.1: {} - minimatch@3.1.2: + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.4: dependencies: brace-expansion: 1.1.12 - minimatch@9.0.5: + minimatch@9.0.7: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 5.0.7 - minimist@1.2.7: {} + minimist@1.2.8: {} mlly@1.8.0: dependencies: @@ -4966,8 +4966,6 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 - os-tmpdir@1.0.2: {} - outdent@0.5.0: {} p-filter@2.1.0: @@ -5034,9 +5032,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.5: {} pidtree@0.6.0: {} @@ -5052,14 +5050,14 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.6 tsx: 4.21.0 - yaml: 2.8.2 + yaml: 2.9.0 postcss@8.5.6: dependencies: @@ -5174,35 +5172,35 @@ snapshots: rfdc@1.4.1: {} - rollup@4.57.1: + rollup@4.62.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.57.1 - '@rollup/rollup-android-arm64': 4.57.1 - '@rollup/rollup-darwin-arm64': 4.57.1 - '@rollup/rollup-darwin-x64': 4.57.1 - '@rollup/rollup-freebsd-arm64': 4.57.1 - '@rollup/rollup-freebsd-x64': 4.57.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 - '@rollup/rollup-linux-arm-musleabihf': 4.57.1 - '@rollup/rollup-linux-arm64-gnu': 4.57.1 - '@rollup/rollup-linux-arm64-musl': 4.57.1 - '@rollup/rollup-linux-loong64-gnu': 4.57.1 - '@rollup/rollup-linux-loong64-musl': 4.57.1 - '@rollup/rollup-linux-ppc64-gnu': 4.57.1 - '@rollup/rollup-linux-ppc64-musl': 4.57.1 - '@rollup/rollup-linux-riscv64-gnu': 4.57.1 - '@rollup/rollup-linux-riscv64-musl': 4.57.1 - '@rollup/rollup-linux-s390x-gnu': 4.57.1 - '@rollup/rollup-linux-x64-gnu': 4.57.1 - '@rollup/rollup-linux-x64-musl': 4.57.1 - '@rollup/rollup-openbsd-x64': 4.57.1 - '@rollup/rollup-openharmony-arm64': 4.57.1 - '@rollup/rollup-win32-arm64-msvc': 4.57.1 - '@rollup/rollup-win32-ia32-msvc': 4.57.1 - '@rollup/rollup-win32-x64-gnu': 4.57.1 - '@rollup/rollup-win32-x64-msvc': 4.57.1 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 router@2.2.0: @@ -5330,7 +5328,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.2.0: {} string-argv@0.3.2: {} @@ -5415,10 +5413,10 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} tldts-core@6.1.86: {} @@ -5426,10 +5424,6 @@ snapshots: dependencies: tldts-core: 6.1.86 - tmp@0.0.33: - dependencies: - os-tmpdir: 1.0.2 - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5454,7 +5448,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -5465,9 +5459,9 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0) resolve-from: 5.0.0 - rollup: 4.57.1 + rollup: 4.62.2 source-map: 0.7.6 sucrase: 3.35.1 tinyexec: 0.3.2 @@ -5528,14 +5522,14 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 - typedoc@0.28.17(typescript@5.9.3): + typedoc@0.28.20(typescript@5.9.3): dependencies: '@gerrit0/mini-shiki': 3.23.0 lunr: 2.3.9 - markdown-it: 14.1.1 - minimatch: 9.0.5 + markdown-it: 14.3.0 + minimatch: 10.2.5 typescript: 5.9.3 - yaml: 2.8.2 + yaml: 2.9.0 typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): dependencies: @@ -5570,58 +5564,49 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.3 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 postcss: 8.5.6 - rollup: 4.57.1 + rollup: 4.62.2 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.10.13 fsevents: 2.3.3 jiti: 2.6.1 tsx: 4.21.0 - yaml: 2.8.2 - - vitest@4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + yaml: 2.9.0 + + vitest@4.1.10(@types/node@24.10.13)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.5 + std-env: 4.2.0 tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + tinyrainbow: 3.1.0 + vite: 7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.10.13 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) jsdom: 26.1.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml w3c-xmlserializer@5.0.0: dependencies: @@ -5659,7 +5644,7 @@ snapshots: word-wrap@1.2.5: {} - wrap-ansi@7.0.0: + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 @@ -5673,7 +5658,7 @@ snapshots: wrappy@1.0.2: {} - ws@8.19.0: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} @@ -5681,6 +5666,8 @@ snapshots: yaml@2.8.2: {} + yaml@2.9.0: {} + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.2(zod@4.3.6): From 9fd9c9693f30bd364dcdf83700a7419d311d8671 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:42:03 +0800 Subject: [PATCH 037/181] fix(pipeline): make IndexedDB blob refs atomic --- packages/pipeline/src/storage.test.ts | 187 +++++++++++++ packages/pipeline/src/storage.ts | 362 ++++++++++++++------------ 2 files changed, 387 insertions(+), 162 deletions(-) diff --git a/packages/pipeline/src/storage.test.ts b/packages/pipeline/src/storage.test.ts index 22480e0..e5941ac 100644 --- a/packages/pipeline/src/storage.test.ts +++ b/packages/pipeline/src/storage.test.ts @@ -277,6 +277,154 @@ describe("storage", () => { await expect(storage.listBlobs()).resolves.toEqual([]); }); + it("serializes concurrent indexeddb blob refs exactly once across connections", async () => { + const databaseName = createDbName(); + const first = new IndexedDbPipelineStorage(databaseName); + const second = new IndexedDbPipelineStorage(databaseName); + const sidA = "S-concurrent-blob-A"; + const sidB = "S-concurrent-blob-B"; + const hash = "7".repeat(64); + const blob = createBlob(hash, Uint8Array.from([7, 7, 7])); + + await Promise.all([first.assertReady(), second.assertReady()]); + await Promise.all([ + first.putSession({ ...SESSION_A, sid: sidA }), + second.putSession({ ...SESSION_B, sid: sidB }) + ]); + + await Promise.all( + Array.from({ length: 24 }, (_, index) => + (index % 2 === 0 ? first : second).putBlob(blob, sidA) + ) + ); + expect((await first.getBlob(hash))?.refCount).toBe(1); + + await Promise.all( + Array.from({ length: 24 }, (_, index) => + (index % 2 === 0 ? second : first).putBlob(blob, sidB) + ) + ); + expect((await second.getBlob(hash))?.refCount).toBe(2); + + await Promise.all([first.deleteSession(sidA), second.deleteSession(sidA)]); + expect((await first.getBlob(hash))?.refCount).toBe(1); + + await second.deleteSession(sidB); + expect(await first.getBlob(hash)).toBeUndefined(); + }); + + it("does not reattach blob refs while or after their indexeddb session is deleted", async () => { + const databaseName = createDbName(); + const first = new IndexedDbPipelineStorage(databaseName); + const second = new IndexedDbPipelineStorage(databaseName); + const sid = "S-concurrent-delete"; + const firstHash = "8".repeat(64); + const racingHash = "9".repeat(64); + + await Promise.all([first.assertReady(), second.assertReady()]); + await first.putSession({ ...SESSION_A, sid }); + await first.putBlob(createBlob(firstHash, Uint8Array.from([1])), sid); + + await Promise.allSettled([ + first.deleteSession(sid), + second.putBlob(createBlob(racingHash, Uint8Array.from([2])), sid) + ]); + + expect(await first.getSession(sid)).toBeUndefined(); + expect(await first.getBlob(firstHash)).toBeUndefined(); + expect(await first.getBlob(racingHash)).toBeUndefined(); + await expect( + second.putBlob(createBlob("a".repeat(64), Uint8Array.from([3])), sid) + ).rejects.toThrow(/missing session/i); + await expect(first.listBlobs()).resolves.toEqual([]); + }); + + it("rolls back the blob row when an indexeddb blob-ref write fails", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const sid = "S-put-rollback"; + const hash = "4".repeat(64); + const blob = createBlob(hash, Uint8Array.from([4, 4])); + const originalPut = IDBObjectStore.prototype.put; + + await storage.putSession({ ...SESSION_A, sid }); + const putSpy = vi.spyOn(IDBObjectStore.prototype, "put").mockImplementation(function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey + ): IDBRequest { + if (this.name === "blobRefs") { + throw new Error("simulated blobRefs write failure"); + } + + return key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); + }); + + try { + await expect(storage.putBlob(blob, sid)).rejects.toThrow(/simulated blobRefs/i); + } finally { + putSpy.mockRestore(); + } + + expect(await storage.getBlob(hash)).toBeUndefined(); + await storage.putBlob(blob, sid); + expect((await storage.getBlob(hash))?.refCount).toBe(1); + await storage.deleteSession(sid); + expect(await storage.getBlob(hash)).toBeUndefined(); + }); + + it("rolls back every indexeddb session store when atomic deletion fails", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const sid = "S-delete-rollback"; + const hash = "5".repeat(64); + const chunk = createChunk(sid, "C-rollback", 1, "rollback-event"); + const indexes = { + time: [chunkMeta("C-rollback", 1)], + request: [], + inverted: [] + }; + const integrity = { + manifestSha256: "6".repeat(64), + files: {} + }; + + await storage.putSession({ ...SESSION_A, sid }); + await storage.putChunk(chunk); + await storage.putBlob(createBlob(hash, Uint8Array.from([5, 5])), sid); + await storage.putIndexes(sid, indexes); + await storage.putIntegrity(sid, integrity); + + const originalDelete = IDBObjectStore.prototype.delete; + const deleteSpy = vi.spyOn(IDBObjectStore.prototype, "delete").mockImplementation(function ( + this: IDBObjectStore, + query: IDBValidKey | IDBKeyRange + ): IDBRequest { + if (this.name === "integrity") { + throw new Error("simulated integrity delete failure"); + } + + return originalDelete.call(this, query); + }); + + try { + await expect(storage.deleteSession(sid)).rejects.toThrow(/simulated integrity/i); + } finally { + deleteSpy.mockRestore(); + } + + expect(await storage.getSession(sid)).toEqual(expect.objectContaining({ sid })); + expect(await storage.getChunk(sid, "C-rollback")).toEqual(chunk); + expect(await storage.getBlob(hash)).toEqual(expect.objectContaining({ refCount: 1 })); + expect(await storage.getIndexes(sid)).toEqual(indexes); + expect(await storage.getIntegrity(sid)).toEqual(integrity); + + await storage.deleteSession(sid); + expect(await storage.getSession(sid)).toBeUndefined(); + expect(await storage.getChunk(sid, "C-rollback")).toBeUndefined(); + expect(await storage.getBlob(hash)).toBeUndefined(); + expect((await storage.getIndexes(sid)).time).toEqual([]); + expect(await storage.getIntegrity(sid)).toBeUndefined(); + }); + it("supports legacy indexeddb layouts where chunks store has no sid/seq index", async () => { const sid = "S-legacy-layout"; const dbName = createDbName(); @@ -353,6 +501,44 @@ describe("storage", () => { warnSpy.mockRestore(); }); + it("retains quota recovery for atomic indexeddb blob writes", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const protectedSid = "S-quota-protected"; + const hash = "3".repeat(64); + const originalPut = IDBObjectStore.prototype.put; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + let injectQuotaFailure = true; + + await storage.putSession({ ...SESSION_A, sid: "S-quota-oldest", startedAt: 1 }); + await storage.putSession({ ...SESSION_B, sid: protectedSid, startedAt: 2 }); + + const putSpy = vi.spyOn(IDBObjectStore.prototype, "put").mockImplementation(function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey + ): IDBRequest { + if (this.name === "blobs" && injectQuotaFailure) { + injectQuotaFailure = false; + throw new DOMException("simulated quota pressure", "QuotaExceededError"); + } + + return key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); + }); + + try { + await storage.putBlob(createBlob(hash, Uint8Array.from([3, 3])), protectedSid); + } finally { + putSpy.mockRestore(); + warnSpy.mockRestore(); + } + + expect(await storage.getSession("S-quota-oldest")).toBeUndefined(); + expect(await storage.getSession(protectedSid)).toEqual( + expect.objectContaining({ sid: protectedSid }) + ); + expect(await storage.getBlob(hash)).toEqual(expect.objectContaining({ refCount: 1 })); + }); + it("fails fast when indexeddb runtime is unavailable", async () => { const originalIndexedDb = (globalThis as unknown as { indexedDB?: IDBFactory }).indexedDB; @@ -424,6 +610,7 @@ describe("storage", () => { expect(firstManagedKey.created).toBe(true); expect(firstManagedKey.key.extractable).toBe(false); await firstStorage.assertReady(); + await firstStorage.putSession({ ...SESSION_A, sid }); await firstStorage.putChunk(createChunk(sid, "C-secret", 1, `${eventSecret}\n`)); await firstStorage.putBlob(createBlob(hash, new TextEncoder().encode(blobSecret)), sid); diff --git a/packages/pipeline/src/storage.ts b/packages/pipeline/src/storage.ts index c483e2f..e02274c 100644 --- a/packages/pipeline/src/storage.ts +++ b/packages/pipeline/src/storage.ts @@ -689,45 +689,92 @@ export class IndexedDbPipelineStorage implements PipelineStorage { public async putBlob(blob: StoredBlob, sidHint?: string): Promise { const trackingSid = normalizeTrackingSid(sidHint); - if (trackingSid && (await this.hasTrackedBlobHashForSession(trackingSid, blob.hash))) { - return; + if (trackingSid && !SHA256_HEX_PATTERN.test(blob.hash)) { + throw new Error("A session-tracked pipeline blob must have a SHA-256 hex hash."); } - const existing = await this.getBlob(blob.hash); + let attempt = 0; - if (existing) { - await this.put( - "blobs", - { - key: blob.hash, - value: { - ...existing, - refCount: existing.refCount + 1 + while (true) { + const db = await this.db(); + + try { + await runMultiStoreTransaction( + db, + ["sessions", "blobs", "blobRefs"], + "readwrite", + async (transaction) => { + const blobsStore = transaction.objectStore("blobs"); + const blobRefsStore = transaction.objectStore("blobRefs"); + const existingBlobRequest = requestToPromise( + blobsStore.get(blob.hash) + ); + + if (!trackingSid) { + const existingBlob = await existingBlobRequest; + blobsStore.put({ + key: blob.hash, + value: existingBlob + ? { + ...existingBlob.value, + refCount: existingBlob.value.refCount + 1 + } + : blob + } satisfies BlobRow); + return; + } + + const [session, existingBlob, existingRefs] = await Promise.all([ + requestToPromise( + transaction.objectStore("sessions").get(trackingSid) + ), + existingBlobRequest, + requestToPromise(blobRefsStore.get(trackingSid)) + ]); + + if (!session) { + throw new Error( + `Cannot attach pipeline blob ${blob.hash} to missing session ${trackingSid}.` + ); + } + + const trackedHashes = normalizeBlobHashes(existingRefs?.value ?? []); + + if (trackedHashes.includes(blob.hash)) { + return; + } + + blobsStore.put({ + key: blob.hash, + value: existingBlob + ? { + ...existingBlob.value, + refCount: existingBlob.value.refCount + 1 + } + : { + ...blob, + refCount: 1 + } + } satisfies BlobRow); + blobRefsStore.put({ + key: trackingSid, + value: [...trackedHashes, blob.hash] + } satisfies BlobRefsRow); } - }, - { - allowQuotaRecovery: false + ); + return; + } catch (error) { + if (!isQuotaExceededError(error) || attempt >= MAX_QUOTA_RECOVERY_ATTEMPTS) { + throw error; } - ); - if (trackingSid) { - await this.trackBlobHashForSession(trackingSid, blob.hash); - } - return; - } - await this.put( - "blobs", - { - key: blob.hash, - value: blob - }, - { - allowQuotaRecovery: true, - protectedSid: sidHint + attempt += 1; + const recovered = await this.recoverQuotaPressure(trackingSid ?? undefined); + + if (!recovered) { + throw error; + } } - ); - if (trackingSid) { - await this.trackBlobHashForSession(trackingSid, blob.hash); } } @@ -780,25 +827,62 @@ export class IndexedDbPipelineStorage implements PipelineStorage { } public async deleteSession(sid: string, blobHashes: string[] = []): Promise { - const trackedBlobHashes = await this.getTrackedBlobHashes(sid); - const mergedBlobHashes = mergeBlobHashes(blobHashes, trackedBlobHashes); const db = await this.db(); - await runTransaction(db, "sessions", "readwrite", (store) => { - return requestToPromise(store.delete(sid)); - }); - await runTransaction(db, "indexes", "readwrite", (store) => { - return requestToPromise(store.delete(sid)); - }); - await runTransaction(db, "integrity", "readwrite", (store) => { - return requestToPromise(store.delete(sid)); - }); - await this.deleteChunksBySid(sid); - await this.deleteTrackedBlobHashes(sid); + await runMultiStoreTransaction( + db, + ["sessions", "chunks", "blobs", "blobRefs", "indexes", "integrity"], + "readwrite", + async (transaction) => { + const sessionsStore = transaction.objectStore("sessions"); + const chunksStore = transaction.objectStore("chunks"); + const blobsStore = transaction.objectStore("blobs"); + const blobRefsStore = transaction.objectStore("blobRefs"); + const indexesStore = transaction.objectStore("indexes"); + const integrityStore = transaction.objectStore("integrity"); + const [session, trackedRefs, chunks] = await Promise.all([ + requestToPromise(sessionsStore.get(sid)), + requestToPromise(blobRefsStore.get(sid)), + deleteChunksBySidInTransaction(chunksStore, sid) + ]); + const inferredBlobHashes = collectBlobHashesFromChunks(chunks); + const ownedBlobHashes = trackedRefs + ? normalizeBlobHashes(trackedRefs.value) + : session || chunks.length > 0 + ? mergeBlobHashes(blobHashes, [...inferredBlobHashes]) + : []; + + const storedBlobs = await Promise.all( + ownedBlobHashes.map((hash) => requestToPromise(blobsStore.get(hash))) + ); - for (const hash of mergedBlobHashes) { - await this.decrementOrDeleteBlob(hash); - } + for (let index = 0; index < ownedBlobHashes.length; index += 1) { + const hash = ownedBlobHashes[index]; + const storedBlob = storedBlobs[index]; + + if (!hash || !storedBlob) { + continue; + } + + if (storedBlob.value.refCount <= 1) { + blobsStore.delete(hash); + } else { + blobsStore.put({ + key: hash, + value: { + ...storedBlob.value, + refCount: storedBlob.value.refCount - 1 + } + } satisfies BlobRow); + } + } + + sessionsStore.delete(sid); + indexesStore.delete(sid); + integrityStore.delete(sid); + blobRefsStore.delete(sid); + } + ); } private chunkKey(sid: string, chunkId: string): string { @@ -906,15 +990,10 @@ export class IndexedDbPipelineStorage implements PipelineStorage { return null; } - await this.deleteSessionWithBlobCleanup(oldest.sid); + await this.deleteSession(oldest.sid); return oldest.sid; } - private async deleteSessionWithBlobCleanup(sid: string): Promise { - const blobHashes = await this.resolveBlobHashesForSession(sid); - await this.deleteSession(sid, blobHashes); - } - private open(): Promise { if (!globalThis.indexedDB) { return Promise.reject(new Error("indexedDB is unavailable in this runtime")); @@ -959,111 +1038,6 @@ export class IndexedDbPipelineStorage implements PipelineStorage { }; }); } - - private async deleteChunksBySid(sid: string): Promise { - const db = await this.db(); - - await runTransaction(db, "chunks", "readwrite", (store) => { - if (store.indexNames.contains(CHUNKS_BY_SID_SEQ_INDEX)) { - const index = store.index(CHUNKS_BY_SID_SEQ_INDEX); - const range = IDBKeyRange.bound([sid, 0], [sid, Number.MAX_SAFE_INTEGER]); - return deleteByCursor(index.openCursor(range)); - } - - return requestToPromise(store.getAll()).then(async (rows) => { - for (const row of rows) { - if (row.value.sid !== sid) { - continue; - } - - await requestToPromise(store.delete(row.key)); - } - }); - }); - } - - private async resolveBlobHashesForSession(sid: string): Promise { - const tracked = await this.getTrackedBlobHashes(sid); - - if (tracked.length > 0) { - return tracked; - } - - const chunks = await this.listChunks(sid); - return [...collectBlobHashesFromChunks(chunks)]; - } - - private async trackBlobHashForSession(sid: string, hash: string): Promise { - if (!SHA256_HEX_PATTERN.test(hash)) { - return; - } - - const existing = await this.get("blobRefs", sid); - const next = mergeBlobHashes(existing?.value ?? [], [hash]); - - await this.put( - "blobRefs", - { - key: sid, - value: next - }, - { - allowQuotaRecovery: true, - protectedSid: sid - } - ); - } - - private async getTrackedBlobHashes(sid: string): Promise { - const row = await this.get("blobRefs", sid); - return normalizeBlobHashes(row?.value ?? []); - } - - private async hasTrackedBlobHashForSession(sid: string, hash: string): Promise { - if (!SHA256_HEX_PATTERN.test(hash)) { - return false; - } - - const tracked = await this.getTrackedBlobHashes(sid); - return tracked.includes(hash); - } - - private async deleteTrackedBlobHashes(sid: string): Promise { - const db = await this.db(); - await runTransaction(db, "blobRefs", "readwrite", (store) => { - return requestToPromise(store.delete(sid)); - }); - } - - private async decrementOrDeleteBlob(hash: string): Promise { - const existing = await this.getBlob(hash); - - if (!existing) { - return; - } - - if (existing.refCount <= 1) { - const db = await this.db(); - await runTransaction(db, "blobs", "readwrite", (store) => { - return requestToPromise(store.delete(hash)); - }); - return; - } - - await this.put( - "blobs", - { - key: hash, - value: { - ...existing, - refCount: existing.refCount - 1 - } - }, - { - allowQuotaRecovery: false - } - ); - } } function isQuotaExceededError(error: unknown): boolean { @@ -1332,6 +1306,42 @@ async function runTransaction( return result; } +async function runMultiStoreTransaction( + db: IDBDatabase, + storeNames: string[], + mode: IDBTransactionMode, + handler: (transaction: IDBTransaction) => TResult | Promise +): Promise { + const transaction = db.transaction(storeNames, mode); + const completion = new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => + reject(transaction.error ?? new Error("IndexedDB transaction failed")); + transaction.onabort = () => + reject(transaction.error ?? new Error("IndexedDB transaction aborted")); + }); + + // Attach a rejection handler immediately. The handler can await IndexedDB requests while the + // transaction aborts independently, and leaving `completion` temporarily unobserved would + // otherwise surface an unhandled rejection. + void completion.catch(() => undefined); + + try { + const result = await handler(transaction); + await completion; + return result; + } catch (error) { + try { + transaction.abort(); + } catch { + // The transaction may already have committed or aborted because of a request failure. + } + + await completion.catch(() => undefined); + throw error; + } +} + function requestToPromise(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => { @@ -1344,8 +1354,34 @@ function requestToPromise(request: IDBRequest): Promise): Promise { - return new Promise((resolve, reject) => { +function deleteChunksBySidInTransaction( + store: IDBObjectStore, + sid: string +): Promise { + if (!store.indexNames.contains(CHUNKS_BY_SID_SEQ_INDEX)) { + return requestToPromise(store.getAll()).then((rows) => { + const chunks: StoredChunk[] = []; + + for (const row of rows) { + if (row.value.sid !== sid) { + continue; + } + + chunks.push(row.value); + store.delete(row.key); + } + + return chunks; + }); + } + + const index = store.index(CHUNKS_BY_SID_SEQ_INDEX); + const range = IDBKeyRange.bound([sid, 0], [sid, Number.MAX_SAFE_INTEGER]); + const request = index.openCursor(range); + + return new Promise((resolve, reject) => { + const chunks: StoredChunk[] = []; + request.onerror = () => { reject(request.error ?? new Error("IndexedDB cursor iteration failed")); }; @@ -1354,10 +1390,12 @@ function deleteByCursor(request: IDBRequest): Promise const cursor = request.result; if (!cursor) { - resolve(); + resolve(chunks); return; } + const row = cursor.value as ChunkRow; + chunks.push(row.value); cursor.delete(); cursor.continue(); }; From a2431cf78b1348ea34a161ff7f3a74c436794c67 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:56:58 +0800 Subject: [PATCH 038/181] fix(player): require secure share server URLs --- apps/player/src/lib/share.test.ts | 44 ++++++++++++++- apps/player/src/lib/share.ts | 92 +++++++++++++++++++++++++++---- docs/SECURITY.md | 2 +- 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/apps/player/src/lib/share.test.ts b/apps/player/src/lib/share.test.ts index 483fb7b..f5cdede 100644 --- a/apps/player/src/lib/share.test.ts +++ b/apps/player/src/lib/share.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { resolveShareArchiveRequest } from "./share.js"; +import { normalizeShareServerBaseUrl, resolveShareArchiveRequest } from "./share.js"; describe("resolveShareArchiveRequest", () => { it("resolves a share id against the configured server", () => { @@ -42,4 +42,46 @@ describe("resolveShareArchiveRequest", () => { resolveShareArchiveRequest("https://share.example.test/other/path", "https://fallback") ).toBeNull(); }); + + it.each([ + "http://share.example.test/share/abcdefgh", + "ftp://share.example.test/share/abcdefgh", + "file:///share/abcdefgh", + "https://user:secret@share.example.test/share/abcdefgh" + ])("rejects an unsafe share reference %s", (reference) => { + expect(resolveShareArchiveRequest(reference, "https://fallback.invalid")).toBeNull(); + }); + + it("rejects an id when its fallback server is unsafe", () => { + expect(resolveShareArchiveRequest("abcdefgh", "http://share.example.test")).toBeNull(); + }); + + it("rejects query credentials that cannot be sent as safe headers", () => { + expect( + resolveShareArchiveRequest( + "https://share.example.test/share/abcdefgh?key=line%0Abreak", + "https://fallback.invalid" + ) + ).toBeNull(); + }); +}); + +describe("normalizeShareServerBaseUrl", () => { + it.each([ + ["https://share.example.test/path?ignored=true", "https://share.example.test"], + ["http://localhost:8787", "http://localhost:8787"], + ["http://127.42.0.9:8787", "http://127.42.0.9:8787"], + ["http://[::1]:8787", "http://[::1]:8787"] + ])("normalizes a safe server URL %s", (input, expected) => { + expect(normalizeShareServerBaseUrl(input)).toBe(expected); + }); + + it.each([ + "http://share.example.test", + "ws://localhost:8787", + "javascript:alert(1)", + "https://user:secret@share.example.test" + ])("rejects an unsafe server URL %s", (input) => { + expect(normalizeShareServerBaseUrl(input)).toBeNull(); + }); }); diff --git a/apps/player/src/lib/share.ts b/apps/player/src/lib/share.ts index 117b7c8..aefe4ef 100644 --- a/apps/player/src/lib/share.ts +++ b/apps/player/src/lib/share.ts @@ -16,7 +16,12 @@ export function resolveShareArchiveRequest( } if (/^[a-zA-Z0-9_-]{8,}$/.test(trimmed)) { - const baseUrl = fallbackBaseUrl; + const baseUrl = normalizeShareServerBaseUrl(fallbackBaseUrl); + + if (!baseUrl) { + return null; + } + return { shareId: trimmed, baseUrl, @@ -32,16 +37,27 @@ export function resolveShareArchiveRequest( return null; } + const baseUrl = safeShareServerOrigin(parsed); + + if (!baseUrl) { + return null; + } + + const queryApiKey = readQueryApiKey(parsed); + + if (queryApiKey === null) { + return null; + } + const sharePageMatch = /^\/share\/([a-zA-Z0-9_-]+)$/.exec(parsed.pathname); if (sharePageMatch?.[1]) { const shareId = sharePageMatch[1]; - const baseUrl = parsed.origin; return { shareId, baseUrl, archiveUrl: `${baseUrl}/api/share/${encodeURIComponent(shareId)}/archive`, - ...readQueryApiKey(parsed) + ...queryApiKey }; } @@ -50,9 +66,9 @@ export function resolveShareArchiveRequest( if (archiveMatch?.[1]) { return { shareId: archiveMatch[1], - baseUrl: parsed.origin, - archiveUrl: `${parsed.origin}${parsed.pathname}`, - ...readQueryApiKey(parsed) + baseUrl, + archiveUrl: `${baseUrl}${parsed.pathname}`, + ...queryApiKey }; } @@ -62,9 +78,9 @@ export function resolveShareArchiveRequest( const shareId = metadataMatch[1]; return { shareId, - baseUrl: parsed.origin, - archiveUrl: `${parsed.origin}/api/share/${encodeURIComponent(shareId)}/archive`, - ...readQueryApiKey(parsed) + baseUrl, + archiveUrl: `${baseUrl}/api/share/${encodeURIComponent(shareId)}/archive`, + ...queryApiKey }; } @@ -80,7 +96,7 @@ export function normalizeShareServerBaseUrl(value: string): string | null { try { const url = new URL(trimmed); - return url.origin; + return safeShareServerOrigin(url); } catch { return null; } @@ -104,13 +120,65 @@ export function resolveShareServerOrigin(value: string | null): string | null { } } -function readQueryApiKey(url: URL): Pick { +function safeShareServerOrigin(url: URL): string | null { + if (url.username.length > 0 || url.password.length > 0) { + return null; + } + + if (url.protocol === "https:") { + return url.origin; + } + + if (url.protocol === "http:" && isLoopbackHostname(url.hostname)) { + return url.origin; + } + + return null; +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase(); + + if (normalized === "localhost" || normalized === "[::1]" || normalized === "::1") { + return true; + } + + const octets = normalized.split("."); + return ( + octets.length === 4 && + octets[0] === "127" && + octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) + ); +} + +function readQueryApiKey(url: URL): Pick | null { const key = url.searchParams.get("key"); if (!key) { return {}; } + const normalized = key.trim(); + + if ( + normalized.length === 0 || + normalized.length > 4_096 || + containsAsciiControlCharacter(normalized) + ) { + return null; + } + return { - queryApiKey: key + queryApiKey: normalized }; } + +function containsAsciiControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) { + return true; + } + } + + return false; +} diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 583b9cf..f510b98 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -29,7 +29,7 @@ Plaintext synthetic or local-debug export exemptions require a well-formed `capt ## Player Safety -The player treats archives as untrusted input. It does not load captured external resources by default, limits replay resources to inert local object/data URLs, revokes screenshot object URLs after a short TTL, and serves player/share views with no-referrer and restrictive CSP controls. +The player treats archives as untrusted input. It does not load captured external resources by default, limits replay resources to inert local object/data URLs, revokes screenshot object URLs after a short TTL, and serves player/share views with no-referrer and restrictive CSP controls. Remote share servers must use HTTPS; plaintext HTTP is accepted only for loopback development endpoints so share API credentials are not sent over an unprotected remote connection. ## Share Server From 3e77ee3f77d6c2338f4b1af7eec3e37923c33637 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 01:56:04 +0800 Subject: [PATCH 039/181] fix(extension): recover MV3 sessions across restarts --- apps/extension/CHANGELOG.md | 2 + apps/extension/README.md | 2 + .../extension/scripts/lib/extension-build.mjs | 12 +- apps/extension/src/shared/chrome-api.ts | 8 + .../src/shared/extension-build.test.mjs | 19 + apps/extension/src/sw/index.ts | 717 +++++++++++++++--- .../src/sw/runtime-state.integration.test.ts | 177 +++++ apps/extension/src/sw/runtime-state.test.ts | 218 ++++++ apps/extension/src/sw/runtime-state.ts | 613 +++++++++++++++ packages/pipeline/src/index.test.ts | 24 + packages/protocol/src/ids.ts | 8 + packages/protocol/src/index.test.ts | 3 + packages/recorder/src/action-span.ts | 13 + packages/recorder/src/index.test.ts | 19 + packages/recorder/src/recorder.ts | 17 + 15 files changed, 1746 insertions(+), 106 deletions(-) create mode 100644 apps/extension/src/sw/runtime-state.integration.test.ts create mode 100644 apps/extension/src/sw/runtime-state.test.ts create mode 100644 apps/extension/src/sw/runtime-state.ts diff --git a/apps/extension/CHANGELOG.md b/apps/extension/CHANGELOG.md index 76b5970..ee6a450 100644 --- a/apps/extension/CHANGELOG.md +++ b/apps/extension/CHANGELOG.md @@ -11,6 +11,8 @@ ### Patch Changes +- Restored active and stopped runtime sessions safely across MV3 service-worker restarts, with + strict policy-bound checkpoints and alarm-backed retention cleanup. - Required an export passphrase in the popup and sessions UI and removed the automatic plaintext local-export override from the service-worker/offscreen pipeline bridge. - Updated dependencies diff --git a/apps/extension/README.md b/apps/extension/README.md index d8fb829..704135d 100644 --- a/apps/extension/README.md +++ b/apps/extension/README.md @@ -95,6 +95,7 @@ The Chrome Web Store package uses the `store-safe` profile: | Permission | Purpose | | ------------ | ------------------------------------------------------ | | `activeTab` | Temporary page access after the user starts recording | +| `alarms` | Restart-safe local session retention cleanup | | `scripting` | Programmatic capture injection after that user gesture | | `storage` | Extension settings and session data | | `offscreen` | Pipeline processing in the background | @@ -106,6 +107,7 @@ The separately packaged development/enterprise profile adds the following broade | Permission | Purpose | | ------------ | ------------------------------------------------ | | `debugger` | CDP access for network, runtime, and page events | +| `alarms` | Restart-safe local session retention cleanup | | `tabs` | Tab information and URL access | | `scripting` | Content script injection | | `storage` | Extension settings and session data | diff --git a/apps/extension/scripts/lib/extension-build.mjs b/apps/extension/scripts/lib/extension-build.mjs index c38ee2c..88d2b52 100644 --- a/apps/extension/scripts/lib/extension-build.mjs +++ b/apps/extension/scripts/lib/extension-build.mjs @@ -61,6 +61,7 @@ export const EXTENSION_MANIFEST_PROFILES = ["dev", "store-safe"]; // and persistent host access comes from ``, so neither `cookies` // nor `activeTab` belongs in the dev/enterprise permission set. const DEV_PERMISSIONS = [ + "alarms", "debugger", "downloads", "offscreen", @@ -72,6 +73,7 @@ const DEV_PERMISSIONS = [ ]; const STORE_SAFE_PERMISSIONS = [ "activeTab", + "alarms", "downloads", "offscreen", "scripting", @@ -423,6 +425,12 @@ function validateUniqueStringArray(value, fieldName, issues) { function validateStoreSafeManifest(manifest, issues) { const permissions = new Set(manifest?.permissions ?? []); + for (const permission of STORE_SAFE_PERMISSIONS) { + if (!permissions.has(permission)) { + issues.push(`Store-safe manifest must include '${permission}' permission.`); + } + } + if (permissions.has("debugger")) { issues.push("Store-safe manifest must not request the debugger permission."); } @@ -431,10 +439,6 @@ function validateStoreSafeManifest(manifest, issues) { issues.push("Store-safe manifest must not request persistent tabs or webRequest access."); } - if (!permissions.has("activeTab")) { - issues.push("Store-safe manifest must request activeTab for user-gesture scoped capture."); - } - if (Array.isArray(manifest?.host_permissions) && manifest.host_permissions.length > 0) { issues.push("Store-safe manifest must not declare persistent host_permissions."); } diff --git a/apps/extension/src/shared/chrome-api.ts b/apps/extension/src/shared/chrome-api.ts index 78e8e12..1027211 100644 --- a/apps/extension/src/shared/chrome-api.ts +++ b/apps/extension/src/shared/chrome-api.ts @@ -28,6 +28,14 @@ export type PortLike = { }; export type ChromeApi = { + alarms?: { + create(name: string, alarmInfo: { when: number }): void | Promise; + clear(name: string): boolean | Promise; + getAll?(): Promise>; + onAlarm: { + addListener(callback: (alarm: { name: string; scheduledTime?: number }) => void): void; + }; + }; action?: { setBadgeText(details: { text: string }): Promise; setBadgeBackgroundColor(details: { color: string }): Promise; diff --git a/apps/extension/src/shared/extension-build.test.mjs b/apps/extension/src/shared/extension-build.test.mjs index 9b2fbe0..e5993a0 100644 --- a/apps/extension/src/shared/extension-build.test.mjs +++ b/apps/extension/src/shared/extension-build.test.mjs @@ -49,6 +49,7 @@ describe("extension build manifest", () => { expect(manifest.key).toBeTypeOf("string"); expect(manifest.permissions).not.toContain("activeTab"); expect(manifest.permissions).not.toContain("cookies"); + expect(manifest.permissions).toContain("alarms"); expect(manifest.permissions).toContain("tabCapture"); expect(manifest).not.toHaveProperty("content_scripts"); expect(manifest.content_security_policy?.extension_pages).toContain("script-src 'self'"); @@ -65,6 +66,7 @@ describe("extension build manifest", () => { expect(manifest).not.toHaveProperty("key"); expect(manifest.permissions).toContain("activeTab"); + expect(manifest.permissions).toContain("alarms"); expect(manifest.permissions).toContain("tabCapture"); expect(manifest.permissions).not.toContain("debugger"); expect(manifest.permissions).not.toContain("tabs"); @@ -87,6 +89,23 @@ describe("extension build manifest", () => { expect(validateExtensionManifest(manifest, { version: "1.2.3", release: true })).toEqual([]); }); + it("rejects store-safe packages without restart-safe cleanup alarms", () => { + const manifest = createExtensionManifest({ + version: "1.2.3", + release: true, + profile: "store-safe" + }); + manifest.permissions = manifest.permissions.filter((permission) => permission !== "alarms"); + + expect( + validateExtensionManifest(manifest, { + version: "1.2.3", + release: true, + profile: "store-safe" + }) + ).toContain("Store-safe manifest must include 'alarms' permission."); + }); + it("rejects always-on content scripts in the development profile", () => { const manifest = createExtensionManifest({ version: "1.2.3", profile: "dev" }); manifest.content_scripts = [ diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index b63cd9f..dd51dce 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -101,6 +101,20 @@ import { parseStorageSnapshotMeta, type LocalStorageSnapshotMode } from "./storage-snapshot.js"; +import { + RuntimeCleanupScheduler, + RuntimeStartCoordinator, + capCleanupDeadline, + createRuntimeStateSnapshot, + evaluateActiveRuntimeRestoration, + extractPersistedRuntimeIdentities, + parseRuntimeStateSnapshot, + type PersistedActiveRuntime, + type PersistedRuntime, + type PersistedRuntimeCounters, + type PersistedScreenRecording, + type PersistedStoppedRuntime +} from "./runtime-state.js"; type SessionRuntime = { sid: string; @@ -150,7 +164,7 @@ type SessionRuntime = { queue: Promise; removeCdpListeners: Array<() => void>; heapSnapshotCapture: HeapSnapshotCaptureState | null; - cleanupTimer: ReturnType | null; + cleanupDeadline?: number; consentExpiryTimer: ReturnType | null; }; @@ -348,6 +362,7 @@ const chromeApi = getChromeApi(); const sessionsByTab = new Map(); const sessionsBySid = new Map(); +const sessionStartCoordinator = new RuntimeStartCoordinator(); const sessionAnnotations = new Map(); const connectedPorts = new Set(); let offscreenPort: PortLike | null = null; @@ -374,6 +389,8 @@ const offscreenSessionRecovery = new Map>(); let offscreenRequestSeq = 0; let freezeBadgeTimer: ReturnType | null = null; let liteWebRequestCaptureCleanup: (() => void) | null = null; +let runtimeStatePersistTail: Promise = Promise.resolve(); +let runtimeStatePersistTimer: ReturnType | null = null; const OFFSCREEN_PATH = "offscreen.html"; const SCREENSHOT_ACTION_COOLDOWN_MS = 2_000; @@ -447,7 +464,7 @@ const ACTIVE_SESSION_STORAGE_KEY = "webblackbox.runtime.sessions"; const SESSION_ANNOTATIONS_STORAGE_KEY = "webblackbox.runtime.sessionAnnotations"; const EXPORT_AUDIT_STORAGE_KEY = "webblackbox.audit.exports"; const EXPORT_AUDIT_MAX_EVENTS = 200; -const STOPPED_SESSION_TTL_MS = 10 * 60_000; +const RUNTIME_STATE_PERSIST_DEBOUNCE_MS = 500; const ACTION_SCREENSHOT_RAW_TYPES = new Set(["click", "dblclick", "submit", "marker"]); const STOP_DRAIN_CONTENT_RAW_TYPES = new Set([ "snapshot", @@ -470,7 +487,26 @@ const SCREEN_RECORDING_OFFSCREEN_SOURCE = "tab"; console.info("[WebBlackbox] service worker booted"); -void restoreRuntimeState(); +const runtimeCleanupScheduler = new RuntimeCleanupScheduler({ + alarms: chromeApi?.alarms, + onDue: async (sid) => { + const runtime = sessionsBySid.get(sid); + + if (runtime?.stoppedAt) { + await disposeStoppedSession(runtime); + } + } +}); +const runtimeStateRestorePromise = restoreRuntimeState().catch(async (error) => { + console.warn("[WebBlackbox] runtime state restoration failed closed", error); + await failClosedRuntimeRestoration(); +}); + +chromeApi?.alarms?.onAlarm.addListener((alarm) => { + void runtimeCleanupScheduler.handleAlarm(alarm.name).catch((error) => { + console.warn("[WebBlackbox] stopped-session cleanup alarm failed", error); + }); +}); chromeApi?.runtime?.onInstalled.addListener(() => { void setIdleBadge(); @@ -538,12 +574,14 @@ async function syncContentPortStateOnConnect(port: PortLike): Promise { const tabId = port.sender?.tab?.id; if (typeof tabId !== "number") { + sendInactiveContentPortState(port); return; } const runtime = sessionsByTab.get(tabId); if (!runtime || runtime.stoppedAt) { + sendInactiveContentPortState(port); return; } @@ -570,12 +608,14 @@ function syncContentPortRecordingState(port: PortLike): void { const tabId = port.sender?.tab?.id; if (typeof tabId !== "number") { + sendInactiveContentPortState(port); return; } const runtime = sessionsByTab.get(tabId); if (!runtime || runtime.stoppedAt) { + sendInactiveContentPortState(port); return; } @@ -588,6 +628,20 @@ function syncContentPortRecordingState(port: PortLike): void { sendContentPortRecordingState(port, runtime, true); } +function sendInactiveContentPortState(port: PortLike): void { + try { + port.postMessage({ + kind: "sw.recording-status", + active: false + }); + } catch (error) { + logPortSendFailure("sw.recording-status", error, { + tabId: port.sender?.tab?.id, + frameId: port.sender?.frameId + }); + } +} + function sendContentPortRecordingState( port: PortLike, runtime: SessionRuntime, @@ -683,6 +737,10 @@ async function handleInboundMessage( port?: PortLike, sender: MessageSenderLike | undefined = port?.sender ): Promise { + if (message.kind.startsWith("ui.")) { + await runtimeStateRestorePromise; + } + if (message.kind === "ui.start") { const tabId = await resolveUiActionTabId(message.tabId); @@ -908,6 +966,15 @@ async function startSession( tabId: number, mode: CaptureMode, options: { visualCapture?: FullModeVisualCapture } = {} +): Promise { + await runtimeStateRestorePromise; + await sessionStartCoordinator.run(tabId, () => startSessionUnlocked(tabId, mode, options)); +} + +async function startSessionUnlocked( + tabId: number, + mode: CaptureMode, + options: { visualCapture?: FullModeVisualCapture } ): Promise { const existing = sessionsByTab.get(tabId); @@ -959,93 +1026,22 @@ async function startSession( tags: [...annotation.tags] }; - const recorderPlugins = createDefaultRecorderPlugins(); const pipeline = createOffscreenPipelineClient(sid); await pipeline.start(metadata, recorderConfig.redaction, recorderConfig.capturePolicy); - - const runtime: SessionRuntime = { + const runtime = createSessionRuntime({ sid, tabId, mode, url: metadata.url, captureUrl: tabMetadata.captureUrl, title: metadata.title, - tags: [...annotation.tags], + tags: annotation.tags, note: annotation.note, config: recorderConfig, startedAt, - stoppedAt: undefined, - recorder: new WebBlackboxRecorder( - { - ...recorderConfig, - mode - }, - {}, - undefined, - recorderPlugins - ), pipeline, - cdpRouter: null, - enabledCdpSessions: new Set(), - cdpScope: createCdpCaptureScopeState( - recorderConfig.capturePolicy, - tabId, - tabMetadata.captureUrl - ), - authorizedContentFrames: new Set([0]), - requestMeta: new Map(), - screenshotInterval: null, - screenRecording: null, - lastPointer: null, - lastViewport: null, - lastActionScreenshotMono: Number.NEGATIVE_INFINITY, - lastIncidentCaptureAt: Number.NEGATIVE_INFINITY, - lastNavigationSnapshotAt: Number.NEGATIVE_INFINITY, - queueDepth: 0, - droppedBestEffortTasks: 0, - pipelineEventBuffer: [], - pipelineFlushTimer: null, - pipelineFlushQueued: false, - stopping: false, - responseBodyCaptures: 0, - responseBodyCaptureTimestamps: [], - capturedEventCount: 0, - capturedErrorCount: 0, - capturedSizeBytes: 0, - budgetAlertCount: 0, - performanceBudget, - networkBudgetSample: { - total: 0, - failed: 0 - }, - lastFreezeNotices: new Map(), - lastBudgetBreachAt: new Map(), - queue: Promise.resolve(), - removeCdpListeners: [], - heapSnapshotCapture: null, - cleanupTimer: null, - consentExpiryTimer: null - }; - - runtime.recorder = new WebBlackboxRecorder( - { - ...recorderConfig, - mode - }, - { - onEvent: (event) => { - updateSessionMetadataFromEvent(runtime, event); - trackSessionCounters(runtime, event); - evaluatePerformanceBudget(runtime, event); - enqueuePipelineEvent(runtime, event); - }, - onFreeze: (reason) => { - handleFreezeNotice(runtime, reason); - } - }, - undefined, - recorderPlugins - ); + performanceBudget + }); sessionsByTab.set(tabId, runtime); sessionsBySid.set(sid, runtime); @@ -1100,6 +1096,142 @@ async function startSession( notifyOffscreenPipelineStatus(); } +function createSessionRuntime(input: { + sid: string; + tabId: number; + mode: CaptureMode; + url: string; + captureUrl: string; + title?: string; + tags: readonly string[]; + note?: string; + config: typeof DEFAULT_RECORDER_CONFIG; + startedAt: number; + stoppedAt?: number; + cleanupDeadline?: number; + pipeline: SessionPipelineClient; + performanceBudget: PerformanceBudgetConfig; + counters?: PersistedRuntimeCounters; + screenRecording?: PersistedScreenRecording; +}): SessionRuntime { + const counters = input.counters ?? { + recorderEventSequence: 0, + recorderActionSequence: 0, + eventCount: 0, + errorCount: 0, + sizeBytes: 0, + budgetAlertCount: 0, + droppedBestEffortTasks: 0, + responseBodyCaptures: 0, + networkTotal: 0, + networkFailed: 0 + }; + const recorderPlugins = createDefaultRecorderPlugins(); + const runtimeRef: { current?: SessionRuntime } = {}; + const recorder = new WebBlackboxRecorder( + { + ...input.config, + mode: input.mode + }, + { + onEvent: (event) => { + const runtime = runtimeRef.current; + + if (!runtime) { + return; + } + + updateSessionMetadataFromEvent(runtime, event); + trackSessionCounters(runtime, event); + evaluatePerformanceBudget(runtime, event); + enqueuePipelineEvent(runtime, event); + }, + onFreeze: (reason) => { + if (runtimeRef.current) { + handleFreezeNotice(runtimeRef.current, reason); + } + } + }, + undefined, + recorderPlugins + ); + recorder.restoreSequenceState({ + event: counters.recorderEventSequence, + action: counters.recorderActionSequence + }); + + const runtime: SessionRuntime = { + sid: input.sid, + tabId: input.tabId, + mode: input.mode, + url: input.url, + captureUrl: input.captureUrl, + title: input.title, + tags: [...input.tags], + note: input.note, + config: input.config, + startedAt: input.startedAt, + stoppedAt: input.stoppedAt, + recorder, + pipeline: input.pipeline, + cdpRouter: null, + enabledCdpSessions: new Set(), + cdpScope: createCdpCaptureScopeState(input.config.capturePolicy, input.tabId, input.captureUrl), + authorizedContentFrames: new Set(input.stoppedAt ? [] : [0]), + requestMeta: new Map(), + screenshotInterval: null, + screenRecording: input.screenRecording + ? { + recordingId: input.screenRecording.recordingId, + source: SCREEN_RECORDING_OFFSCREEN_SOURCE, + startedAt: input.screenRecording.startedAt, + startedMono: input.screenRecording.startedMono, + mime: input.screenRecording.mime, + width: input.screenRecording.width, + height: input.screenRecording.height, + frameRate: input.screenRecording.frameRate, + chunks: new Array(input.screenRecording.chunkCount), + chunkCount: input.screenRecording.chunkCount, + sizeBytes: input.screenRecording.sizeBytes, + pendingWrites: new Set(), + stopPromise: null + } + : null, + lastPointer: null, + lastViewport: null, + lastActionScreenshotMono: Number.NEGATIVE_INFINITY, + lastIncidentCaptureAt: Number.NEGATIVE_INFINITY, + lastNavigationSnapshotAt: Number.NEGATIVE_INFINITY, + queueDepth: 0, + droppedBestEffortTasks: counters.droppedBestEffortTasks, + pipelineEventBuffer: [], + pipelineFlushTimer: null, + pipelineFlushQueued: false, + stopping: false, + responseBodyCaptures: counters.responseBodyCaptures, + responseBodyCaptureTimestamps: [], + capturedEventCount: counters.eventCount, + capturedErrorCount: counters.errorCount, + capturedSizeBytes: counters.sizeBytes, + budgetAlertCount: counters.budgetAlertCount, + performanceBudget: input.performanceBudget, + networkBudgetSample: { + total: counters.networkTotal, + failed: counters.networkFailed + }, + lastFreezeNotices: new Map(), + lastBudgetBreachAt: new Map(), + queue: Promise.resolve(), + removeCdpListeners: [], + heapSnapshotCapture: null, + cleanupDeadline: input.cleanupDeadline, + consentExpiryTimer: null + }; + runtimeRef.current = runtime; + + return runtime; +} + async function reloadRecordingTab(tabId: number): Promise { if (!chromeApi?.tabs?.reload) { throw new Error("Current Chrome API cannot reload the active tab."); @@ -1155,7 +1287,9 @@ async function stopSession(tabId: number): Promise { sessionsByTab.delete(runtime.tabId); uninstallLiteWebRequestCaptureIfUnused(); runtime.stoppedAt = Date.now(); - scheduleStoppedRuntimeCleanup(runtime); + runtime.cleanupDeadline = + runtime.stoppedAt + resolveRuntimeLocalTtlMs(runtime.config.capturePolicy); + await scheduleStoppedRuntimeCleanup(runtime); if (sessionsByTab.size === 0) { await setIdleBadge(); @@ -1788,11 +1922,13 @@ function trackSessionCounters(runtime: SessionRuntime, event: WebBlackboxEvent): if (event.type === "error.exception" || event.type === "error.unhandledrejection") { runtime.capturedErrorCount += 1; pushSessionList(); + scheduleRuntimeStatePersist(); return; } if (runtime.capturedEventCount % 50 === 0) { pushSessionList(); + scheduleRuntimeStatePersist(); } } @@ -1844,6 +1980,7 @@ function evaluatePerformanceBudget(runtime: SessionRuntime, event: WebBlackboxEv if (updated) { pushSessionList(); + scheduleRuntimeStatePersist(); } } @@ -4639,14 +4776,12 @@ async function cleanupCdpInstrumentation( runtime.heapSnapshotCapture = null; } -function scheduleStoppedRuntimeCleanup(runtime: SessionRuntime): void { - if (runtime.cleanupTimer !== null) { - clearTimeout(runtime.cleanupTimer); +async function scheduleStoppedRuntimeCleanup(runtime: SessionRuntime): Promise { + if (!runtime.stoppedAt || !runtime.cleanupDeadline) { + throw new Error("Stopped session is missing its absolute cleanup deadline."); } - runtime.cleanupTimer = setTimeout(() => { - void disposeStoppedSession(runtime); - }, STOPPED_SESSION_TTL_MS); + await runtimeCleanupScheduler.schedule(runtime.sid, runtime.cleanupDeadline); } async function disposeStoppedSession(runtime: SessionRuntime): Promise { @@ -4654,10 +4789,7 @@ async function disposeStoppedSession(runtime: SessionRuntime): Promise { return; } - if (runtime.cleanupTimer !== null) { - clearTimeout(runtime.cleanupTimer); - runtime.cleanupTimer = null; - } + await runtimeCleanupScheduler.cancel(runtime.sid).catch(() => undefined); await flushBufferedPipelineEvents(runtime); await runtime.queue; @@ -4668,6 +4800,9 @@ async function disposeStoppedSession(runtime: SessionRuntime): Promise { }) .catch(() => undefined); sessionsBySid.delete(runtime.sid); + if (sessionAnnotations.delete(runtime.sid)) { + await persistSessionAnnotations().catch(() => undefined); + } if (sessionsByTab.size === 0) { await setIdleBadge(); @@ -4681,6 +4816,19 @@ async function disposeStoppedSession(runtime: SessionRuntime): Promise { notifyOffscreenPipelineStatus(); } +function resolveRuntimeLocalTtlMs(policy: CapturePolicy | null | undefined): number { + const ttl = policy?.retention.localTtlMs; + + if (typeof ttl === "number" && Number.isSafeInteger(ttl) && ttl > 0) { + return ttl; + } + + return ( + DEFAULT_RECORDER_CONFIG.capturePolicy?.retention.localTtlMs ?? + DEFAULT_CAPTURE_POLICY.retention.localTtlMs + ); +} + async function closeOffscreenIfUnused(): Promise { if (sessionsBySid.size > 0) { return; @@ -4734,6 +4882,7 @@ function toSessionMetadata(runtime: SessionRuntime): SessionMetadata { sid: runtime.sid, tabId: runtime.tabId, startedAt: runtime.startedAt, + endedAt: runtime.stoppedAt, mode: runtime.mode, url: sanitizeUrlForPrivacy(runtime.url), title: runtime.title, @@ -4827,6 +4976,7 @@ async function updateSessionMetadataFromEventAsync( if (changed) { pushSessionList(); + scheduleRuntimeStatePersist(); } } @@ -4873,6 +5023,7 @@ async function handleTabUrlChanged(tabId: number, rawUrl: string): Promise runtime.cdpScope.captureUrl = rawUrl; resetAuthorizedContentFrames(runtime); pushSessionList(); + scheduleRuntimeStatePersist(); } } @@ -5101,6 +5252,7 @@ async function updateSessionAnnotation( }); await persistSessionAnnotations().catch(() => undefined); + await persistRuntimeState().catch(() => undefined); pushSessionList(); } @@ -5215,16 +5367,90 @@ async function persistRuntimeState(): Promise { return; } - const sessions = [...sessionsByTab.values()].map((runtime) => ({ + const snapshot = createRuntimeStateSnapshot([...sessionsBySid.values()].map(toPersistedRuntime)); + const operation = runtimeStatePersistTail + .catch(() => undefined) + .then(async () => { + await chromeApi.storage!.local.set({ + [ACTIVE_SESSION_STORAGE_KEY]: snapshot + }); + }); + runtimeStatePersistTail = operation; + await operation; +} + +function scheduleRuntimeStatePersist(): void { + if (runtimeStatePersistTimer !== null) { + return; + } + + runtimeStatePersistTimer = setTimeout(() => { + runtimeStatePersistTimer = null; + void persistRuntimeState().catch((error) => { + console.warn("[WebBlackbox] failed to persist runtime checkpoint", error); + }); + }, RUNTIME_STATE_PERSIST_DEBOUNCE_MS); +} + +function toPersistedRuntime(runtime: SessionRuntime): PersistedRuntime { + const recorderSequence = runtime.recorder.getSequenceState(); + const counters: PersistedRuntimeCounters = { + recorderEventSequence: recorderSequence.event, + recorderActionSequence: recorderSequence.action, + eventCount: runtime.capturedEventCount, + errorCount: runtime.capturedErrorCount, + sizeBytes: runtime.capturedSizeBytes, + budgetAlertCount: runtime.budgetAlertCount, + droppedBestEffortTasks: runtime.droppedBestEffortTasks, + responseBodyCaptures: runtime.responseBodyCaptures, + networkTotal: runtime.networkBudgetSample.total, + networkFailed: runtime.networkBudgetSample.failed + }; + const screenRecording: PersistedScreenRecording | undefined = runtime.screenRecording + ? { + recordingId: runtime.screenRecording.recordingId, + startedAt: runtime.screenRecording.startedAt, + startedMono: runtime.screenRecording.startedMono, + mime: runtime.screenRecording.mime, + ...(runtime.screenRecording.width ? { width: runtime.screenRecording.width } : {}), + ...(runtime.screenRecording.height ? { height: runtime.screenRecording.height } : {}), + ...(runtime.screenRecording.frameRate + ? { frameRate: runtime.screenRecording.frameRate } + : {}), + chunkCount: runtime.screenRecording.chunkCount, + sizeBytes: runtime.screenRecording.sizeBytes + } + : undefined; + const base = { sid: runtime.sid, tabId: runtime.tabId, mode: runtime.mode, - startedAt: runtime.startedAt - })); + url: sanitizeUrlForPrivacy(runtime.url), + ...(runtime.title ? { title: runtime.title.slice(0, 1_024) } : {}), + tags: normalizeSessionTags(runtime.tags), + ...(runtime.note ? { note: normalizeSessionNote(runtime.note) } : {}), + config: runtime.config, + startedAt: runtime.startedAt, + counters, + ...(screenRecording ? { screenRecording } : {}) + }; - await chromeApi.storage.local.set({ - [ACTIVE_SESSION_STORAGE_KEY]: sessions - }); + if (runtime.stoppedAt) { + const cleanupDeadline = + runtime.cleanupDeadline ?? + runtime.stoppedAt + resolveRuntimeLocalTtlMs(runtime.config.capturePolicy); + return { + ...base, + state: "stopped", + stoppedAt: runtime.stoppedAt, + cleanupDeadline + }; + } + + return { + ...base, + state: "active" + }; } async function appendExportAuditEvent(event: ExportAuditEvent): Promise { @@ -5260,22 +5486,117 @@ async function restoreRuntimeState(): Promise { const values = await chromeApi.storage.local.get(ACTIVE_SESSION_STORAGE_KEY); const persisted = values?.[ACTIVE_SESSION_STORAGE_KEY]; + const snapshot = parseRuntimeStateSnapshot(persisted); - if (Array.isArray(persisted) && persisted.length > 0) { + if (persisted !== undefined && !snapshot) { + const identities = extractPersistedRuntimeIdentities(persisted); + sessionAnnotations.clear(); + await persistSessionAnnotations().catch(() => undefined); await chromeApi.storage.local .set({ - [ACTIVE_SESSION_STORAGE_KEY]: [] + [ACTIVE_SESSION_STORAGE_KEY]: createRuntimeStateSnapshot([]) }) .catch(() => undefined); + await clearStaleRuntimeCleanupAlarms(new Set()); + + for (const identity of identities) { + await notifyTabStatus(identity.tabId, false); + await purgePersistedRuntimeData({ + ...identity, + mode: "lite", + url: "https://invalid.invalid/", + title: undefined, + tags: [], + config: withSessionCapturePolicy(resolveModeBaseConfig("lite"), { + tabId: identity.tabId, + origin: "https://invalid.invalid", + startedAt: identity.startedAt + }) + }); + } + } else if (snapshot) { + const enterprisePolicy = await loadEnterprisePolicy(); + const performanceBudget = await loadPerformanceBudgetConfig(); + const stoppedRuntimes: SessionRuntime[] = []; + + for (const persistedRuntime of snapshot.sessions) { + const config = applyEnterprisePolicyToRecorderConfig( + persistedRuntime.config, + enterprisePolicy + ); + + try { + if (persistedRuntime.state === "stopped") { + const runtime = await restoreStoppedRuntime(persistedRuntime, config, performanceBudget); + stoppedRuntimes.push(runtime); + continue; + } + + const restored = await restoreActiveRuntime(persistedRuntime, config, performanceBudget); + + if (!restored) { + await notifyTabStatus(persistedRuntime.tabId, false); + await purgePersistedRuntimeData({ ...persistedRuntime, config }); + } + } catch (error) { + console.warn("[WebBlackbox] failed closed while restoring session", { + sid: persistedRuntime.sid, + error: error instanceof Error ? error.message : String(error) + }); + await notifyTabStatus(persistedRuntime.tabId, false); + await purgePersistedRuntimeData({ ...persistedRuntime, config }); + } + } - for (const item of persisted) { - const row = asRecord(item); - const tabId = typeof row?.tabId === "number" ? row.tabId : undefined; + await clearStaleRuntimeCleanupAlarms(new Set(stoppedRuntimes.map((runtime) => runtime.sid))); - if (typeof tabId === "number") { - await notifyTabStatus(tabId, false); + for (const runtime of stoppedRuntimes) { + if (sessionsBySid.get(runtime.sid) === runtime) { + await scheduleStoppedRuntimeCleanup(runtime); } } + + await persistRuntimeState(); + } + + if (sessionsByTab.size > 0) { + await setRecordingBadge(); + } else { + await setIdleBadge(); + } + pushSessionList(); + notifyOffscreenPipelineStatus(); +} + +async function failClosedRuntimeRestoration(): Promise { + const runtimes = [...sessionsBySid.values()]; + sessionsByTab.clear(); + sessionsBySid.clear(); + sessionAnnotations.clear(); + uninstallLiteWebRequestCaptureIfUnused(); + + for (const runtime of runtimes) { + await runtimeCleanupScheduler.cancel(runtime.sid).catch(() => undefined); + + if (!runtime.stoppedAt) { + await notifyAuthorizedContentFrames(runtime, false).catch(() => undefined); + await teardownCaptureInstrumentation(runtime).catch(() => undefined); + } + + await runtime.pipeline.close({ purge: true }).catch(() => undefined); + } + + await chromeApi?.storage?.local + ?.set({ + [ACTIVE_SESSION_STORAGE_KEY]: createRuntimeStateSnapshot([]) + }) + .catch(() => undefined); + await persistSessionAnnotations().catch(() => undefined); + + for (const port of connectedPorts) { + if (port.name === PORT_NAMES.content) { + sendInactiveContentPortState(port); + } } await setIdleBadge(); @@ -5283,6 +5604,198 @@ async function restoreRuntimeState(): Promise { notifyOffscreenPipelineStatus(); } +async function restoreActiveRuntime( + persisted: PersistedActiveRuntime, + config: typeof DEFAULT_RECORDER_CONFIG, + performanceBudget: PerformanceBudgetConfig +): Promise { + let tab: { + id?: number; + active?: boolean; + url?: string; + title?: string; + lastAccessed?: number; + } | null = null; + + try { + tab = chromeApi?.tabs?.get ? await chromeApi.tabs.get(persisted.tabId) : null; + } catch { + tab = null; + } + + const candidate: PersistedActiveRuntime = { + ...persisted, + config + }; + const decision = evaluateActiveRuntimeRestoration(candidate, tab, Date.now()); + + if (!decision.allowed) { + console.warn("[WebBlackbox] active session checkpoint rejected", { + sid: persisted.sid, + reason: decision.reason + }); + return null; + } + + await ensureOffscreenDocument(); + const pipeline = createOffscreenPipelineClient(persisted.sid); + await pipeline.start( + { + sid: persisted.sid, + tabId: persisted.tabId, + startedAt: persisted.startedAt, + mode: persisted.mode, + url: decision.sanitizedUrl, + title: persisted.title, + tags: [...persisted.tags] + }, + config.redaction, + config.capturePolicy + ); + const runtime = createSessionRuntime({ + sid: persisted.sid, + tabId: persisted.tabId, + mode: persisted.mode, + url: decision.sanitizedUrl, + captureUrl: decision.captureUrl, + title: + typeof tab?.title === "string" && tab.title.trim().length > 0 + ? tab.title.trim().slice(0, 1_024) + : persisted.title, + tags: persisted.tags, + note: persisted.note, + config, + startedAt: persisted.startedAt, + pipeline, + performanceBudget, + counters: persisted.counters, + screenRecording: persisted.screenRecording + }); + sessionsByTab.set(runtime.tabId, runtime); + sessionsBySid.set(runtime.sid, runtime); + scheduleConsentExpiry(runtime); + + if (runtime.mode === "lite") { + installLiteWebRequestCapture(); + } + + if (shouldInjectHooksForMode(runtime.mode)) { + await ensureContentScriptInjected(runtime); + await ensureInjectedHooks(runtime.tabId, 0); + } + + if (runtime.mode === "full" && runtime.config.capturePolicy?.categories.cdp !== "off") { + await attachCdp(runtime); + } + + await notifyAuthorizedContentFrames(runtime, true); + return runtime; +} + +async function restoreStoppedRuntime( + persisted: PersistedStoppedRuntime, + config: typeof DEFAULT_RECORDER_CONFIG, + performanceBudget: PerformanceBudgetConfig +): Promise { + const cleanupDeadline = capCleanupDeadline( + persisted.stoppedAt, + persisted.cleanupDeadline, + resolveRuntimeLocalTtlMs(config.capturePolicy) + ); + await ensureOffscreenDocument(); + const pipeline = createOffscreenPipelineClient(persisted.sid); + await pipeline.start( + { + sid: persisted.sid, + tabId: persisted.tabId, + startedAt: persisted.startedAt, + endedAt: persisted.stoppedAt, + mode: persisted.mode, + url: persisted.url, + title: persisted.title, + tags: [...persisted.tags] + }, + config.redaction, + config.capturePolicy + ); + const runtime = createSessionRuntime({ + sid: persisted.sid, + tabId: persisted.tabId, + mode: persisted.mode, + url: persisted.url, + captureUrl: persisted.url, + title: persisted.title, + tags: persisted.tags, + note: persisted.note, + config, + startedAt: persisted.startedAt, + stoppedAt: persisted.stoppedAt, + cleanupDeadline, + pipeline, + performanceBudget, + counters: persisted.counters + }); + sessionsBySid.set(runtime.sid, runtime); + return runtime; +} + +async function purgePersistedRuntimeData(runtime: { + sid: string; + tabId: number; + startedAt: number; + mode: CaptureMode; + url: string; + title?: string; + tags: readonly string[]; + config: typeof DEFAULT_RECORDER_CONFIG; +}): Promise { + if (sessionAnnotations.delete(runtime.sid)) { + await persistSessionAnnotations().catch(() => undefined); + } + + try { + await ensureOffscreenDocument(); + const pipeline = createOffscreenPipelineClient(runtime.sid); + await pipeline.start( + { + sid: runtime.sid, + tabId: runtime.tabId, + startedAt: runtime.startedAt, + mode: runtime.mode, + url: sanitizeUrlForPrivacy(runtime.url), + title: runtime.title, + tags: [...runtime.tags] + }, + runtime.config.redaction, + runtime.config.capturePolicy + ); + await pipeline.close({ purge: true }); + } catch (error) { + console.warn("[WebBlackbox] persisted session purge failed", { + sid: runtime.sid, + error: error instanceof Error ? error.message : String(error) + }); + } +} + +async function clearStaleRuntimeCleanupAlarms(validSids: ReadonlySet): Promise { + const alarms = await chromeApi?.alarms?.getAll?.().catch(() => []); + + for (const alarm of alarms ?? []) { + const prefix = "webblackbox.runtime.cleanup:"; + + if (!alarm.name.startsWith(prefix)) { + continue; + } + + const sid = alarm.name.slice(prefix.length); + + if (!validSids.has(sid)) { + await chromeApi?.alarms?.clear(alarm.name); + } + } +} + function notifyOffscreenPipelineStatus(): void { const port = offscreenPort; diff --git a/apps/extension/src/sw/runtime-state.integration.test.ts b/apps/extension/src/sw/runtime-state.integration.test.ts new file mode 100644 index 0000000..27faf97 --- /dev/null +++ b/apps/extension/src/sw/runtime-state.integration.test.ts @@ -0,0 +1,177 @@ +import { DEFAULT_RECORDER_CONFIG, type RecorderConfig } from "@webblackbox/protocol"; +import { describe, expect, it, vi } from "vitest"; + +import { + RuntimeCleanupScheduler, + capCleanupDeadline, + createRuntimeCleanupAlarmName, + createRuntimeStateSnapshot, + evaluateActiveRuntimeRestoration, + extractPersistedRuntimeIdentities, + parseRuntimeStateSnapshot, + type PersistedActiveRuntime, + type PersistedRuntimeCounters, + type PersistedStoppedRuntime +} from "./runtime-state.js"; + +const STARTED_AT = Date.parse("2026-06-01T00:00:00.000Z"); +const COUNTERS: PersistedRuntimeCounters = { + recorderEventSequence: 88, + recorderActionSequence: 12, + eventCount: 88, + errorCount: 2, + sizeBytes: 10_000, + budgetAlertCount: 1, + droppedBestEffortTasks: 0, + responseBodyCaptures: 3, + networkTotal: 30, + networkFailed: 2 +}; + +function config(tabId: number, expiresAt?: string): RecorderConfig { + const next = structuredClone(DEFAULT_RECORDER_CONFIG); + next.capturePolicy = { + ...next.capturePolicy!, + consent: { + ...next.capturePolicy!.consent, + grantedAt: new Date(STARTED_AT).toISOString(), + ...(expiresAt ? { expiresAt } : {}) + }, + scope: { + ...next.capturePolicy!.scope, + tabId, + origin: "https://app.example" + }, + redaction: structuredClone(next.redaction) + }; + return next; +} + +function active(expiresAt?: string): PersistedActiveRuntime { + return { + state: "active", + sid: "S-1780272000000-active", + tabId: 41, + mode: "lite", + url: "https://app.example/home", + title: "App", + tags: ["restart"], + config: config(41, expiresAt), + startedAt: STARTED_AT, + counters: { ...COUNTERS } + }; +} + +function stopped(): PersistedStoppedRuntime { + const stoppedAt = STARTED_AT + 60_000; + return { + ...active(), + state: "stopped", + sid: "S-1780272060000-stopped", + stoppedAt, + cleanupDeadline: stoppedAt + 86_400_000 + }; +} + +describe("service-worker restart recovery integration", () => { + it("restores a valid active checkpoint and re-arms stopped TTL cleanup after restart", async () => { + const rawStorageValue = structuredClone( + createRuntimeStateSnapshot([active(), stopped()], STARTED_AT + 120_000) + ); + const restored = parseRuntimeStateSnapshot(rawStorageValue); + expect(restored?.sessions).toHaveLength(2); + + const activeRow = restored!.sessions[0] as PersistedActiveRuntime; + expect( + evaluateActiveRuntimeRestoration( + activeRow, + { id: 41, url: "https://app.example/after-restart" }, + STARTED_AT + 120_000 + ) + ).toMatchObject({ allowed: true }); + expect(activeRow.counters.recorderEventSequence).toBe(88); + + let now = STARTED_AT + 120_000; + const alarmCreate = vi.fn(); + const purge = vi.fn(); + const scheduler = new RuntimeCleanupScheduler({ + alarms: { create: alarmCreate, clear: vi.fn().mockResolvedValue(true) }, + now: () => now, + setTimer: () => 1 as unknown as ReturnType, + onDue: purge + }); + const stoppedRow = restored!.sessions[1] as PersistedStoppedRuntime; + const cappedDeadline = capCleanupDeadline( + stoppedRow.stoppedAt, + stoppedRow.cleanupDeadline, + 3_600_000 + ); + + await scheduler.schedule(stoppedRow.sid, cappedDeadline); + expect(alarmCreate).toHaveBeenCalledWith(createRuntimeCleanupAlarmName(stoppedRow.sid), { + when: cappedDeadline + }); + now = cappedDeadline + 1; + await scheduler.handleAlarm(createRuntimeCleanupAlarmName(stoppedRow.sid)); + expect(purge).toHaveBeenCalledWith(stoppedRow.sid); + }); + + it("rejects missing tabs and expired consent instead of re-enabling capture", () => { + expect(evaluateActiveRuntimeRestoration(active(), null, STARTED_AT + 1)).toEqual({ + allowed: false, + reason: "tab-missing" + }); + + const expiresAt = new Date(STARTED_AT + 30_000).toISOString(); + expect( + evaluateActiveRuntimeRestoration( + active(expiresAt), + { id: 41, url: "https://app.example/home" }, + STARTED_AT + 30_001 + ) + ).toEqual({ allowed: false, reason: "consent-expired" }); + }); + + it("rejects a corrupt checkpoint while retaining only safe purge identities", () => { + const snapshot = createRuntimeStateSnapshot([active()]); + const corrupt = { + ...snapshot, + sessions: [{ ...snapshot.sessions[0], injectedKey: "must-fail-closed" }] + }; + + expect(parseRuntimeStateSnapshot(corrupt)).toBeNull(); + expect(extractPersistedRuntimeIdentities(corrupt)).toEqual([ + { + sid: "S-1780272000000-active", + tabId: 41, + startedAt: STARTED_AT + } + ]); + }); + + it("migrates legacy and unsupported storage versions by failing closed to purge identities", () => { + const legacy = [ + { + sid: "S-1780272000000-legacy", + tabId: 41, + mode: "lite", + startedAt: STARTED_AT + } + ]; + expect(parseRuntimeStateSnapshot(legacy)).toBeNull(); + expect(extractPersistedRuntimeIdentities(legacy)).toEqual([ + { + sid: "S-1780272000000-legacy", + tabId: 41, + startedAt: STARTED_AT + } + ]); + + const futureVersion = { + ...createRuntimeStateSnapshot([active()]), + schemaVersion: 2 + }; + expect(parseRuntimeStateSnapshot(futureVersion)).toBeNull(); + expect(extractPersistedRuntimeIdentities(futureVersion)).toHaveLength(1); + }); +}); diff --git a/apps/extension/src/sw/runtime-state.test.ts b/apps/extension/src/sw/runtime-state.test.ts new file mode 100644 index 0000000..c332c0b --- /dev/null +++ b/apps/extension/src/sw/runtime-state.test.ts @@ -0,0 +1,218 @@ +import { DEFAULT_RECORDER_CONFIG, type RecorderConfig } from "@webblackbox/protocol"; +import { describe, expect, it, vi } from "vitest"; + +import { + RuntimeCleanupScheduler, + RuntimeStartCoordinator, + capCleanupDeadline, + createRuntimeCleanupAlarmName, + createRuntimeStateSnapshot, + evaluateActiveRuntimeRestoration, + parseRuntimeStateSnapshot, + type PersistedActiveRuntime, + type PersistedRuntime +} from "./runtime-state.js"; + +function createConfig(tabId = 7, expiresAt?: string): RecorderConfig { + const policy = structuredClone(DEFAULT_RECORDER_CONFIG.capturePolicy!); + policy.scope = { + ...policy.scope, + tabId, + origin: "https://example.test" + }; + policy.consent = { + ...policy.consent, + grantedAt: "2026-01-01T00:00:00.000Z", + ...(expiresAt ? { expiresAt } : {}) + }; + + return { + ...structuredClone(DEFAULT_RECORDER_CONFIG), + capturePolicy: policy, + redaction: structuredClone(policy.redaction) + }; +} + +function createRuntime( + state: "active" | "stopped" = "active", + overrides: Partial = {} +): PersistedRuntime { + const base = { + state, + sid: "S-1767225600000-runtime", + tabId: 7, + mode: "lite" as const, + url: "https://example.test/path", + title: "Example", + tags: ["recovery"], + note: "restart test", + config: createConfig(), + startedAt: Date.parse("2026-01-01T00:00:00.000Z"), + counters: { + recorderEventSequence: 30, + recorderActionSequence: 4, + eventCount: 30, + errorCount: 1, + sizeBytes: 4_096, + budgetAlertCount: 2, + droppedBestEffortTasks: 3, + responseBodyCaptures: 4, + networkTotal: 10, + networkFailed: 1 + }, + ...overrides + }; + + return state === "stopped" + ? { + ...base, + state: "stopped", + stoppedAt: Date.parse("2026-01-01T01:00:00.000Z"), + cleanupDeadline: Date.parse("2026-01-02T01:00:00.000Z") + } + : { ...base, state: "active" }; +} + +describe("runtime state checkpoint", () => { + it("round-trips active and stopped sessions without events or keys", () => { + const snapshot = createRuntimeStateSnapshot( + [createRuntime("active"), createRuntime("stopped", { sid: "S-1767225600001-stopped" })], + 123 + ); + const parsed = parseRuntimeStateSnapshot(snapshot); + + expect(parsed).toEqual(snapshot); + expect(JSON.stringify(parsed)).not.toMatch(/"events"|"encryptionKey"|"passphrase":/); + }); + + it("rejects the entire checkpoint on unknown fields, invalid policy, or duplicate active tabs", () => { + const valid = createRuntimeStateSnapshot([createRuntime()]); + expect( + parseRuntimeStateSnapshot({ + ...valid, + unexpected: true + }) + ).toBeNull(); + + const invalidPolicy = structuredClone(valid); + invalidPolicy.sessions[0]!.config.capturePolicy!.scope.tabId = 8; + expect(parseRuntimeStateSnapshot(invalidPolicy)).toBeNull(); + + const missingCounter = structuredClone(valid) as unknown as { + sessions: Array<{ counters: Record }>; + }; + delete missingCounter.sessions[0]!.counters.recorderEventSequence; + expect(parseRuntimeStateSnapshot(missingCounter)).toBeNull(); + + const duplicateTab = createRuntimeStateSnapshot([ + createRuntime(), + createRuntime("active", { sid: "S-1767225600001-duplicate" }) + ]); + expect(parseRuntimeStateSnapshot(duplicateTab)).toBeNull(); + }); + + it("fails closed when the tab disappeared or consent expired", () => { + const runtime = createRuntime() as PersistedActiveRuntime; + expect( + evaluateActiveRuntimeRestoration(runtime, null, Date.parse("2026-01-01T00:01:00Z")) + ).toEqual({ allowed: false, reason: "tab-missing" }); + + runtime.config = createConfig(7, "2026-01-01T00:02:00.000Z"); + expect( + evaluateActiveRuntimeRestoration( + runtime, + { id: 7, url: "https://example.test/next" }, + Date.parse("2026-01-01T00:03:00.000Z") + ) + ).toEqual({ allowed: false, reason: "consent-expired" }); + }); + + it("accepts a live same-origin tab and never extends an absolute retention deadline", () => { + const runtime = createRuntime() as PersistedActiveRuntime; + expect( + evaluateActiveRuntimeRestoration( + runtime, + { id: 7, url: "https://example.test/next?token=secret" }, + Date.parse("2026-01-01T00:01:00Z") + ) + ).toMatchObject({ + allowed: true, + captureUrl: "https://example.test/next?token=secret" + }); + + expect(capCleanupDeadline(1_000, 11_000, 2_000)).toBe(3_000); + expect(capCleanupDeadline(1_000, 2_000, 5_000)).toBe(2_000); + }); +}); + +describe("MV3 cleanup scheduling", () => { + it("arms chrome.alarms plus a timer and purges once after restart alarm delivery", async () => { + let now = 1_000; + let timerCallback: (() => void) | undefined; + const alarmCreate = vi.fn(); + const alarmClear = vi.fn().mockResolvedValue(true); + const onDue = vi.fn(); + const scheduler = new RuntimeCleanupScheduler({ + alarms: { create: alarmCreate, clear: alarmClear }, + now: () => now, + setTimer: (callback) => { + timerCallback = callback; + return 1 as unknown as ReturnType; + }, + clearTimer: vi.fn(), + onDue + }); + + await scheduler.schedule("S-1767225600001-stopped", 2_000); + expect(alarmCreate).toHaveBeenCalledWith( + createRuntimeCleanupAlarmName("S-1767225600001-stopped"), + { when: 2_000 } + ); + expect(timerCallback).toBeTypeOf("function"); + + now = 2_001; + await scheduler.handleAlarm(createRuntimeCleanupAlarmName("S-1767225600001-stopped")); + timerCallback?.(); + await Promise.resolve(); + expect(onDue).toHaveBeenCalledTimes(1); + }); + + it("immediately purges an already-expired restored session", async () => { + const sessions = new Map([["S-1767225600001-expired", { stopped: true }]]); + const onDue = vi.fn((sid: string) => { + sessions.delete(sid); + }); + const scheduler = new RuntimeCleanupScheduler({ now: () => 5_000, onDue }); + + await scheduler.schedule("S-1767225600001-expired", 4_999); + expect(onDue).toHaveBeenCalledOnce(); + expect(sessions.has("S-1767225600001-expired")).toBe(false); + }); +}); + +describe("runtime start coordination", () => { + it("coalesces concurrent starts for one tab and allows a later start", async () => { + const coordinator = new RuntimeStartCoordinator(); + let release!: () => void; + const firstStart = vi.fn( + () => + new Promise((resolve) => { + release = resolve; + }) + ); + + const first = coordinator.run(7, firstStart); + const duplicate = coordinator.run(7, firstStart); + await Promise.resolve(); + expect(firstStart).toHaveBeenCalledOnce(); + expect(duplicate).toBe(first); + release(); + await first; + + await coordinator.run( + 7, + firstStart.mockImplementation(async () => undefined) + ); + expect(firstStart).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/extension/src/sw/runtime-state.ts b/apps/extension/src/sw/runtime-state.ts new file mode 100644 index 0000000..b1f6737 --- /dev/null +++ b/apps/extension/src/sw/runtime-state.ts @@ -0,0 +1,613 @@ +import { + evaluateCaptureScope, + recorderConfigSchema, + sanitizeUrlForPrivacy, + type CaptureMode, + type RecorderConfig +} from "@webblackbox/protocol"; + +export const RUNTIME_STATE_SCHEMA_VERSION = 1; +export const RUNTIME_CLEANUP_ALARM_PREFIX = "webblackbox.runtime.cleanup:"; + +const MAX_PERSISTED_SESSIONS = 512; +const MAX_TIMER_DELAY_MS = 2_147_000_000; +const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +export type PersistedRuntimeCounters = { + recorderEventSequence: number; + recorderActionSequence: number; + eventCount: number; + errorCount: number; + sizeBytes: number; + budgetAlertCount: number; + droppedBestEffortTasks: number; + responseBodyCaptures: number; + networkTotal: number; + networkFailed: number; +}; + +export type PersistedScreenRecording = { + recordingId: string; + startedAt: number; + startedMono: number; + mime: string; + width?: number; + height?: number; + frameRate?: number; + chunkCount: number; + sizeBytes: number; +}; + +type PersistedRuntimeBase = { + sid: string; + tabId: number; + mode: CaptureMode; + url: string; + title?: string; + tags: string[]; + note?: string; + config: RecorderConfig; + startedAt: number; + counters: PersistedRuntimeCounters; + screenRecording?: PersistedScreenRecording; +}; + +export type PersistedActiveRuntime = PersistedRuntimeBase & { + state: "active"; +}; + +export type PersistedStoppedRuntime = PersistedRuntimeBase & { + state: "stopped"; + stoppedAt: number; + cleanupDeadline: number; +}; + +export type PersistedRuntime = PersistedActiveRuntime | PersistedStoppedRuntime; + +export type RuntimeStateSnapshot = { + schemaVersion: typeof RUNTIME_STATE_SCHEMA_VERSION; + savedAt: number; + sessions: PersistedRuntime[]; +}; + +export type ActiveRuntimeRestorationDecision = + | { allowed: true; captureUrl: string; sanitizedUrl: string } + | { allowed: false; reason: string }; + +export type PersistedRuntimeIdentity = { + sid: string; + tabId: number; + startedAt: number; +}; + +export type RuntimeCleanupAlarmApi = { + create(name: string, alarmInfo: { when: number }): void | Promise; + clear(name: string): boolean | Promise; +}; + +export type RuntimeCleanupSchedulerOptions = { + alarms?: RuntimeCleanupAlarmApi; + now?: () => number; + setTimer?: (callback: () => void, delayMs: number) => ReturnType; + clearTimer?: (timer: ReturnType) => void; + onDue: (sid: string) => void | Promise; +}; + +export class RuntimeStartCoordinator { + private readonly inFlight = new Map>(); + + public run(tabId: number, start: () => Promise): Promise { + const existing = this.inFlight.get(tabId); + + if (existing) { + return existing; + } + + const task = Promise.resolve() + .then(start) + .finally(() => { + if (this.inFlight.get(tabId) === task) { + this.inFlight.delete(tabId); + } + }); + this.inFlight.set(tabId, task); + return task; + } +} + +/** + * Parses the versioned service-worker checkpoint as an all-or-nothing unit. + * A single malformed or unexpected field rejects the complete checkpoint so + * recovery cannot silently downgrade policy or mix trusted and corrupt rows. + */ +export function parseRuntimeStateSnapshot(input: unknown): RuntimeStateSnapshot | null { + const record = asStrictRecord(input, ["schemaVersion", "savedAt", "sessions"]); + + if ( + !record || + record.schemaVersion !== RUNTIME_STATE_SCHEMA_VERSION || + !isFiniteNonNegativeNumber(record.savedAt) || + !Array.isArray(record.sessions) || + record.sessions.length > MAX_PERSISTED_SESSIONS + ) { + return null; + } + + const sessions: PersistedRuntime[] = []; + const seenSids = new Set(); + const seenActiveTabs = new Set(); + + for (const value of record.sessions) { + const session = parsePersistedRuntime(value); + + if (!session || seenSids.has(session.sid)) { + return null; + } + + if (session.state === "active") { + if (seenActiveTabs.has(session.tabId)) { + return null; + } + seenActiveTabs.add(session.tabId); + } + + seenSids.add(session.sid); + sessions.push(session); + } + + return { + schemaVersion: RUNTIME_STATE_SCHEMA_VERSION, + savedAt: record.savedAt, + sessions + }; +} + +export function createRuntimeStateSnapshot( + sessions: readonly PersistedRuntime[], + savedAt = Date.now() +): RuntimeStateSnapshot { + return { + schemaVersion: RUNTIME_STATE_SCHEMA_VERSION, + savedAt, + sessions: sessions.map(clonePersistedRuntime) + }; +} + +/** Extracts only non-sensitive identifiers used to fail-closed purge a corrupt checkpoint. */ +export function extractPersistedRuntimeIdentities(input: unknown): PersistedRuntimeIdentity[] { + const root = asRecord(input); + const values = Array.isArray(input) + ? input + : root && Array.isArray(root.sessions) + ? root.sessions + : []; + const identities: PersistedRuntimeIdentity[] = []; + const seen = new Set(); + + for (const value of values.slice(0, MAX_PERSISTED_SESSIONS)) { + const record = asRecord(value); + + if ( + typeof record?.sid !== "string" || + !SESSION_ID_PATTERN.test(record.sid) || + !isNonNegativeSafeInteger(record.tabId) || + !isFiniteNonNegativeNumber(record.startedAt) || + seen.has(record.sid) + ) { + continue; + } + + seen.add(record.sid); + identities.push({ + sid: record.sid, + tabId: record.tabId, + startedAt: record.startedAt + }); + } + + return identities; +} + +export function evaluateActiveRuntimeRestoration( + runtime: PersistedActiveRuntime, + tab: { id?: number; url?: string } | null | undefined, + now = Date.now() +): ActiveRuntimeRestorationDecision { + if (tab?.id !== runtime.tabId || typeof tab.url !== "string" || tab.url.length === 0) { + return { allowed: false, reason: "tab-missing" }; + } + + const decision = evaluateCaptureScope(runtime.config.capturePolicy, { + url: tab.url, + tabId: runtime.tabId, + frameId: 0, + topLevel: true, + now + }); + + if (!decision.allowed) { + return { allowed: false, reason: decision.reason }; + } + + return { + allowed: true, + captureUrl: tab.url, + sanitizedUrl: sanitizeUrlForPrivacy(tab.url) + }; +} + +/** Never extends an already-persisted retention deadline when policy changes. */ +export function capCleanupDeadline( + stoppedAt: number, + persistedDeadline: number, + localTtlMs: number +): number { + return Math.min(persistedDeadline, stoppedAt + localTtlMs); +} + +export function createRuntimeCleanupAlarmName(sid: string): string { + return `${RUNTIME_CLEANUP_ALARM_PREFIX}${sid}`; +} + +export function parseRuntimeCleanupAlarmName(name: string): string | null { + if (!name.startsWith(RUNTIME_CLEANUP_ALARM_PREFIX)) { + return null; + } + + const sid = name.slice(RUNTIME_CLEANUP_ALARM_PREFIX.length); + return SESSION_ID_PATTERN.test(sid) ? sid : null; +} + +/** + * Uses both a best-effort in-memory timer and chrome.alarms. The timer gives + * prompt cleanup while the alarm is the MV3 restart/suspension-safe source. + */ +export class RuntimeCleanupScheduler { + private readonly alarms: RuntimeCleanupAlarmApi | undefined; + private readonly now: () => number; + private readonly setTimer: ( + callback: () => void, + delayMs: number + ) => ReturnType; + private readonly clearTimer: (timer: ReturnType) => void; + private readonly onDue: (sid: string) => void | Promise; + private readonly deadlines = new Map(); + private readonly timers = new Map>(); + private readonly dueTasks = new Map>(); + + public constructor(options: RuntimeCleanupSchedulerOptions) { + this.alarms = options.alarms; + this.now = options.now ?? Date.now; + this.setTimer = options.setTimer ?? setTimeout; + this.clearTimer = options.clearTimer ?? clearTimeout; + this.onDue = options.onDue; + } + + public async schedule(sid: string, deadline: number): Promise { + if (!SESSION_ID_PATTERN.test(sid) || !isFiniteNonNegativeNumber(deadline)) { + throw new Error("Invalid stopped-session cleanup schedule."); + } + + this.cancelTimer(sid); + this.deadlines.set(sid, deadline); + + const delay = deadline - this.now(); + + if (delay <= 0) { + await this.runDue(sid); + return; + } + + const alarmName = createRuntimeCleanupAlarmName(sid); + await this.alarms?.clear(alarmName); + await this.alarms?.create(alarmName, { when: deadline }); + + const timer = this.setTimer( + () => { + this.timers.delete(sid); + void this.handleDueSignal(sid); + }, + Math.min(delay, MAX_TIMER_DELAY_MS) + ); + this.timers.set(sid, timer); + } + + public async cancel(sid: string): Promise { + this.deadlines.delete(sid); + this.cancelTimer(sid); + await this.alarms?.clear(createRuntimeCleanupAlarmName(sid)); + } + + public async handleAlarm(name: string): Promise { + const sid = parseRuntimeCleanupAlarmName(name); + + if (!sid || !this.deadlines.has(sid)) { + return false; + } + + await this.handleDueSignal(sid); + return true; + } + + public getDeadline(sid: string): number | undefined { + return this.deadlines.get(sid); + } + + private async handleDueSignal(sid: string): Promise { + const deadline = this.deadlines.get(sid); + + if (deadline === undefined) { + return; + } + + if (this.now() < deadline) { + await this.schedule(sid, deadline); + return; + } + + await this.runDue(sid); + } + + private async runDue(sid: string): Promise { + const existing = this.dueTasks.get(sid); + + if (existing) { + await existing; + return; + } + + this.deadlines.delete(sid); + this.cancelTimer(sid); + const task = Promise.resolve(this.onDue(sid)).finally(() => { + this.dueTasks.delete(sid); + }); + this.dueTasks.set(sid, task); + await task; + } + + private cancelTimer(sid: string): void { + const timer = this.timers.get(sid); + + if (timer !== undefined) { + this.clearTimer(timer); + this.timers.delete(sid); + } + } +} + +function parsePersistedRuntime(input: unknown): PersistedRuntime | null { + const raw = asRecord(input); + + if (raw?.state === "active") { + const record = asStrictRecord(input, [ + "state", + "sid", + "tabId", + "mode", + "url", + "title", + "tags", + "note", + "config", + "startedAt", + "counters", + "screenRecording" + ]); + const base = record ? parsePersistedRuntimeBase(record) : null; + return base ? { ...base, state: "active" } : null; + } + + if (raw?.state === "stopped") { + const record = asStrictRecord(input, [ + "state", + "sid", + "tabId", + "mode", + "url", + "title", + "tags", + "note", + "config", + "startedAt", + "counters", + "screenRecording", + "stoppedAt", + "cleanupDeadline" + ]); + const base = record ? parsePersistedRuntimeBase(record) : null; + + if ( + !base || + !isFiniteNonNegativeNumber(record?.stoppedAt) || + !isFiniteNonNegativeNumber(record.cleanupDeadline) || + record.stoppedAt < base.startedAt || + record.cleanupDeadline < record.stoppedAt + ) { + return null; + } + + return { + ...base, + state: "stopped", + stoppedAt: record.stoppedAt, + cleanupDeadline: record.cleanupDeadline + }; + } + + return null; +} + +function parsePersistedRuntimeBase(record: Record): PersistedRuntimeBase | null { + const parsedConfig = recorderConfigSchema.safeParse(record.config); + const mode = record.mode; + const config = parsedConfig.success ? parsedConfig.data : null; + const counters = parseCounters(record.counters); + const screenRecording = parseScreenRecording(record.screenRecording); + + if ( + typeof record.sid !== "string" || + !SESSION_ID_PATTERN.test(record.sid) || + !isNonNegativeSafeInteger(record.tabId) || + (mode !== "lite" && mode !== "full") || + typeof record.url !== "string" || + record.url.length === 0 || + record.url.length > 4_096 || + (record.title !== undefined && + (typeof record.title !== "string" || record.title.length > 1_024)) || + !isStringList(record.tags, 12, 40) || + (record.note !== undefined && (typeof record.note !== "string" || record.note.length > 500)) || + !config || + config.mode !== mode || + !config.capturePolicy || + config.capturePolicy.scope.tabId !== record.tabId || + JSON.stringify(config.capturePolicy.redaction) !== JSON.stringify(config.redaction) || + !isFiniteNonNegativeNumber(record.startedAt) || + !counters || + (record.screenRecording !== undefined && !screenRecording) + ) { + return null; + } + + return { + sid: record.sid, + tabId: record.tabId, + mode, + url: record.url, + title: record.title as string | undefined, + tags: [...(record.tags as string[])], + note: record.note as string | undefined, + config, + startedAt: record.startedAt, + counters, + screenRecording: screenRecording ?? undefined + }; +} + +function parseCounters(input: unknown): PersistedRuntimeCounters | null { + const record = asExactRecord(input, [ + "recorderEventSequence", + "recorderActionSequence", + "eventCount", + "errorCount", + "sizeBytes", + "budgetAlertCount", + "droppedBestEffortTasks", + "responseBodyCaptures", + "networkTotal", + "networkFailed" + ]); + + if (!record || !Object.values(record).every(isNonNegativeSafeInteger)) { + return null; + } + + const counters = record as PersistedRuntimeCounters; + + if ( + counters.errorCount > counters.eventCount || + counters.networkFailed > counters.networkTotal || + counters.recorderEventSequence < counters.eventCount || + counters.recorderActionSequence > counters.recorderEventSequence + ) { + return null; + } + + return counters; +} + +function parseScreenRecording(input: unknown): PersistedScreenRecording | null { + if (input === undefined) { + return null; + } + + const record = asStrictRecord(input, [ + "recordingId", + "startedAt", + "startedMono", + "mime", + "width", + "height", + "frameRate", + "chunkCount", + "sizeBytes" + ]); + + if ( + !record || + typeof record.recordingId !== "string" || + record.recordingId.length === 0 || + record.recordingId.length > 256 || + !isFiniteNonNegativeNumber(record.startedAt) || + !isFiniteNonNegativeNumber(record.startedMono) || + typeof record.mime !== "string" || + record.mime.length === 0 || + record.mime.length > 256 || + !isOptionalPositiveNumber(record.width) || + !isOptionalPositiveNumber(record.height) || + !isOptionalPositiveNumber(record.frameRate) || + !isNonNegativeSafeInteger(record.chunkCount) || + !isNonNegativeSafeInteger(record.sizeBytes) + ) { + return null; + } + + return record as PersistedScreenRecording; +} + +function clonePersistedRuntime(runtime: PersistedRuntime): PersistedRuntime { + return structuredClone(runtime); +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asStrictRecord( + value: unknown, + allowedKeys: readonly string[] +): Record | null { + const record = asRecord(value); + + if (!record) { + return null; + } + + const allowed = new Set(allowedKeys); + + return Object.keys(record).every((key) => allowed.has(key)) ? record : null; +} + +function asExactRecord( + value: unknown, + requiredKeys: readonly string[] +): Record | null { + const record = asStrictRecord(value, requiredKeys); + + if (!record || requiredKeys.some((key) => !Object.hasOwn(record, key))) { + return null; + } + + return record; +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isOptionalPositiveNumber(value: unknown): boolean { + return value === undefined || (typeof value === "number" && Number.isFinite(value) && value > 0); +} + +function isStringList(value: unknown, maxItems: number, maxLength: number): value is string[] { + return ( + Array.isArray(value) && + value.length <= maxItems && + value.every((item) => typeof item === "string" && item.length > 0 && item.length <= maxLength) + ); +} diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index 7715d8e..ca7ca99 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -259,6 +259,30 @@ describe("pipeline", () => { expect(new Set(persisted.map((event) => event.id)).size).toBe(events.length); }); + it("continues chunk sequencing when an offscreen pipeline is rebuilt after restart", async () => { + const storage = new MemoryPipelineStorage(); + const first = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1 + }); + await first.start(); + await first.ingest(createEvent("E-before-restart", "user.marker", 1)); + await first.close(); + + const restored = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1 + }); + await restored.start(); + await restored.ingest(createEvent("E-after-restart", "user.marker", 2)); + await restored.close(); + + const chunks = await storage.listChunks(SESSION.sid); + expect(chunks.map((chunk) => chunk.meta.seq)).toEqual([1, 2]); + }); + it("can retry an ingest after chunk persistence fails without a sequence gap or duplicate", async () => { const storage = new FailOnceChunkStorage(); const pipeline = createTestPipeline({ diff --git a/packages/protocol/src/ids.ts b/packages/protocol/src/ids.ts index 9633640..80689e3 100644 --- a/packages/protocol/src/ids.ts +++ b/packages/protocol/src/ids.ts @@ -36,4 +36,12 @@ export class EventIdFactory { public value(): number { return this.sequence; } + + public restore(value: number): void { + if (!Number.isSafeInteger(value) || value < this.sequence) { + throw new Error("Event id sequence must be a safe integer that does not move backwards."); + } + + this.sequence = value; + } } diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index a2b9701..19740fc 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -30,6 +30,9 @@ describe("protocol", () => { expect(sessionId.startsWith("S-1700000000000-")).toBe(true); expect(ids.next()).toBe("E-00000001"); expect(ids.next()).toBe("E-00000002"); + ids.restore(41); + expect(ids.next()).toBe("E-00000042"); + expect(() => ids.restore(1)).toThrow(/does not move backwards/); }); it("validates an event envelope", () => { diff --git a/packages/recorder/src/action-span.ts b/packages/recorder/src/action-span.ts index c3075c1..41ea717 100644 --- a/packages/recorder/src/action-span.ts +++ b/packages/recorder/src/action-span.ts @@ -114,4 +114,17 @@ export class ActionSpanTracker { ? (value as Record) : null; } + + public sequenceValue(): number { + return this.sequence; + } + + public restoreSequence(value: number): void { + if (!Number.isSafeInteger(value) || value < this.sequence) { + throw new Error("Action sequence must be a safe integer that does not move backwards."); + } + + this.sequence = value; + this.currentAction = null; + } } diff --git a/packages/recorder/src/index.test.ts b/packages/recorder/src/index.test.ts index bc63814..f0ee7b1 100644 --- a/packages/recorder/src/index.test.ts +++ b/packages/recorder/src/index.test.ts @@ -170,6 +170,25 @@ describe("recorder", () => { expect(result.event?.tab).toBe(0); }); + it("restores event and action sequences after a service-worker restart", () => { + const recorder = new WebBlackboxRecorder(TEST_CONFIG); + recorder.restoreSequenceState({ event: 41, action: 9 }); + + const result = recorder.ingest({ + source: "content", + rawType: "click", + sid: "S-recovered", + tabId: 7, + t: Date.now(), + mono: 10, + payload: { selector: "button" } + }); + + expect(result.event?.id).toBe("E-00000042"); + expect(result.event?.ref?.act).toBe("A-000010"); + expect(recorder.getSequenceState()).toEqual({ event: 42, action: 10 }); + }); + it("assigns action span id to dependent events", () => { const recorder = new WebBlackboxRecorder(TEST_CONFIG); const now = Date.now(); diff --git a/packages/recorder/src/recorder.ts b/packages/recorder/src/recorder.ts index 1dd08b4..09adb81 100644 --- a/packages/recorder/src/recorder.ts +++ b/packages/recorder/src/recorder.ts @@ -75,6 +75,11 @@ export type RecorderHooks = { onFreeze?: (reason: FreezeReason, event: WebBlackboxEvent) => void; }; +export type RecorderSequenceState = { + event: number; + action: number; +}; + export class WebBlackboxRecorder { private readonly idFactory = new EventIdFactory(); @@ -183,6 +188,18 @@ export class WebBlackboxRecorder { return this.ringBuffer.size(); } + public getSequenceState(): RecorderSequenceState { + return { + event: this.idFactory.value(), + action: this.actionSpanTracker.sequenceValue() + }; + } + + public restoreSequenceState(state: RecorderSequenceState): void { + this.idFactory.restore(state.event); + this.actionSpanTracker.restoreSequence(state.action); + } + private applyRawPlugins(raw: RawRecorderEvent): RawRecorderEvent | null { let nextRaw = raw; From e29c6fb2f8b3ed553679bceb9da65650cb005900 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:06:08 +0800 Subject: [PATCH 040/181] fix(tooling): pin local executable entrypoints --- apps/mcp-server/package.json | 2 +- apps/player/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mcp-server/package.json b/apps/mcp-server/package.json index 2a64ca5..db2dede 100644 --- a/apps/mcp-server/package.json +++ b/apps/mcp-server/package.json @@ -50,7 +50,7 @@ "prepack": "pnpm run build", "prestart": "pnpm run build", "start": "node dist/cli.js", - "inspect": "pnpm run build && npx @modelcontextprotocol/inspector node dist/cli.js", + "inspect": "pnpm run build && pnpm dlx @modelcontextprotocol/inspector@0.22.0 node dist/cli.js", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --config vitest.config.ts", diff --git a/apps/player/package.json b/apps/player/package.json index 8b2cb64..22d5623 100644 --- a/apps/player/package.json +++ b/apps/player/package.json @@ -10,7 +10,7 @@ "e2e:playback": "pnpm run build && node scripts/e2e-playback-regression.mjs", "pages:build": "pnpm build && node scripts/prepare-pages-build.mjs", "pages:deploy": "node scripts/deploy-gh-pages.mjs", - "serve": "npx -y http-server ./ -p 4177", + "serve": "vite preview --host 127.0.0.1 --port 4177 --strictPort --outDir build", "lint": "eslint src --ext .ts,.tsx", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run --passWithNoTests" From e201f0d761fd3587618d74e18511d6f23a67dae7 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:09:22 +0800 Subject: [PATCH 041/181] fix(privacy): reject full-mode bodies without MIME --- apps/extension/src/sw/body-capture-utils.test.ts | 15 +++++++++++++++ apps/extension/src/sw/body-capture-utils.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/extension/src/sw/body-capture-utils.test.ts b/apps/extension/src/sw/body-capture-utils.test.ts index 7b413ba..43a44bb 100644 --- a/apps/extension/src/sw/body-capture-utils.test.ts +++ b/apps/extension/src/sw/body-capture-utils.test.ts @@ -62,6 +62,21 @@ describe("body-capture utils", () => { expect(rule.mimeAllowlist).toEqual(["application/json"]); }); + it("disables full-mode body capture when the response MIME type is missing", () => { + const rule = resolveFullBodyCaptureRule( + { + sampling: { + bodyCaptureMaxBytes: 64 * 1024 + }, + sitePolicies: [] + }, + "https://api.example.com/v1/search", + undefined + ); + + expect(rule.enabled).toBe(false); + }); + it("keeps lite-mode default capture rule enabled for unmatched policies", () => { const rule = resolveLiteBodyCaptureRule( { diff --git a/apps/extension/src/sw/body-capture-utils.ts b/apps/extension/src/sw/body-capture-utils.ts index 8e88eea..0c380ac 100644 --- a/apps/extension/src/sw/body-capture-utils.ts +++ b/apps/extension/src/sw/body-capture-utils.ts @@ -170,7 +170,7 @@ export function wildcardMatch(value: string, pattern: string): boolean { export function isMimeAllowed(allowlist: string[], mimeType: string | undefined): boolean { if (!mimeType) { - return true; + return false; } const normalizedMime = mimeType.toLowerCase(); From cfb97b63bba459d3497175bdf986d46bd0cd9943 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:06:36 +0800 Subject: [PATCH 042/181] fix(extension): validate runtime message ingress --- apps/extension/src/content/flush-policy.ts | 4 +- .../src/shared/inbound-messages.test.ts | 401 ++++++++ apps/extension/src/shared/inbound-messages.ts | 856 ++++++++++++++++++ apps/extension/src/shared/messages.ts | 2 + apps/extension/src/sw/index.ts | 37 +- 5 files changed, 1280 insertions(+), 20 deletions(-) create mode 100644 apps/extension/src/shared/inbound-messages.test.ts create mode 100644 apps/extension/src/shared/inbound-messages.ts diff --git a/apps/extension/src/content/flush-policy.ts b/apps/extension/src/content/flush-policy.ts index 155edb2..0559a61 100644 --- a/apps/extension/src/content/flush-policy.ts +++ b/apps/extension/src/content/flush-policy.ts @@ -1,4 +1,6 @@ -export const CONTENT_EVENT_FLUSH_CHUNK = 120; +import { CONTENT_EVENT_BATCH_MAX } from "../shared/messages.js"; + +export const CONTENT_EVENT_FLUSH_CHUNK = CONTENT_EVENT_BATCH_MAX; export const CONTENT_EVENT_FLUSH_URGENT_THRESHOLD = 360; export const CONTENT_EVENT_FLUSH_URGENT_MS = 8; export const CONTENT_EVENT_FLUSH_SOON_MS = 16; diff --git a/apps/extension/src/shared/inbound-messages.test.ts b/apps/extension/src/shared/inbound-messages.test.ts new file mode 100644 index 0000000..0079fd4 --- /dev/null +++ b/apps/extension/src/shared/inbound-messages.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from "vitest"; + +import type { MessageSenderLike } from "./chrome-api.js"; +import { + isAuthorizedExtensionPort, + parseExtensionInboundMessage, + type ExtensionInboundContext +} from "./inbound-messages.js"; +import { CONTENT_EVENT_BATCH_MAX, PORT_NAMES } from "./messages.js"; + +const EXTENSION_BASE_URL = "chrome-extension://webblackbox-test/"; + +const POPUP_SENDER: MessageSenderLike = { + url: `${EXTENSION_BASE_URL}popup.html`, + origin: "chrome-extension://webblackbox-test" +}; + +const SESSIONS_SENDER: MessageSenderLike = { + url: `${EXTENSION_BASE_URL}sessions.html`, + origin: "chrome-extension://webblackbox-test" +}; + +const CONTENT_SENDER: MessageSenderLike = { + frameId: 0, + url: "https://app.example.test/dashboard", + origin: "https://app.example.test", + tab: { + id: 42, + url: "https://app.example.test/dashboard" + } +}; + +function portContext(portName: string, sender: MessageSenderLike): ExtensionInboundContext { + return { + transport: "port", + portName, + sender, + extensionBaseUrl: EXTENSION_BASE_URL + }; +} + +function runtimeContext(sender: MessageSenderLike): ExtensionInboundContext { + return { + transport: "runtime", + sender, + extensionBaseUrl: EXTENSION_BASE_URL + }; +} + +function validRawEvent(overrides: Record = {}): Record { + return { + source: "content", + rawType: "click", + tabId: -1, + sid: "", + t: 1_720_000_000_000, + mono: 123.5, + payload: { + x: 14, + y: 21, + target: { + tag: "BUTTON" + } + }, + ...overrides + }; +} + +function contentBatch(events: unknown[]): Record { + return { + kind: "content.events", + events, + documentUrl: "https://app.example.test/dashboard" + }; +} + +describe("extension inbound source authorization", () => { + it("binds named ports to their real extension page or tab/frame sender", () => { + expect( + isAuthorizedExtensionPort( + { name: PORT_NAMES.popup, sender: POPUP_SENDER }, + EXTENSION_BASE_URL + ) + ).toBe(true); + expect( + isAuthorizedExtensionPort( + { name: PORT_NAMES.sessions, sender: SESSIONS_SENDER }, + EXTENSION_BASE_URL + ) + ).toBe(true); + expect( + isAuthorizedExtensionPort( + { name: PORT_NAMES.content, sender: CONTENT_SENDER }, + EXTENSION_BASE_URL + ) + ).toBe(true); + + expect( + isAuthorizedExtensionPort( + { name: PORT_NAMES.popup, sender: CONTENT_SENDER }, + EXTENSION_BASE_URL + ) + ).toBe(false); + expect( + isAuthorizedExtensionPort( + { name: PORT_NAMES.content, sender: POPUP_SENDER }, + EXTENSION_BASE_URL + ) + ).toBe(false); + expect( + isAuthorizedExtensionPort( + { + name: PORT_NAMES.popup, + sender: { url: `${EXTENSION_BASE_URL}sessions.html` } + }, + EXTENSION_BASE_URL + ) + ).toBe(false); + expect( + isAuthorizedExtensionPort( + { name: "webblackbox:spoofed", sender: POPUP_SENDER }, + EXTENSION_BASE_URL + ) + ).toBe(false); + }); + + it("prevents a content sender from forging UI controls", () => { + expect( + parseExtensionInboundMessage( + { kind: "ui.stop", tabId: 42 }, + portContext(PORT_NAMES.content, CONTENT_SENDER) + ) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + { kind: "ui.export", sid: "session-1", passphrase: "secret" }, + runtimeContext(CONTENT_SENDER) + ) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + contentBatch([validRawEvent()]), + portContext(PORT_NAMES.popup, POPUP_SENDER) + ) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + { kind: "ui.start", mode: "lite", tabId: 42 }, + portContext(PORT_NAMES.sessions, SESSIONS_SENDER) + ) + ).toBeNull(); + }); + + it("only permits the one-off messages used by each trusted sender", () => { + expect( + parseExtensionInboundMessage( + { kind: "ui.start", mode: "lite", tabId: 42 }, + runtimeContext(POPUP_SENDER) + ) + ).toEqual({ kind: "ui.start", mode: "lite", tabId: 42 }); + expect( + parseExtensionInboundMessage({ kind: "ui.stop", tabId: 42 }, runtimeContext(POPUP_SENDER)) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + { kind: "content.ready", documentUrl: CONTENT_SENDER.url }, + runtimeContext(CONTENT_SENDER) + ) + ).toEqual({ kind: "content.ready", documentUrl: CONTENT_SENDER.url }); + expect( + parseExtensionInboundMessage(contentBatch([validRawEvent()]), runtimeContext(CONTENT_SENDER)) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + { kind: "ui.start", mode: "lite", tabId: 42 }, + runtimeContext({ ...CONTENT_SENDER, url: "https://attacker.example/" }) + ) + ).toBeNull(); + }); +}); + +describe("extension inbound schema validation", () => { + it("accepts canonical UI messages and copies their bounded fields", () => { + const start = parseExtensionInboundMessage( + { + kind: "ui.start", + tabId: 42, + mode: "full", + reloadPage: false, + visualCapture: "both", + recordScreen: true + }, + portContext(PORT_NAMES.popup, POPUP_SENDER) + ); + const exportMessage = parseExtensionInboundMessage( + { + kind: "ui.export", + sid: "session-1", + passphrase: "correct horse battery staple", + saveAs: false, + policy: { + includeScreenshots: true, + includeScreenRecordings: false, + maxArchiveBytes: 100 * 1024 * 1024, + recentWindowMs: 20 * 60 * 1000 + } + }, + portContext(PORT_NAMES.popup, POPUP_SENDER) + ); + const annotate = parseExtensionInboundMessage( + { + kind: "ui.annotate", + sid: "session-1", + tags: [], + note: "Reproduced once" + }, + portContext(PORT_NAMES.sessions, SESSIONS_SENDER) + ); + + expect(start).toEqual({ + kind: "ui.start", + tabId: 42, + mode: "full", + reloadPage: false, + visualCapture: "both", + recordScreen: true + }); + expect(exportMessage).toEqual({ + kind: "ui.export", + sid: "session-1", + passphrase: "correct horse battery staple", + saveAs: false, + policy: { + includeScreenshots: true, + includeScreenRecordings: false, + maxArchiveBytes: 100 * 1024 * 1024, + recentWindowMs: 20 * 60 * 1000 + } + }); + expect(annotate).toEqual({ + kind: "ui.annotate", + sid: "session-1", + tags: [], + note: "Reproduced once" + }); + }); + + it("rejects unknown fields, invalid enums, non-finite numbers, and excessive UI data", () => { + const popup = portContext(PORT_NAMES.popup, POPUP_SENDER); + const sessions = portContext(PORT_NAMES.sessions, SESSIONS_SENDER); + + expect( + parseExtensionInboundMessage( + { kind: "ui.start", mode: "lite", tabId: 42, admin: true }, + popup + ) + ).toBeNull(); + expect( + parseExtensionInboundMessage({ kind: "ui.start", mode: "turbo", tabId: 42 }, popup) + ).toBeNull(); + expect( + parseExtensionInboundMessage({ kind: "ui.stop", tabId: Number.POSITIVE_INFINITY }, popup) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + { + kind: "ui.export", + sid: "session-1", + policy: { maxArchiveBytes: Number.NaN } + }, + popup + ) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + { + kind: "ui.annotate", + sid: "session-1", + tags: Array.from({ length: 13 }, (_, index) => `tag-${index}`) + }, + sessions + ) + ).toBeNull(); + expect( + parseExtensionInboundMessage({ kind: "ui.request-session-list", unexpected: "field" }, popup) + ).toBeNull(); + }); + + it("accepts a valid bounded RawRecorderEvent batch", () => { + expect( + parseExtensionInboundMessage( + contentBatch([ + validRawEvent(), + validRawEvent({ + rawType: "networkBody", + t: 1_720_000_000_001, + mono: 124, + frame: "content-frame-2", + payload: { + reqId: "req-1", + mime: "application/json", + body: '{"ok":true}', + nested: [null, true, undefined, 2] + } + }) + ]), + portContext(PORT_NAMES.content, CONTENT_SENDER) + ) + ).toEqual( + contentBatch([ + validRawEvent(), + validRawEvent({ + rawType: "networkBody", + t: 1_720_000_000_001, + mono: 124, + frame: "content-frame-2", + payload: { + reqId: "req-1", + mime: "application/json", + body: '{"ok":true}', + nested: [null, true, undefined, 2] + } + }) + ]) + ); + }); + + it("enforces the sender-aligned content batch limit", () => { + const context = portContext(PORT_NAMES.content, CONTENT_SENDER); + const maximumBatch = Array.from({ length: CONTENT_EVENT_BATCH_MAX }, (_, index) => + validRawEvent({ mono: index }) + ); + const oversizedBatch = [...maximumBatch, validRawEvent({ mono: CONTENT_EVENT_BATCH_MAX })]; + + expect(parseExtensionInboundMessage(contentBatch(maximumBatch), context)).not.toBeNull(); + expect(parseExtensionInboundMessage(contentBatch(oversizedBatch), context)).toBeNull(); + expect(parseExtensionInboundMessage(contentBatch([]), context)).toBeNull(); + }); + + it("rejects malformed, spoofed, and non-finite raw events", () => { + const context = portContext(PORT_NAMES.content, CONTENT_SENDER); + + for (const event of [ + validRawEvent({ source: "system" }), + validRawEvent({ rawType: "session-start" }), + validRawEvent({ tabId: 1.5 }), + validRawEvent({ t: Number.NaN }), + validRawEvent({ mono: Number.POSITIVE_INFINITY }), + validRawEvent({ payload: new Date() }), + validRawEvent({ cdpSessionId: "forged-cdp-session" }) + ]) { + expect(parseExtensionInboundMessage(contentBatch([event]), context)).toBeNull(); + } + }); + + it("rejects cyclic, accessor-backed, and excessively deep payload graphs", () => { + const context = portContext(PORT_NAMES.content, CONTENT_SENDER); + const cyclic: Record = {}; + cyclic.self = cyclic; + + let deep: Record = {}; + for (let index = 0; index < 24; index += 1) { + deep = { child: deep }; + } + + const accessorPayload = {}; + Object.defineProperty(accessorPayload, "secret", { + enumerable: true, + get: () => "should-not-run" + }); + + expect( + parseExtensionInboundMessage(contentBatch([validRawEvent({ payload: cyclic })]), context) + ).toBeNull(); + expect( + parseExtensionInboundMessage(contentBatch([validRawEvent({ payload: deep })]), context) + ).toBeNull(); + expect( + parseExtensionInboundMessage( + contentBatch([validRawEvent({ payload: accessorPayload })]), + context + ) + ).toBeNull(); + }); + + it("fails closed on malformed envelopes and document URLs", () => { + const context = portContext(PORT_NAMES.content, CONTENT_SENDER); + const getterMessage = {}; + Object.defineProperty(getterMessage, "kind", { + enumerable: true, + get: () => "content.ready" + }); + + expect(parseExtensionInboundMessage(null, context)).toBeNull(); + expect(parseExtensionInboundMessage(["content.ready"], context)).toBeNull(); + expect(parseExtensionInboundMessage(getterMessage, context)).toBeNull(); + expect( + parseExtensionInboundMessage({ kind: "content.ready", documentUrl: "not a url" }, context) + ).toBeNull(); + }); +}); diff --git a/apps/extension/src/shared/inbound-messages.ts b/apps/extension/src/shared/inbound-messages.ts new file mode 100644 index 0000000..2054049 --- /dev/null +++ b/apps/extension/src/shared/inbound-messages.ts @@ -0,0 +1,856 @@ +import type { RawRecorderEvent } from "@webblackbox/recorder"; + +import type { MessageSenderLike, PortLike } from "./chrome-api.js"; +import { + CONTENT_EVENT_BATCH_MAX, + PORT_NAMES, + type ContentEventBatchMessage, + type ExtensionInboundMessage, + type UiAnnotateSessionMessage, + type UiExportSessionMessage, + type UiStartSessionMessage +} from "./messages.js"; + +export type ExtensionInboundContext = + | { + transport: "port"; + portName: string; + sender?: MessageSenderLike; + extensionBaseUrl: string; + } + | { + transport: "runtime"; + sender?: MessageSenderLike; + extensionBaseUrl: string; + }; + +type InboundPrincipal = "content" | "offscreen" | "options" | "popup" | "sessions"; + +type PayloadBudget = { + nodes: number; + stringChars: number; +}; + +const UI_PAGE_BY_PRINCIPAL = { + offscreen: "offscreen.html", + options: "options.html", + popup: "popup.html", + sessions: "sessions.html" +} as const; + +const CONTENT_RAW_TYPES = new Set([ + "blur", + "click", + "console", + "cookieSnapshot", + "dblclick", + "fetch", + "fetchError", + "focus", + "indexedDbOp", + "indexedDbSnapshot", + "input", + "keydown", + "localStorageOp", + "localStorageSnapshot", + "longtask", + "marker", + "mousemove", + "mutation", + "networkBody", + "pageError", + "privacyViolation", + "resize", + "resourceError", + "rrweb", + "screenshot", + "scroll", + "sessionStorageOp", + "snapshot", + "sse", + "submit", + "unhandledrejection", + "visibilitychange", + "vitals", + "xhr" +]); + +const MAX_DOCUMENT_URL_CHARS = 16 * 1024; +const MAX_IDENTIFIER_CHARS = 256; +const MAX_MARKER_CHARS = 512; +const MAX_PASSPHRASE_CHARS = 4 * 1024; +const MAX_PAYLOAD_DEPTH = 20; +const MAX_PAYLOAD_NODES = 100_000; +const MAX_PAYLOAD_OBJECT_KEYS = 2_048; +const MAX_PAYLOAD_STRING_CHARS = 12 * 1024 * 1024; +const MAX_BATCH_PAYLOAD_STRING_CHARS = 24 * 1024 * 1024; +const MAX_TAGS = 12; +const MAX_TAG_CHARS = 40; +const MAX_NOTE_CHARS = 500; +const MAX_EXPORT_BYTES = 5 * 1024 * 1024 * 1024; +const MAX_EXPORT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +const UI_START_KEYS = new Set([ + "kind", + "mode", + "recordScreen", + "reloadPage", + "tabId", + "visualCapture" +]); +const UI_STOP_KEYS = new Set(["kind", "tabId"]); +const UI_EXPORT_KEYS = new Set(["kind", "passphrase", "policy", "saveAs", "sid"]); +const UI_DELETE_KEYS = new Set(["kind", "sid"]); +const UI_ANNOTATE_KEYS = new Set(["kind", "note", "sid", "tags"]); +const KIND_ONLY_KEYS = new Set(["kind"]); +const CONTENT_EVENTS_KEYS = new Set(["documentUrl", "events", "kind"]); +const CONTENT_MARKER_KEYS = new Set(["documentUrl", "kind", "message"]); +const CONTENT_READY_KEYS = new Set(["documentUrl", "kind"]); +const CONTENT_STOP_DRAINED_KEYS = new Set(["kind", "sid"]); +const EXPORT_POLICY_KEYS = new Set([ + "includeScreenRecordings", + "includeScreenshots", + "maxArchiveBytes", + "recentWindowMs" +]); +const RAW_EVENT_KEYS = new Set([ + "frame", + "mono", + "payload", + "rawType", + "sid", + "source", + "t", + "tabId" +]); + +/** + * Verifies that a named runtime port really belongs to the extension surface + * associated with that name. Content ports are instead bound to a concrete + * tab/frame sender and cannot claim an extension UI principal. + */ +export function isAuthorizedExtensionPort( + port: Pick, + extensionBaseUrl: string +): boolean { + try { + return resolvePortPrincipal(port.name, port.sender, extensionBaseUrl) !== null; + } catch { + return false; + } +} + +/** + * Parses untrusted Chrome runtime input into a fresh, bounded message envelope. + * The source principal is authorized before payload traversal, then every + * control message and RawRecorderEvent is validated without type assertions. + */ +export function parseExtensionInboundMessage( + value: unknown, + context: ExtensionInboundContext +): ExtensionInboundMessage | null { + try { + const principal = resolveInboundPrincipal(context); + + if (!principal) { + return null; + } + + const row = asPlainRecord(value); + + if (!row) { + return null; + } + + const kind = row.kind; + + if (typeof kind !== "string" || !isKindAllowed(principal, context.transport, kind)) { + return null; + } + + switch (kind) { + case "ui.start": + return parseUiStart(row); + case "ui.stop": + return parseUiStop(row); + case "ui.export": + return parseUiExport(row); + case "ui.delete": + return parseUiDelete(row); + case "ui.annotate": + return parseUiAnnotate(row); + case "ui.request-session-list": + return hasExactKeys(row, KIND_ONLY_KEYS) ? { kind } : null; + case "content.events": + return parseContentEvents(row); + case "content.marker": + return parseContentMarker(row); + case "content.ready": + return parseContentReady(row); + case "content.stop-drained": + return parseContentStopDrained(row); + default: + return null; + } + } catch { + return null; + } +} + +function resolveInboundPrincipal(context: ExtensionInboundContext): InboundPrincipal | null { + if (context.transport === "port") { + return resolvePortPrincipal(context.portName, context.sender, context.extensionBaseUrl); + } + + const uiPrincipal = resolveUiPrincipal(context.sender, context.extensionBaseUrl); + + if (uiPrincipal) { + return uiPrincipal; + } + + return isContentSender(context.sender, context.extensionBaseUrl) ? "content" : null; +} + +function resolvePortPrincipal( + portName: string, + sender: MessageSenderLike | undefined, + extensionBaseUrl: string +): InboundPrincipal | null { + if (portName === PORT_NAMES.content) { + return isContentSender(sender, extensionBaseUrl) ? "content" : null; + } + + const uiPrincipal = resolveUiPrincipal(sender, extensionBaseUrl); + + if (portName === PORT_NAMES.popup && uiPrincipal === "popup") { + return uiPrincipal; + } + + if (portName === PORT_NAMES.sessions && uiPrincipal === "sessions") { + return uiPrincipal; + } + + if (portName === PORT_NAMES.options && uiPrincipal === "options") { + return uiPrincipal; + } + + if (portName === PORT_NAMES.offscreen && uiPrincipal === "offscreen") { + return uiPrincipal; + } + + return null; +} + +function resolveUiPrincipal( + sender: MessageSenderLike | undefined, + extensionBaseUrl: string +): Exclude | null { + if (typeof sender?.url !== "string") { + return null; + } + + for (const [principal, page] of Object.entries(UI_PAGE_BY_PRINCIPAL) as Array< + [Exclude, string] + >) { + if (isExactExtensionPage(sender.url, extensionBaseUrl, page)) { + return principal; + } + } + + return null; +} + +function isContentSender(sender: MessageSenderLike | undefined, extensionBaseUrl: string): boolean { + const tabId = sender?.tab?.id; + const frameId = sender?.frameId; + const senderUrl = sender?.url; + + if ( + !Number.isSafeInteger(tabId) || + (tabId as number) < 0 || + (frameId !== undefined && (!Number.isSafeInteger(frameId) || frameId < 0)) || + typeof senderUrl !== "string" || + senderUrl.length === 0 || + senderUrl.length > MAX_DOCUMENT_URL_CHARS + ) { + return false; + } + + const parsedSenderUrl = parseUrl(senderUrl); + const parsedExtensionBase = parseUrl(extensionBaseUrl); + + if (!parsedSenderUrl || !parsedExtensionBase) { + return false; + } + + return !hasSameUrlAuthority(parsedSenderUrl, parsedExtensionBase); +} + +function isExactExtensionPage(senderUrl: string, extensionBaseUrl: string, page: string): boolean { + if (senderUrl.length === 0 || senderUrl.length > MAX_DOCUMENT_URL_CHARS) { + return false; + } + + const parsedSenderUrl = parseUrl(senderUrl); + const expectedUrl = parseUrl(new URL(page, extensionBaseUrl).href); + + return Boolean( + parsedSenderUrl && + expectedUrl && + parsedSenderUrl.protocol === expectedUrl.protocol && + parsedSenderUrl.host === expectedUrl.host && + parsedSenderUrl.pathname === expectedUrl.pathname && + parsedSenderUrl.search === "" && + parsedSenderUrl.hash === "" + ); +} + +function hasSameUrlAuthority(left: URL, right: URL): boolean { + return left.protocol === right.protocol && left.host === right.host; +} + +function parseUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + return null; + } +} + +function isKindAllowed( + principal: InboundPrincipal, + transport: ExtensionInboundContext["transport"], + kind: string +): boolean { + if (principal === "content") { + if (transport === "runtime") { + return kind === "content.ready"; + } + + return ( + kind === "content.events" || + kind === "content.marker" || + kind === "content.ready" || + kind === "content.stop-drained" + ); + } + + if (principal === "popup") { + if (transport === "runtime") { + return kind === "ui.start" || kind === "ui.export"; + } + + return ( + kind === "ui.start" || + kind === "ui.stop" || + kind === "ui.export" || + kind === "ui.request-session-list" + ); + } + + if (principal === "sessions" && transport === "port") { + return ( + kind === "ui.stop" || kind === "ui.export" || kind === "ui.delete" || kind === "ui.annotate" + ); + } + + return false; +} + +function parseUiStart(row: Record): UiStartSessionMessage | null { + if (!hasExactKeys(row, UI_START_KEYS)) { + return null; + } + + const tabId = parseOptionalTabId(row.tabId); + const reloadPage = parseOptionalBoolean(row.reloadPage); + const recordScreen = parseOptionalBoolean(row.recordScreen); + const visualCapture = row.visualCapture; + + if ( + tabId === null || + reloadPage === null || + recordScreen === null || + (row.mode !== "lite" && row.mode !== "full") || + (visualCapture !== undefined && + visualCapture !== "screenshots" && + visualCapture !== "recording" && + visualCapture !== "both" && + visualCapture !== "none") + ) { + return null; + } + + return { + kind: "ui.start", + mode: row.mode, + ...(tabId === undefined ? {} : { tabId }), + ...(reloadPage === undefined ? {} : { reloadPage }), + ...(visualCapture === undefined ? {} : { visualCapture }), + ...(recordScreen === undefined ? {} : { recordScreen }) + }; +} + +function parseUiStop(row: Record): ExtensionInboundMessage | null { + if (!hasExactKeys(row, UI_STOP_KEYS)) { + return null; + } + + const tabId = parseOptionalTabId(row.tabId); + + if (tabId === null) { + return null; + } + + return { + kind: "ui.stop", + ...(tabId === undefined ? {} : { tabId }) + }; +} + +function parseUiExport(row: Record): UiExportSessionMessage | null { + if (!hasExactKeys(row, UI_EXPORT_KEYS) || !isIdentifier(row.sid, false)) { + return null; + } + + const passphrase = parseOptionalBoundedString(row.passphrase, MAX_PASSPHRASE_CHARS); + const saveAs = parseOptionalBoolean(row.saveAs); + const policy = parseExportPolicy(row.policy); + + if (passphrase === null || saveAs === null || policy === null) { + return null; + } + + return { + kind: "ui.export", + sid: row.sid, + ...(passphrase === undefined ? {} : { passphrase }), + ...(saveAs === undefined ? {} : { saveAs }), + ...(policy === undefined ? {} : { policy }) + }; +} + +function parseUiDelete(row: Record): ExtensionInboundMessage | null { + if (!hasExactKeys(row, UI_DELETE_KEYS) || !isIdentifier(row.sid, false)) { + return null; + } + + return { + kind: "ui.delete", + sid: row.sid + }; +} + +function parseUiAnnotate(row: Record): UiAnnotateSessionMessage | null { + if (!hasExactKeys(row, UI_ANNOTATE_KEYS) || !isIdentifier(row.sid, false)) { + return null; + } + + const tags = parseOptionalTags(row.tags); + const note = parseOptionalBoundedString(row.note, MAX_NOTE_CHARS); + + if (tags === null || note === null) { + return null; + } + + return { + kind: "ui.annotate", + sid: row.sid, + ...(tags === undefined ? {} : { tags }), + ...(note === undefined ? {} : { note }) + }; +} + +function parseContentEvents(row: Record): ContentEventBatchMessage | null { + if (!hasExactKeys(row, CONTENT_EVENTS_KEYS)) { + return null; + } + + const documentUrl = parseDocumentUrl(row.documentUrl); + + if (documentUrl === null || !isDenseArray(row.events, CONTENT_EVENT_BATCH_MAX, 1)) { + return null; + } + + const events: RawRecorderEvent[] = []; + const budget: PayloadBudget = { + nodes: 0, + stringChars: 0 + }; + + for (const value of row.events) { + const event = parseRawContentEvent(value, budget); + + if (!event) { + return null; + } + + events.push(event); + } + + return { + kind: "content.events", + events, + ...(documentUrl === undefined ? {} : { documentUrl }) + }; +} + +function parseContentMarker(row: Record): ExtensionInboundMessage | null { + if ( + !hasExactKeys(row, CONTENT_MARKER_KEYS) || + typeof row.message !== "string" || + row.message.length === 0 || + row.message.length > MAX_MARKER_CHARS + ) { + return null; + } + + const documentUrl = parseDocumentUrl(row.documentUrl); + + if (documentUrl === null) { + return null; + } + + return { + kind: "content.marker", + message: row.message, + ...(documentUrl === undefined ? {} : { documentUrl }) + }; +} + +function parseContentReady(row: Record): ExtensionInboundMessage | null { + if (!hasExactKeys(row, CONTENT_READY_KEYS)) { + return null; + } + + const documentUrl = parseDocumentUrl(row.documentUrl); + + if (documentUrl === null) { + return null; + } + + return { + kind: "content.ready", + ...(documentUrl === undefined ? {} : { documentUrl }) + }; +} + +function parseContentStopDrained(row: Record): ExtensionInboundMessage | null { + if (!hasExactKeys(row, CONTENT_STOP_DRAINED_KEYS) || !isIdentifier(row.sid, false)) { + return null; + } + + return { + kind: "content.stop-drained", + sid: row.sid + }; +} + +function parseRawContentEvent(value: unknown, budget: PayloadBudget): RawRecorderEvent | null { + const row = asPlainRecord(value); + + if ( + !row || + !hasExactKeys(row, RAW_EVENT_KEYS) || + row.source !== "content" || + typeof row.rawType !== "string" || + !CONTENT_RAW_TYPES.has(row.rawType) || + !Number.isSafeInteger(row.tabId) || + (row.tabId as number) < -1 || + !isIdentifier(row.sid, true) || + !isNonNegativeFiniteNumber(row.t) || + !isNonNegativeFiniteNumber(row.mono) || + (row.frame !== undefined && !isIdentifier(row.frame, false)) || + !isPlainRecord(row.payload) || + !isBoundedPayload(row.payload, budget) + ) { + return null; + } + + return { + source: "content", + rawType: row.rawType, + tabId: row.tabId as number, + sid: row.sid, + t: row.t, + mono: row.mono, + ...(row.frame === undefined ? {} : { frame: row.frame }), + payload: row.payload + }; +} + +function parseExportPolicy(value: unknown): UiExportSessionMessage["policy"] | null { + if (value === undefined) { + return undefined; + } + + const row = asPlainRecord(value); + + if (!row || !hasExactKeys(row, EXPORT_POLICY_KEYS)) { + return null; + } + + const includeScreenshots = parseOptionalBoolean(row.includeScreenshots); + const includeScreenRecordings = parseOptionalBoolean(row.includeScreenRecordings); + const maxArchiveBytes = parseOptionalBoundedInteger( + row.maxArchiveBytes, + 64 * 1024, + MAX_EXPORT_BYTES + ); + const recentWindowMs = parseOptionalBoundedInteger( + row.recentWindowMs, + 60_000, + MAX_EXPORT_WINDOW_MS + ); + + if ( + includeScreenshots === null || + includeScreenRecordings === null || + maxArchiveBytes === null || + recentWindowMs === null + ) { + return null; + } + + return { + ...(includeScreenshots === undefined ? {} : { includeScreenshots }), + ...(includeScreenRecordings === undefined ? {} : { includeScreenRecordings }), + ...(maxArchiveBytes === undefined ? {} : { maxArchiveBytes }), + ...(recentWindowMs === undefined ? {} : { recentWindowMs }) + }; +} + +function isBoundedPayload(value: Record, budget: PayloadBudget): boolean { + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }]; + const seen = new WeakSet(); + + while (stack.length > 0) { + const current = stack.pop(); + + if (!current || current.depth > MAX_PAYLOAD_DEPTH) { + return false; + } + + const item = current.value; + budget.nodes += 1; + + if (budget.nodes > MAX_PAYLOAD_NODES) { + return false; + } + + if (item === null || item === undefined || typeof item === "boolean") { + continue; + } + + if (typeof item === "number") { + if (!Number.isFinite(item)) { + return false; + } + continue; + } + + if (typeof item === "string") { + if (item.length > MAX_PAYLOAD_STRING_CHARS) { + return false; + } + + budget.stringChars += item.length; + + if (budget.stringChars > MAX_BATCH_PAYLOAD_STRING_CHARS) { + return false; + } + continue; + } + + if (typeof item !== "object") { + return false; + } + + if (seen.has(item)) { + return false; + } + seen.add(item); + + if (Array.isArray(item)) { + if (!isDenseArray(item, MAX_PAYLOAD_NODES)) { + return false; + } + + for (let index = item.length - 1; index >= 0; index -= 1) { + stack.push({ value: item[index], depth: current.depth + 1 }); + } + continue; + } + + const row = asPlainRecord(item); + + if (!row) { + return false; + } + + const keys = Object.keys(row); + + if (keys.length > MAX_PAYLOAD_OBJECT_KEYS) { + return false; + } + + for (const key of keys) { + if (key.length > MAX_IDENTIFIER_CHARS) { + return false; + } + + budget.stringChars += key.length; + + if (budget.stringChars > MAX_BATCH_PAYLOAD_STRING_CHARS) { + return false; + } + + stack.push({ value: row[key], depth: current.depth + 1 }); + } + } + + return true; +} + +function asPlainRecord(value: unknown): Record | null { + if (!isPlainRecord(value)) { + return null; + } + + const ownKeys = Reflect.ownKeys(value); + + for (const key of ownKeys) { + if (typeof key !== "string") { + return null; + } + + const descriptor = Object.getOwnPropertyDescriptor(value, key); + + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return null; + } + } + + return value; +} + +function isPlainRecord(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasExactKeys(row: Record, allowed: ReadonlySet): boolean { + const keys = Object.keys(row); + return keys.length <= allowed.size && keys.every((key) => allowed.has(key)); +} + +function isDenseArray(value: unknown, maxLength: number, minLength = 0): value is unknown[] { + if (!Array.isArray(value) || value.length < minLength || value.length > maxLength) { + return false; + } + + const ownKeys = Reflect.ownKeys(value); + + if (ownKeys.length !== value.length + 1 || !ownKeys.includes("length")) { + return false; + } + + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) { + return false; + } + } + + return true; +} + +function parseOptionalTabId(value: unknown): number | undefined | null { + if (value === undefined) { + return undefined; + } + + return Number.isSafeInteger(value) && (value as number) >= 0 ? (value as number) : null; +} + +function parseOptionalBoolean(value: unknown): boolean | undefined | null { + if (value === undefined) { + return undefined; + } + + return typeof value === "boolean" ? value : null; +} + +function parseOptionalBoundedInteger( + value: unknown, + min: number, + max: number +): number | undefined | null { + if (value === undefined) { + return undefined; + } + + return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max + ? (value as number) + : null; +} + +function parseOptionalBoundedString(value: unknown, maxChars: number): string | undefined | null { + if (value === undefined) { + return undefined; + } + + return typeof value === "string" && value.length <= maxChars ? value : null; +} + +function parseOptionalTags(value: unknown): string[] | undefined | null { + if (value === undefined) { + return undefined; + } + + if (!isDenseArray(value, MAX_TAGS)) { + return null; + } + + if ( + !value.every((tag): tag is string => typeof tag === "string" && tag.length <= MAX_TAG_CHARS) + ) { + return null; + } + + return [...value]; +} + +function parseDocumentUrl(value: unknown): string | undefined | null { + if (value === undefined) { + return undefined; + } + + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_DOCUMENT_URL_CHARS || + !parseUrl(value) + ) { + return null; + } + + return value; +} + +function isIdentifier(value: unknown, allowEmpty: boolean): value is string { + return ( + typeof value === "string" && + value.length <= MAX_IDENTIFIER_CHARS && + (allowEmpty || value.length > 0) + ); +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} diff --git a/apps/extension/src/shared/messages.ts b/apps/extension/src/shared/messages.ts index 743129f..43cc1a3 100644 --- a/apps/extension/src/shared/messages.ts +++ b/apps/extension/src/shared/messages.ts @@ -17,6 +17,8 @@ export const PORT_NAMES = { offscreen: "webblackbox:offscreen" } as const; +export const CONTENT_EVENT_BATCH_MAX = 120; + export type FullModeVisualCapture = "screenshots" | "recording" | "both" | "none"; export type UiStartSessionMessage = { diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index dd51dce..999658f 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -28,6 +28,10 @@ import { } from "@webblackbox/recorder"; import { getChromeApi, type MessageSenderLike, type PortLike } from "../shared/chrome-api.js"; +import { + isAuthorizedExtensionPort, + parseExtensionInboundMessage +} from "../shared/inbound-messages.js"; import { PORT_NAMES, type ExportPrivacyWarning, @@ -359,6 +363,7 @@ type LiteBodyCaptureRule = { }; const chromeApi = getChromeApi(); +const extensionBaseUrl = chromeApi?.runtime?.getURL("") ?? ""; const sessionsByTab = new Map(); const sessionsBySid = new Map(); @@ -513,9 +518,8 @@ chromeApi?.runtime?.onInstalled.addListener(() => { }); chromeApi?.runtime?.onConnect.addListener((port) => { - if ( - !Object.values(PORT_NAMES).includes(port.name as (typeof PORT_NAMES)[keyof typeof PORT_NAMES]) - ) { + if (!isAuthorizedExtensionPort(port, extensionBaseUrl)) { + port.disconnect?.(); return; } @@ -539,7 +543,12 @@ chromeApi?.runtime?.onConnect.addListener((port) => { return; } - const message = parseInboundMessage(rawMessage); + const message = parseExtensionInboundMessage(rawMessage, { + transport: "port", + portName: port.name, + sender: port.sender, + extensionBaseUrl + }); if (!message) { return; @@ -668,7 +677,11 @@ function sendContentPortRecordingState( } chromeApi?.runtime?.onMessage.addListener((rawMessage, sender, sendResponse) => { - const message = parseInboundMessage(rawMessage); + const message = parseExtensionInboundMessage(rawMessage, { + transport: "runtime", + sender, + extensionBaseUrl + }); if (!message) { return; @@ -5999,20 +6012,6 @@ async function resolveUiActionTabId(tabId?: number): Promise return sessionsByTab.keys().next().value; } -function parseInboundMessage(message: unknown): ExtensionInboundMessage | null { - if (message === null || typeof message !== "object" || Array.isArray(message)) { - return null; - } - - const kind = (message as { kind?: unknown }).kind; - - if (typeof kind !== "string") { - return null; - } - - return message as ExtensionInboundMessage; -} - async function setIdleBadge(): Promise { await chromeApi?.action?.setBadgeText({ text: "" }).catch(() => undefined); } From 3764f957d69979be6662e1d0628c40ae0b45e33b Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:15:19 +0800 Subject: [PATCH 043/181] fix(player): shell-quote generated curl methods --- packages/player-sdk/src/index.test.ts | 15 ++++++++++++--- packages/player-sdk/src/index.ts | 5 ++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/player-sdk/src/index.test.ts b/packages/player-sdk/src/index.test.ts index 4381daf..c636f13 100644 --- a/packages/player-sdk/src/index.test.ts +++ b/packages/player-sdk/src/index.test.ts @@ -695,7 +695,7 @@ describe("WebBlackboxPlayer", () => { const curl = player.generateCurl("R-1"); expect(curl).toContain("curl 'https://example.com/api'"); - expect(curl).toContain("-X POST"); + expect(curl).toContain("-X 'POST'"); const fetchSnippet = player.generateFetch("R-1"); expect(fetchSnippet).toContain("await fetch"); @@ -723,6 +723,15 @@ describe("WebBlackboxPlayer", () => { expect(har.log.entries[0]?.response.content.encoding).toBeUndefined(); }); + it("shell-quotes untrusted HTTP methods in generated curl commands", async () => { + const bytes = await createRichFixtureArchive("get`id`"); + const player = await WebBlackboxPlayer.open(bytes); + const curl = player.generateCurl("R-1"); + + expect(curl).toContain("-X 'GET`ID`'"); + expect(curl).not.toContain("-X GET`ID`"); + }); + it("builds storage timeline, report, and playwright script", async () => { const bytes = await createRichFixtureArchive(); const player = await WebBlackboxPlayer.open(bytes); @@ -1728,7 +1737,7 @@ async function createArchiveWithDeprecatedBlobPath(): Promise { return zip.generateAsync({ type: "uint8array" }); } -async function createRichFixtureArchive(): Promise { +async function createRichFixtureArchive(requestMethod = "POST"): Promise { const zip = new JSZip(); const events: WebBlackboxEvent[] = [ { @@ -1772,7 +1781,7 @@ async function createRichFixtureArchive(): Promise { data: { requestId: "R-1", request: { - method: "POST", + method: requestMethod, url: "https://example.com/api", headers: { "content-type": "application/json", diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index 5e702d9..b43a3e6 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -1503,7 +1503,10 @@ export class WebBlackboxPlayer { return null; } - const lines = [`curl ${shellQuote(entry.url)} \\`, ` -X ${entry.method.toUpperCase()} \\`]; + const lines = [ + `curl ${shellQuote(entry.url)} \\`, + ` -X ${shellQuote(entry.method.toUpperCase())} \\` + ]; for (const [name, value] of Object.entries(entry.requestHeaders)) { lines.push(` -H ${shellQuote(`${name}: ${value}`)} \\`); From 77f89203ff4af5312e344ffa750aabff7e29738e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:15:04 +0800 Subject: [PATCH 044/181] fix(extension): restore crash-consistent pipeline state --- apps/extension/src/offscreen/index.ts | 5 + apps/extension/src/sw/index.ts | 197 +++++++++++-- apps/extension/src/sw/runtime-state.test.ts | 98 +++++++ apps/extension/src/sw/runtime-state.ts | 98 ++++++- packages/pipeline/src/index.test.ts | 84 +++++- packages/pipeline/src/pipeline.ts | 292 +++++++++++++++++++- packages/pipeline/src/storage.ts | 21 ++ 7 files changed, 764 insertions(+), 31 deletions(-) diff --git a/apps/extension/src/offscreen/index.ts b/apps/extension/src/offscreen/index.ts index 01c7966..5f26276 100644 --- a/apps/extension/src/offscreen/index.ts +++ b/apps/extension/src/offscreen/index.ts @@ -20,6 +20,7 @@ type OffscreenPipelineRequest = { | "ingest" | "ingestBatch" | "flush" + | "getResumeState" | "putBlob" | "exportDownload" | "close" @@ -220,6 +221,10 @@ async function processPipelineRequest(message: OffscreenPipelineRequest): Promis throw new Error(`Pipeline session not found: ${message.sid}`); } + if (message.op === "getResumeState") { + return pipeline.getResumeState(); + } + if (message.op === "startScreenRecording") { return startOffscreenScreenRecording(message); } diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index 999658f..ac46fae 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -3,6 +3,7 @@ import { createChromeDebuggerTransport, type CdpRouter } from "@webblackbox/cdp-router"; +import type { PipelineResumeState, ScreenRecordingChunkReference } from "@webblackbox/pipeline"; import { createSessionId, DEFAULT_CAPTURE_POLICY, @@ -109,10 +110,13 @@ import { RuntimeCleanupScheduler, RuntimeStartCoordinator, capCleanupDeadline, + compactScreenRecordingChunkHashes, createRuntimeStateSnapshot, evaluateActiveRuntimeRestoration, extractPersistedRuntimeIdentities, + mergeRuntimeCountersWithSequenceWatermark, parseRuntimeStateSnapshot, + restoreScreenRecordingChunkHashes, type PersistedActiveRuntime, type PersistedRuntime, type PersistedRuntimeCounters, @@ -246,6 +250,7 @@ type SessionPipelineClient = { ingest: (event: WebBlackboxEvent) => Promise; ingestBatch: (events: WebBlackboxEvent[]) => Promise; flush: () => Promise; + getResumeState: () => Promise; putBlob: (mime: string, bytes: Uint8Array) => Promise; exportAndDownload: (options?: { passphrase?: string; @@ -265,6 +270,7 @@ type OffscreenPipelineRequest = { | "ingest" | "ingestBatch" | "flush" + | "getResumeState" | "putBlob" | "exportDownload" | "close" @@ -489,6 +495,7 @@ const STOP_DRAIN_ACK_TIMEOUT_MS = 3_000; const CDP_ARTIFACT_TIMEOUT_MS = 5_000; const CDP_HEAP_SNAPSHOT_TIMEOUT_MS = 8_000; const SCREEN_RECORDING_OFFSCREEN_SOURCE = "tab"; +const PIPELINE_RESUME_MAX_SCREEN_CHUNKS = 500_000; console.info("[WebBlackbox] service worker booted"); @@ -1126,6 +1133,7 @@ function createSessionRuntime(input: { performanceBudget: PerformanceBudgetConfig; counters?: PersistedRuntimeCounters; screenRecording?: PersistedScreenRecording; + screenRecordingChunks?: readonly ScreenRecordingChunkReference[]; }): SessionRuntime { const counters = input.counters ?? { recorderEventSequence: 0, @@ -1172,6 +1180,12 @@ function createSessionRuntime(input: { event: counters.recorderEventSequence, action: counters.recorderActionSequence }); + const restoredScreenRecordingChunks = input.screenRecording + ? restoreScreenRecordingChunkHashes( + input.screenRecording.chunkCount, + input.screenRecordingChunks ?? [] + ) + : null; const runtime: SessionRuntime = { sid: input.sid, @@ -1203,8 +1217,8 @@ function createSessionRuntime(input: { width: input.screenRecording.width, height: input.screenRecording.height, frameRate: input.screenRecording.frameRate, - chunks: new Array(input.screenRecording.chunkCount), - chunkCount: input.screenRecording.chunkCount, + chunks: restoredScreenRecordingChunks ?? [], + chunkCount: restoredScreenRecordingChunks?.length ?? input.screenRecording.chunkCount, sizeBytes: input.screenRecording.sizeBytes, pendingWrites: new Set(), stopPromise: null @@ -1302,7 +1316,12 @@ async function stopSession(tabId: number): Promise { runtime.stoppedAt = Date.now(); runtime.cleanupDeadline = runtime.stoppedAt + resolveRuntimeLocalTtlMs(runtime.config.capturePolicy); - await scheduleStoppedRuntimeCleanup(runtime); + await scheduleStoppedRuntimeCleanup(runtime).catch((error) => { + console.warn("[WebBlackbox] alarm setup failed; timer and restart recovery remain armed", { + sid: runtime.sid, + error: error instanceof Error ? error.message : String(error) + }); + }); if (sessionsByTab.size === 0) { await setIdleBadge(); @@ -2177,6 +2196,13 @@ function createOffscreenPipelineClient(sid: string): SessionPipelineClient { sid }); }, + getResumeState: async () => { + const result = await requestOffscreenPipeline({ + op: "getResumeState", + sid + }); + return normalizePipelineResumeState(result); + }, putBlob: async (mime, bytes) => { return requestOffscreenPipeline({ op: "putBlob", @@ -3270,9 +3296,7 @@ async function finalizeScreenRecording( } await waitForScreenRecordingChunkWrites(recording); - const chunks = recording.chunks.filter( - (chunk): chunk is string => typeof chunk === "string" && chunk.length > 0 - ); + const chunks = compactScreenRecordingChunkHashes(recording.chunks); ingestRawEvent({ source: "system", @@ -4259,6 +4283,93 @@ function shouldInjectHooksForMode(mode: CaptureMode): boolean { return shouldInjectPageHooksForMode(mode); } +function normalizePipelineResumeState(raw: unknown): PipelineResumeState { + const row = asRecord(raw); + const sequence = asRecord(row?.sequenceWatermark); + const event = sequence?.event; + const action = sequence?.action; + + if ( + !row || + Object.keys(row).length !== 2 || + !sequence || + Object.keys(sequence).length !== 2 || + !Number.isSafeInteger(event) || + (event as number) < 0 || + !Number.isSafeInteger(action) || + (action as number) < 0 || + !Array.isArray(row.screenRecordings) || + row.screenRecordings.length > 32 + ) { + throw new Error("Invalid offscreen pipeline resume state."); + } + + const screenRecordings: PipelineResumeState["screenRecordings"] = []; + const recordingIds = new Set(); + let totalChunks = 0; + + for (const candidate of row.screenRecordings) { + const recording = asRecord(candidate); + + if ( + !recording || + Object.keys(recording).length !== 2 || + typeof recording.recordingId !== "string" || + recording.recordingId.length === 0 || + recording.recordingId.length > 256 || + recordingIds.has(recording.recordingId) || + !Array.isArray(recording.chunks) + ) { + throw new Error("Invalid offscreen screen-recording resume state."); + } + + recordingIds.add(recording.recordingId); + totalChunks += recording.chunks.length; + + if (totalChunks > PIPELINE_RESUME_MAX_SCREEN_CHUNKS) { + throw new Error("Offscreen screen-recording resume state is too large."); + } + + const chunks: ScreenRecordingChunkReference[] = []; + const indexes = new Set(); + + for (const chunkCandidate of recording.chunks) { + const chunk = asRecord(chunkCandidate); + const index = chunk?.index; + const hash = chunk?.hash; + + if ( + !chunk || + Object.keys(chunk).length !== 2 || + !Number.isSafeInteger(index) || + (index as number) < 0 || + (index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS || + indexes.has(index as number) || + typeof hash !== "string" || + !/^[a-f0-9]{64}$/u.test(hash) + ) { + throw new Error("Invalid offscreen screen-recording chunk reference."); + } + + indexes.add(index as number); + chunks.push({ index: index as number, hash }); + } + + screenRecordings.push({ + recordingId: recording.recordingId, + chunks: chunks.sort((left, right) => left.index - right.index) + }); + } + + return { + sequenceWatermark: { + event: event as number, + action: action as number + }, + screenRecordings + }; +} + function normalizePipelineExportDownloadResult(raw: unknown): PipelineExportDownloadResult { const row = asRecord(raw); @@ -4802,17 +4913,18 @@ async function disposeStoppedSession(runtime: SessionRuntime): Promise { return; } - await runtimeCleanupScheduler.cancel(runtime.sid).catch(() => undefined); - - await flushBufferedPipelineEvents(runtime); - await runtime.queue; + await flushBufferedPipelineEvents(runtime).catch((error) => { + console.warn("[WebBlackbox] stopped-session buffer flush failed during purge", error); + }); + await runtime.queue.catch((error) => { + console.warn("[WebBlackbox] stopped-session queue drain failed during purge", error); + }); await runtime.pipeline.flush().catch(() => undefined); - await runtime.pipeline - .close({ - purge: true - }) - .catch(() => undefined); + await runtime.pipeline.close({ + purge: true + }); sessionsBySid.delete(runtime.sid); + await runtimeCleanupScheduler.cancel(runtime.sid).catch(() => undefined); if (sessionAnnotations.delete(runtime.sid)) { await persistSessionAnnotations().catch(() => undefined); } @@ -4823,9 +4935,11 @@ async function disposeStoppedSession(runtime: SessionRuntime): Promise { await setRecordingBadge(); } - await closeOffscreenIfUnused(); + await closeOffscreenIfUnused().catch(() => undefined); pushSessionList(); - await persistRuntimeState(); + await persistRuntimeState().catch((error) => { + console.warn("[WebBlackbox] failed to persist stopped-session purge", error); + }); notifyOffscreenPipelineStatus(); } @@ -5565,7 +5679,12 @@ async function restoreRuntimeState(): Promise { for (const runtime of stoppedRuntimes) { if (sessionsBySid.get(runtime.sid) === runtime) { - await scheduleStoppedRuntimeCleanup(runtime); + await scheduleStoppedRuntimeCleanup(runtime).catch((error) => { + console.warn("[WebBlackbox] stopped-session cleanup scheduled for retry", { + sid: runtime.sid, + error: error instanceof Error ? error.message : String(error) + }); + }); } } @@ -5665,6 +5784,28 @@ async function restoreActiveRuntime( config.redaction, config.capturePolicy ); + const resumeState = await pipeline.getResumeState(); + const restoredCounters = mergeRuntimeCountersWithSequenceWatermark( + persisted.counters, + resumeState.sequenceWatermark + ); + const screenRecordingChunks = persisted.screenRecording + ? resumeState.screenRecordings.find( + (recording) => recording.recordingId === persisted.screenRecording?.recordingId + )?.chunks + : undefined; + + if ( + persisted.screenRecording && + persisted.screenRecording.chunkCount > 0 && + !hasCompleteScreenRecordingResumePrefix( + persisted.screenRecording.chunkCount, + screenRecordingChunks ?? [] + ) + ) { + throw new Error("Screen-recording resume state is incomplete."); + } + const runtime = createSessionRuntime({ sid: persisted.sid, tabId: persisted.tabId, @@ -5681,8 +5822,9 @@ async function restoreActiveRuntime( startedAt: persisted.startedAt, pipeline, performanceBudget, - counters: persisted.counters, - screenRecording: persisted.screenRecording + counters: restoredCounters, + screenRecording: persisted.screenRecording, + screenRecordingChunks }); sessionsByTab.set(runtime.tabId, runtime); sessionsBySid.set(runtime.sid, runtime); @@ -5705,6 +5847,21 @@ async function restoreActiveRuntime( return runtime; } +function hasCompleteScreenRecordingResumePrefix( + expectedChunkCount: number, + chunks: readonly ScreenRecordingChunkReference[] +): boolean { + const indexes = new Set(chunks.map((chunk) => chunk.index)); + + for (let index = 0; index < expectedChunkCount; index += 1) { + if (!indexes.has(index)) { + return false; + } + } + + return true; +} + async function restoreStoppedRuntime( persisted: PersistedStoppedRuntime, config: typeof DEFAULT_RECORDER_CONFIG, diff --git a/apps/extension/src/sw/runtime-state.test.ts b/apps/extension/src/sw/runtime-state.test.ts index c332c0b..7dfb1a2 100644 --- a/apps/extension/src/sw/runtime-state.test.ts +++ b/apps/extension/src/sw/runtime-state.test.ts @@ -1,4 +1,5 @@ import { DEFAULT_RECORDER_CONFIG, type RecorderConfig } from "@webblackbox/protocol"; +import { WebBlackboxRecorder } from "@webblackbox/recorder"; import { describe, expect, it, vi } from "vitest"; import { @@ -7,8 +8,11 @@ import { capCleanupDeadline, createRuntimeCleanupAlarmName, createRuntimeStateSnapshot, + compactScreenRecordingChunkHashes, evaluateActiveRuntimeRestoration, + mergeRuntimeCountersWithSequenceWatermark, parseRuntimeStateSnapshot, + restoreScreenRecordingChunkHashes, type PersistedActiveRuntime, type PersistedRuntime } from "./runtime-state.js"; @@ -143,6 +147,66 @@ describe("runtime state checkpoint", () => { expect(capCleanupDeadline(1_000, 11_000, 2_000)).toBe(3_000); expect(capCleanupDeadline(1_000, 2_000, 5_000)).toBe(2_000); }); + + it("advances stale recorder counters to the pipeline high-water", () => { + const runtime = createRuntime() as PersistedActiveRuntime; + const merged = mergeRuntimeCountersWithSequenceWatermark(runtime.counters, { + event: 47, + action: 9 + }); + + expect(merged.recorderEventSequence).toBe(47); + expect(merged.recorderActionSequence).toBe(9); + expect(merged.eventCount).toBe(runtime.counters.eventCount); + + const recorder = new WebBlackboxRecorder(createConfig()); + recorder.restoreSequenceState({ + event: merged.recorderEventSequence, + action: merged.recorderActionSequence + }); + const next = recorder.ingest({ + source: "content", + rawType: "click", + sid: runtime.sid, + tabId: runtime.tabId, + t: runtime.startedAt + 1, + mono: runtime.startedAt + 1, + payload: { selector: "button" } + }); + expect(next.event?.id).toBe("E-00000048"); + expect(next.event?.ref?.act).toBe("A-000010"); + }); + + it("rebuilds restart-spanning video hashes without writing them to chrome.storage", () => { + const hashBeforeRestart = "a".repeat(64); + const hashAfterRestart = "b".repeat(64); + const hashes = restoreScreenRecordingChunkHashes(1, [ + { index: 0, hash: hashBeforeRestart }, + { index: 1, hash: hashAfterRestart } + ]); + + const endEvent = { + type: "screen.recording.end", + data: { + chunks: compactScreenRecordingChunkHashes(hashes) + } + }; + expect(endEvent.data.chunks).toEqual([hashBeforeRestart, hashAfterRestart]); + + const runtime = createRuntime() as PersistedActiveRuntime; + runtime.screenRecording = { + recordingId: "VR-restart", + startedAt: runtime.startedAt, + startedMono: runtime.startedAt, + mime: "video/webm", + chunkCount: 1, + sizeBytes: 10 + }; + const serialized = JSON.stringify(createRuntimeStateSnapshot([runtime])); + expect(serialized).not.toContain(hashBeforeRestart); + expect(serialized).not.toContain(hashAfterRestart); + expect(serialized).not.toContain('"chunks"'); + }); }); describe("MV3 cleanup scheduling", () => { @@ -188,6 +252,40 @@ describe("MV3 cleanup scheduling", () => { expect(onDue).toHaveBeenCalledOnce(); expect(sessions.has("S-1767225600001-expired")).toBe(false); }); + + it("re-arms cleanup after a transient due-time flush failure", async () => { + let now = 5_000; + const sessions = new Set(["S-1767225600001-retry"]); + const alarmCreate = vi.fn(); + const onDue = vi + .fn<(sid: string) => Promise>() + .mockRejectedValueOnce(new Error("transient flush failure")) + .mockImplementationOnce(async (sid) => { + sessions.delete(sid); + }); + const scheduler = new RuntimeCleanupScheduler({ + alarms: { create: alarmCreate, clear: vi.fn().mockResolvedValue(true) }, + now: () => now, + retryDelayMs: 100, + setTimer: () => 1 as unknown as ReturnType, + onDue + }); + + await expect(scheduler.schedule("S-1767225600001-retry", 4_999)).rejects.toThrow( + "transient flush failure" + ); + expect(sessions.has("S-1767225600001-retry")).toBe(true); + expect(scheduler.getDeadline("S-1767225600001-retry")).toBe(5_100); + expect(alarmCreate).toHaveBeenLastCalledWith( + createRuntimeCleanupAlarmName("S-1767225600001-retry"), + { when: 5_100 } + ); + + now = 5_101; + await scheduler.handleAlarm(createRuntimeCleanupAlarmName("S-1767225600001-retry")); + expect(sessions.has("S-1767225600001-retry")).toBe(false); + expect(onDue).toHaveBeenCalledTimes(2); + }); }); describe("runtime start coordination", () => { diff --git a/apps/extension/src/sw/runtime-state.ts b/apps/extension/src/sw/runtime-state.ts index b1f6737..5b7b126 100644 --- a/apps/extension/src/sw/runtime-state.ts +++ b/apps/extension/src/sw/runtime-state.ts @@ -11,6 +11,7 @@ export const RUNTIME_CLEANUP_ALARM_PREFIX = "webblackbox.runtime.cleanup:"; const MAX_PERSISTED_SESSIONS = 512; const MAX_TIMER_DELAY_MS = 2_147_000_000; +const MAX_SCREEN_RECORDING_CHUNKS = 500_000; const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; export type PersistedRuntimeCounters = { @@ -85,11 +86,22 @@ export type RuntimeCleanupAlarmApi = { clear(name: string): boolean | Promise; }; +export type RuntimeSequenceWatermark = { + event: number; + action: number; +}; + +export type RuntimeScreenRecordingChunkReference = { + index: number; + hash: string; +}; + export type RuntimeCleanupSchedulerOptions = { alarms?: RuntimeCleanupAlarmApi; now?: () => number; setTimer?: (callback: () => void, delayMs: number) => ReturnType; clearTimer?: (timer: ReturnType) => void; + retryDelayMs?: number; onDue: (sid: string) => void | Promise; }; @@ -245,6 +257,52 @@ export function capCleanupDeadline( return Math.min(persistedDeadline, stoppedAt + localTtlMs); } +export function mergeRuntimeCountersWithSequenceWatermark( + counters: PersistedRuntimeCounters, + watermark: RuntimeSequenceWatermark +): PersistedRuntimeCounters { + return { + ...counters, + recorderEventSequence: Math.max(counters.recorderEventSequence, watermark.event), + recorderActionSequence: Math.max(counters.recorderActionSequence, watermark.action) + }; +} + +export function restoreScreenRecordingChunkHashes( + persistedChunkCount: number, + references: readonly RuntimeScreenRecordingChunkReference[] +): string[] { + if ( + !Number.isSafeInteger(persistedChunkCount) || + persistedChunkCount < 0 || + persistedChunkCount > MAX_SCREEN_RECORDING_CHUNKS || + references.some( + (reference) => + !Number.isSafeInteger(reference.index) || + reference.index < 0 || + reference.index >= MAX_SCREEN_RECORDING_CHUNKS + ) + ) { + throw new Error("Invalid screen-recording resume chunk range."); + } + + const maxIndex = references.reduce( + (maximum, reference) => Math.max(maximum, reference.index), + -1 + ); + const chunks = new Array(Math.max(persistedChunkCount, maxIndex + 1)); + + for (const reference of references) { + chunks[reference.index] = reference.hash; + } + + return chunks; +} + +export function compactScreenRecordingChunkHashes(chunks: readonly string[]): string[] { + return chunks.filter((chunk) => typeof chunk === "string" && chunk.length > 0); +} + export function createRuntimeCleanupAlarmName(sid: string): string { return `${RUNTIME_CLEANUP_ALARM_PREFIX}${sid}`; } @@ -271,6 +329,7 @@ export class RuntimeCleanupScheduler { ) => ReturnType; private readonly clearTimer: (timer: ReturnType) => void; private readonly onDue: (sid: string) => void | Promise; + private readonly retryDelayMs: number; private readonly deadlines = new Map(); private readonly timers = new Map>(); private readonly dueTasks = new Map>(); @@ -281,6 +340,7 @@ export class RuntimeCleanupScheduler { this.setTimer = options.setTimer ?? setTimeout; this.clearTimer = options.clearTimer ?? clearTimeout; this.onDue = options.onDue; + this.retryDelayMs = Math.max(1, Math.round(options.retryDelayMs ?? 60_000)); } public async schedule(sid: string, deadline: number): Promise { @@ -299,8 +359,14 @@ export class RuntimeCleanupScheduler { } const alarmName = createRuntimeCleanupAlarmName(sid); - await this.alarms?.clear(alarmName); - await this.alarms?.create(alarmName, { when: deadline }); + let alarmError: unknown; + + try { + await this.alarms?.clear(alarmName); + await this.alarms?.create(alarmName, { when: deadline }); + } catch (error) { + alarmError = error; + } const timer = this.setTimer( () => { @@ -310,6 +376,10 @@ export class RuntimeCleanupScheduler { Math.min(delay, MAX_TIMER_DELAY_MS) ); this.timers.set(sid, timer); + + if (alarmError) { + throw alarmError; + } } public async cancel(sid: string): Promise { @@ -356,11 +426,26 @@ export class RuntimeCleanupScheduler { return; } - this.deadlines.delete(sid); this.cancelTimer(sid); - const task = Promise.resolve(this.onDue(sid)).finally(() => { - this.dueTasks.delete(sid); - }); + const task = Promise.resolve() + .then(() => this.onDue(sid)) + .then(async () => { + this.deadlines.delete(sid); + this.cancelTimer(sid); + await this.alarms?.clear(createRuntimeCleanupAlarmName(sid)); + }) + .catch(async (error: unknown) => { + if (this.deadlines.has(sid)) { + const retryDeadline = this.now() + this.retryDelayMs; + this.deadlines.set(sid, retryDeadline); + await this.schedule(sid, retryDeadline).catch(() => undefined); + } + + throw error; + }) + .finally(() => { + this.dueTasks.delete(sid); + }); this.dueTasks.set(sid, task); await task; } @@ -546,6 +631,7 @@ function parseScreenRecording(input: unknown): PersistedScreenRecording | null { !isOptionalPositiveNumber(record.height) || !isOptionalPositiveNumber(record.frameRate) || !isNonNegativeSafeInteger(record.chunkCount) || + (record.chunkCount as number) > MAX_SCREEN_RECORDING_CHUNKS || !isNonNegativeSafeInteger(record.sizeBytes) ) { return null; diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index ca7ca99..27207a7 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -267,7 +267,10 @@ describe("pipeline", () => { maxChunkBytes: 1 }); await first.start(); - await first.ingest(createEvent("E-before-restart", "user.marker", 1)); + await first.ingest({ + ...createEvent("E-00000051", "user.marker", 1), + ref: { act: "A-000007" } + }); await first.close(); const restored = createTestPipeline({ @@ -276,11 +279,88 @@ describe("pipeline", () => { maxChunkBytes: 1 }); await restored.start(); - await restored.ingest(createEvent("E-after-restart", "user.marker", 2)); + expect(await restored.getSequenceWatermark()).toEqual({ event: 51, action: 7 }); + await restored.ingest(createEvent("E-00000052", "user.marker", 2)); + const exported = await restored.exportBundle(FULL_EXPORT_OPTIONS); + const archive = await readWebBlackboxArchive(exported.bytes); await restored.close(); const chunks = await storage.listChunks(SESSION.sid); expect(chunks.map((chunk) => chunk.meta.seq)).toEqual([1, 2]); + expect(chunks[1]?.resumeState?.sequenceWatermark).toEqual({ event: 52, action: 7 }); + expect(archive.events.map((event) => event.id)).toEqual(["E-00000051", "E-00000052"]); + expect(new Set(archive.events.map((event) => event.id)).size).toBe(archive.events.length); + }); + + it("reports pending offscreen events and screen chunk hashes in the live resume state", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1_000_000 + }); + const hash = "c".repeat(64); + + await pipeline.start(); + await pipeline.ingest({ + ...createEvent("E-00000091", "screen.recording.chunk", 1, { + recordingId: "VR-pending", + chunkId: hash, + index: 0, + mime: "video/webm", + size: 10 + }), + ref: { act: "A-000012" } + }); + + expect(await storage.listChunks(SESSION.sid)).toHaveLength(0); + expect(await pipeline.getResumeState()).toEqual({ + sequenceWatermark: { event: 91, action: 12 }, + screenRecordings: [ + { + recordingId: "VR-pending", + chunks: [{ index: 0, hash }] + } + ] + }); + }); + + it("restores cumulative screen hashes from the latest durable chunk only", async () => { + const storage = new MemoryPipelineStorage(); + const before = "d".repeat(64); + const after = "e".repeat(64); + const first = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await first.start(); + await first.ingest( + createEvent("E-00000101", "screen.recording.chunk", 1, { + recordingId: "VR-restart", + chunkId: before, + index: 0 + }) + ); + await first.ingest( + createEvent("E-00000102", "screen.recording.chunk", 2, { + recordingId: "VR-restart", + chunkId: after, + index: 1 + }) + ); + await first.close(); + + const restored = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await restored.start(); + expect(await restored.getResumeState()).toEqual({ + sequenceWatermark: { event: 102, action: 0 }, + screenRecordings: [ + { + recordingId: "VR-restart", + chunks: [ + { index: 0, hash: before }, + { index: 1, hash: after } + ] + } + ] + }); }); it("can retry an ingest after chunk persistence fails without a sequence gap or duplicate", async () => { diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index 1b7510f..d0c2718 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -20,7 +20,10 @@ import { EventIndexer } from "./indexer.js"; import { assertPrivacyScannerPassed, buildPrivacyManifest } from "./privacy.js"; import { PIPELINE_STORAGE_SECURITY, + type PipelineResumeState, type PipelineStorage, + type RecorderSequenceWatermark, + type ScreenRecordingResumeState, type StoredBlob, type StoredChunk } from "./storage.js"; @@ -96,6 +99,10 @@ const LOW_RISK_OVERRIDE_BLOCKED_CATEGORIES = new Set([ ]); const LOCAL_DEBUG_EVIDENCE_PATTERN = /^local-attestation:[A-Za-z0-9][A-Za-z0-9._:-]{7,}$/; const SYNTHETIC_EVIDENCE_PATTERN = /^(?:synthetic-fixture|ci-run):[A-Za-z0-9][A-Za-z0-9._:-]{7,}$/; +const EVENT_SEQUENCE_PATTERN = /^E-(\d+)$/; +const ACTION_SEQUENCE_PATTERN = /^A-(\d+)$/; +const BLOB_HASH_PATTERN = /^[a-f0-9]{64}$/; +const PIPELINE_RESUME_MAX_SCREEN_CHUNKS = 500_000; export class FlightRecorderPipeline { private readonly chunker: EventChunker; @@ -104,6 +111,8 @@ export class FlightRecorderPipeline { private chunkOperationTail: Promise = Promise.resolve(); private acceptingEvents = true; private closePromise: Promise | null = null; + private sequenceWatermark: RecorderSequenceWatermark = { event: 0, action: 0 }; + private readonly screenRecordingChunks = new Map>(); public constructor(private readonly options: FlightRecorderPipelineOptions) { const codec = resolveChunkCodec(options.chunkCodec); @@ -122,10 +131,15 @@ export class FlightRecorderPipeline { public async start(): Promise { await this.ensureStorageReady(); - const lastSequence = - (await this.options.storage.getLatestChunkMeta(this.options.session.sid))?.seq ?? 0; + const latestMeta = await this.options.storage.getLatestChunkMeta(this.options.session.sid); + const lastSequence = latestMeta?.seq ?? 0; this.chunker.restoreSequence(lastSequence); + const resumeState = latestMeta + ? await this.restoreResumeStateFromLatestChunk(latestMeta.chunkId) + : emptyPipelineResumeState(); + this.sequenceWatermark = resumeState.sequenceWatermark; + this.restoreScreenRecordingResumeState(resumeState.screenRecordings); await this.options.storage.putSession(this.options.session); } @@ -137,6 +151,7 @@ export class FlightRecorderPipeline { await this.enqueueChunkOperation(async () => { await this.chunker.append(event); + this.applyEventToResumeState(event); }); } @@ -156,10 +171,20 @@ export class FlightRecorderPipeline { await this.enqueueChunkOperation(async () => { for (const event of events) { await this.chunker.append(event); + this.applyEventToResumeState(event); } }); } + public async getSequenceWatermark(): Promise { + return (await this.getResumeState()).sequenceWatermark; + } + + public async getResumeState(): Promise { + await this.enqueueChunkOperation(async () => undefined); + return this.snapshotResumeState(); + } + public async flush(): Promise { await this.enqueueChunkOperation(async () => { await this.chunker.flush(); @@ -689,12 +714,72 @@ export class FlightRecorderPipeline { codec, sha256: hash }, - bytes + bytes, + resumeState: derivePipelineResumeState(this.snapshotResumeState(), events) }; await this.options.storage.putChunk(chunk); } + private async restoreResumeStateFromLatestChunk(chunkId: string): Promise { + const latest = await this.options.storage.getChunk(this.options.session.sid, chunkId); + + if (!latest) { + throw new Error("Latest pipeline chunk metadata points to missing chunk payload."); + } + + if (isPipelineResumeState(latest.resumeState)) { + return clonePipelineResumeState(latest.resumeState); + } + + const events = await decodeChunkEvents(latest.bytes, latest.meta.codec); + + // Legacy chunks did not persist an exact action high-water. Advancing the + // action sequence to the durable event high-water is deterministic and + // prevents reuse without scanning an unbounded archive history. + const legacy = derivePipelineResumeState(emptyPipelineResumeState(), events); + legacy.sequenceWatermark.action = Math.max( + legacy.sequenceWatermark.action, + legacy.sequenceWatermark.event + ); + return legacy; + } + + private applyEventToResumeState(event: WebBlackboxEvent): void { + this.sequenceWatermark = mergeSequenceWatermarks( + this.sequenceWatermark, + deriveRecorderSequenceWatermark([event]) + ); + applyScreenRecordingChunkEvent(this.screenRecordingChunks, event); + } + + private snapshotResumeState(): PipelineResumeState { + return { + sequenceWatermark: { ...this.sequenceWatermark }, + screenRecordings: [...this.screenRecordingChunks.entries()] + .map(([recordingId, chunks]) => ({ + recordingId, + chunks: [...chunks.entries()] + .map(([index, hash]) => ({ index, hash })) + .sort((left, right) => left.index - right.index) + })) + .sort((left, right) => left.recordingId.localeCompare(right.recordingId)) + }; + } + + private restoreScreenRecordingResumeState( + screenRecordings: readonly ScreenRecordingResumeState[] + ): void { + this.screenRecordingChunks.clear(); + + for (const recording of screenRecordings) { + this.screenRecordingChunks.set( + recording.recordingId, + new Map(recording.chunks.map((chunk) => [chunk.index, chunk.hash])) + ); + } + } + private enqueueChunkOperation(operation: () => Promise): Promise { const result = this.chunkOperationTail.then(operation); this.chunkOperationTail = result.then( @@ -997,6 +1082,207 @@ function assertPrivacyClassifiedEvent(event: WebBlackboxEvent): void { } } +function deriveRecorderSequenceWatermark( + events: readonly WebBlackboxEvent[] +): RecorderSequenceWatermark { + const watermark: RecorderSequenceWatermark = { event: 0, action: 0 }; + + for (const event of events) { + watermark.event = Math.max( + watermark.event, + parseCanonicalSequence(event.id, EVENT_SEQUENCE_PATTERN) + ); + watermark.action = Math.max( + watermark.action, + parseCanonicalSequence(event.ref?.act, ACTION_SEQUENCE_PATTERN) + ); + } + + return watermark; +} + +function mergeSequenceWatermarks( + left: RecorderSequenceWatermark, + right: RecorderSequenceWatermark +): RecorderSequenceWatermark { + return { + event: Math.max(left.event, right.event), + action: Math.max(left.action, right.action) + }; +} + +function derivePipelineResumeState( + base: PipelineResumeState, + events: readonly WebBlackboxEvent[] +): PipelineResumeState { + const screenRecordingChunks = new Map>(); + + for (const recording of base.screenRecordings) { + screenRecordingChunks.set( + recording.recordingId, + new Map(recording.chunks.map((chunk) => [chunk.index, chunk.hash])) + ); + } + + let sequenceWatermark = { ...base.sequenceWatermark }; + + for (const event of events) { + sequenceWatermark = mergeSequenceWatermarks( + sequenceWatermark, + deriveRecorderSequenceWatermark([event]) + ); + applyScreenRecordingChunkEvent(screenRecordingChunks, event); + } + + return { + sequenceWatermark, + screenRecordings: [...screenRecordingChunks.entries()] + .map(([recordingId, chunks]) => ({ + recordingId, + chunks: [...chunks.entries()] + .map(([index, hash]) => ({ index, hash })) + .sort((left, right) => left.index - right.index) + })) + .sort((left, right) => left.recordingId.localeCompare(right.recordingId)) + }; +} + +function applyScreenRecordingChunkEvent( + output: Map>, + event: WebBlackboxEvent +): void { + if (event.type !== "screen.recording.chunk") { + return; + } + + const data = asUnknownRecord(event.data); + const recordingId = data?.recordingId; + const hash = data?.chunkId; + const index = data?.index; + + if ( + typeof recordingId !== "string" || + recordingId.length === 0 || + recordingId.length > 256 || + typeof hash !== "string" || + !BLOB_HASH_PATTERN.test(hash) || + !Number.isSafeInteger(index) || + (index as number) < 0 || + (index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS + ) { + return; + } + + const chunks = output.get(recordingId) ?? new Map(); + chunks.set(index as number, hash); + output.set(recordingId, chunks); +} + +function isPipelineResumeState(value: unknown): value is PipelineResumeState { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + const sequence = asUnknownRecord(record.sequenceWatermark); + + if ( + Object.keys(record).length !== 2 || + !sequence || + Object.keys(sequence).length !== 2 || + !Number.isSafeInteger(sequence.event) || + (sequence.event as number) < 0 || + !Number.isSafeInteger(sequence.action) || + (sequence.action as number) < 0 || + !Array.isArray(record.screenRecordings) || + record.screenRecordings.length > 32 + ) { + return false; + } + + const recordingIds = new Set(); + let totalChunks = 0; + + for (const candidate of record.screenRecordings) { + const recording = asUnknownRecord(candidate); + + if ( + !recording || + Object.keys(recording).length !== 2 || + typeof recording.recordingId !== "string" || + recording.recordingId.length === 0 || + recording.recordingId.length > 256 || + recordingIds.has(recording.recordingId) || + !Array.isArray(recording.chunks) + ) { + return false; + } + + recordingIds.add(recording.recordingId); + totalChunks += recording.chunks.length; + + if (totalChunks > PIPELINE_RESUME_MAX_SCREEN_CHUNKS) { + return false; + } + + const indexes = new Set(); + + for (const chunkCandidate of recording.chunks) { + const chunk = asUnknownRecord(chunkCandidate); + + if ( + !chunk || + Object.keys(chunk).length !== 2 || + !Number.isSafeInteger(chunk.index) || + (chunk.index as number) < 0 || + (chunk.index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS || + indexes.has(chunk.index as number) || + typeof chunk.hash !== "string" || + !BLOB_HASH_PATTERN.test(chunk.hash) + ) { + return false; + } + + indexes.add(chunk.index as number); + } + } + + return true; +} + +function emptyPipelineResumeState(): PipelineResumeState { + return { + sequenceWatermark: { event: 0, action: 0 }, + screenRecordings: [] + }; +} + +function clonePipelineResumeState(state: PipelineResumeState): PipelineResumeState { + return { + sequenceWatermark: { ...state.sequenceWatermark }, + screenRecordings: state.screenRecordings.map((recording) => ({ + recordingId: recording.recordingId, + chunks: recording.chunks.map((chunk) => ({ ...chunk })) + })) + }; +} + +function asUnknownRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function parseCanonicalSequence(value: unknown, pattern: RegExp): number { + if (typeof value !== "string") { + return 0; + } + + const match = pattern.exec(value); + const sequence = match?.[1] ? Number(match[1]) : Number.NaN; + return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : 0; +} + function normalizeBoundedPositiveInt(value: unknown): number | null { if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { return null; diff --git a/packages/pipeline/src/storage.ts b/packages/pipeline/src/storage.ts index e02274c..0c108de 100644 --- a/packages/pipeline/src/storage.ts +++ b/packages/pipeline/src/storage.ts @@ -12,6 +12,27 @@ export type StoredChunk = { sid: string; meta: ChunkTimeIndexEntry; bytes: Uint8Array; + resumeState?: PipelineResumeState; +}; + +export type RecorderSequenceWatermark = { + event: number; + action: number; +}; + +export type ScreenRecordingChunkReference = { + index: number; + hash: string; +}; + +export type ScreenRecordingResumeState = { + recordingId: string; + chunks: ScreenRecordingChunkReference[]; +}; + +export type PipelineResumeState = { + sequenceWatermark: RecorderSequenceWatermark; + screenRecordings: ScreenRecordingResumeState[]; }; export type StoredBlob = { From 7797830061578ebc6b360c4bfdbdb49ff365b1bf Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:19:00 +0800 Subject: [PATCH 045/181] fix(player): bound archive resource consumption --- apps/mcp-server/README.md | 1 + .../src/archive-file-reader.test.ts | 51 ++ apps/mcp-server/src/archive-file-reader.ts | 47 ++ .../src/archive-operation-admission.test.ts | 37 ++ .../src/archive-operation-admission.ts | 34 ++ apps/mcp-server/src/index.ts | 203 ++++--- apps/mcp-server/src/session-tools.test.ts | 18 +- apps/mcp-server/src/session-tools.ts | 31 +- apps/share-server/README.md | 9 +- apps/share-server/package.json | 2 +- apps/share-server/src/index.test.ts | 212 ++++++- apps/share-server/src/index.ts | 539 +++++++++++++++--- docs/SECURITY.md | 2 +- packages/player-sdk/README.md | 33 ++ packages/player-sdk/package.json | 2 +- .../src/archive-resource-limits.test.ts | 138 +++++ .../player-sdk/src/archive-resource-limits.ts | 429 ++++++++++++++ .../src/bounded-stream-reader.test.ts | 72 +++ .../player-sdk/src/bounded-stream-reader.ts | 114 ++++ .../player-sdk/src/bounded-zip-reader.test.ts | 194 +++++++ packages/player-sdk/src/bounded-zip-reader.ts | 371 ++++++++++++ packages/player-sdk/src/index.test.ts | 195 ++++++- packages/player-sdk/src/index.ts | 491 ++++++++++------ pnpm-lock.yaml | 4 +- 24 files changed, 2858 insertions(+), 371 deletions(-) create mode 100644 apps/mcp-server/src/archive-file-reader.test.ts create mode 100644 apps/mcp-server/src/archive-file-reader.ts create mode 100644 apps/mcp-server/src/archive-operation-admission.test.ts create mode 100644 apps/mcp-server/src/archive-operation-admission.ts create mode 100644 packages/player-sdk/src/archive-resource-limits.test.ts create mode 100644 packages/player-sdk/src/archive-resource-limits.ts create mode 100644 packages/player-sdk/src/bounded-stream-reader.test.ts create mode 100644 packages/player-sdk/src/bounded-stream-reader.ts create mode 100644 packages/player-sdk/src/bounded-zip-reader.test.ts create mode 100644 packages/player-sdk/src/bounded-zip-reader.ts diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index facd6cb..3eeb8d6 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -91,6 +91,7 @@ node dist/cli.js --version ## Notes - Archive paths are resolved from the current working directory if relative. +- Archive size is checked against a 64 MiB MCP ceiling on one open file descriptor before bytes are read; the Player SDK then enforces ZIP expansion and event budgets. The production MCP server admits one archive-heavy tool call at a time; excess concurrent calls fail fast instead of multiplying retained Player memory. `compare_sessions` is one admitted operation and can hold its two bounded archives. - Encrypted archives require `passphrase`. - `query_events` defaults to payload-hidden output (`includeData=false`) to avoid huge responses. - Range-scoped tools (`monoStart` / `monoEnd`) preload only intersecting chunks when opening archives. diff --git a/apps/mcp-server/src/archive-file-reader.test.ts b/apps/mcp-server/src/archive-file-reader.test.ts new file mode 100644 index 0000000..66a5fe1 --- /dev/null +++ b/apps/mcp-server/src/archive-file-reader.test.ts @@ -0,0 +1,51 @@ +import { appendFile, mkdtemp, open, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { readFileHandleBounded } from "./archive-file-reader.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe("bounded archive file reader", () => { + it("detects growth on the same inode without allocating the appended bytes", async () => { + const root = await mkdtemp(join(tmpdir(), "wb-mcp-fd-grow-")); + tempDirs.push(root); + const path = join(root, "archive.webblackbox"); + await writeFile(path, "1234"); + const handle = await open(path, "r"); + + try { + const before = await handle.stat(); + await appendFile(path, "5".repeat(1024 * 1024)); + await expect(readFileHandleBounded(handle, before.size, 2 * 1024 * 1024)).rejects.toThrow( + /size changed while being read/i + ); + } finally { + await handle.close(); + } + }); + + it("keeps reading the opened inode when the pathname is replaced", async () => { + const root = await mkdtemp(join(tmpdir(), "wb-mcp-fd-swap-")); + tempDirs.push(root); + const path = join(root, "archive.webblackbox"); + const originalPath = join(root, "original.webblackbox"); + await writeFile(path, "safe"); + const handle = await open(path, "r"); + + try { + const before = await handle.stat(); + await rename(path, originalPath); + await writeFile(path, "attacker replacement"); + const bytes = await readFileHandleBounded(handle, before.size, before.size); + expect(bytes.toString("utf8")).toBe("safe"); + } finally { + await handle.close(); + } + }); +}); diff --git a/apps/mcp-server/src/archive-file-reader.ts b/apps/mcp-server/src/archive-file-reader.ts new file mode 100644 index 0000000..3068e89 --- /dev/null +++ b/apps/mcp-server/src/archive-file-reader.ts @@ -0,0 +1,47 @@ +import type { FileHandle } from "node:fs/promises"; + +/** Reads one already-open file descriptor without allocating beyond the observed size. */ +export async function readFileHandleBounded( + handle: FileHandle, + expectedBytes: number, + maxBytes: number +): Promise { + if (!Number.isSafeInteger(expectedBytes) || expectedBytes < 0 || expectedBytes > maxBytes) { + throw new Error(`Archive input exceeds the MCP limit (${expectedBytes} > ${maxBytes} bytes).`); + } + + const output = Buffer.allocUnsafe(expectedBytes); + let offset = 0; + + while (offset < expectedBytes) { + const { bytesRead } = await handle.read(output, offset, expectedBytes - offset, offset); + if (bytesRead === 0) { + break; + } + offset += bytesRead; + } + + if (offset !== expectedBytes) { + throw new Error( + `Archive file size changed while being read (${offset} bytes read; expected ${expectedBytes}).` + ); + } + + const probe = Buffer.allocUnsafe(1); + const { bytesRead: trailingBytes } = await handle.read(probe, 0, 1, expectedBytes); + const afterRead = await handle.stat(); + if (trailingBytes > 0 || afterRead.size !== expectedBytes) { + const observedBytes = Math.max(expectedBytes + trailingBytes, afterRead.size); + if (observedBytes > maxBytes) { + throw new Error( + `Archive input exceeds the MCP limit (${observedBytes} > ${maxBytes} bytes).` + ); + } + + throw new Error( + `Archive file size changed while being read (${observedBytes} bytes observed; expected ${expectedBytes}).` + ); + } + + return output; +} diff --git a/apps/mcp-server/src/archive-operation-admission.test.ts b/apps/mcp-server/src/archive-operation-admission.test.ts new file mode 100644 index 0000000..fcab55d --- /dev/null +++ b/apps/mcp-server/src/archive-operation-admission.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { + ArchiveOperationAdmission, + ArchiveOperationCapacityError +} from "./archive-operation-admission.js"; + +describe("archive operation admission", () => { + it("rejects excess work and releases capacity after success", async () => { + const admission = new ArchiveOperationAdmission(1); + let releaseFirst: (() => void) | undefined; + const first = admission.run( + () => + new Promise((resolve) => { + releaseFirst = () => resolve("first"); + }) + ); + + await expect(admission.run(async () => "second")).rejects.toBeInstanceOf( + ArchiveOperationCapacityError + ); + releaseFirst?.(); + await expect(first).resolves.toBe("first"); + await expect(admission.run(async () => "third")).resolves.toBe("third"); + }); + + it("releases capacity when an admitted operation fails", async () => { + const admission = new ArchiveOperationAdmission(1); + + await expect( + admission.run(async () => { + throw new Error("operation failed"); + }) + ).rejects.toThrow("operation failed"); + await expect(admission.run(async () => "recovered")).resolves.toBe("recovered"); + }); +}); diff --git a/apps/mcp-server/src/archive-operation-admission.ts b/apps/mcp-server/src/archive-operation-admission.ts new file mode 100644 index 0000000..405a84a --- /dev/null +++ b/apps/mcp-server/src/archive-operation-admission.ts @@ -0,0 +1,34 @@ +/** Raised when another archive-heavy MCP operation already owns the process budget. */ +export class ArchiveOperationCapacityError extends Error { + public override readonly name = "ArchiveOperationCapacityError"; + + public constructor(public readonly maxConcurrentOperations: number) { + super( + `Archive operation capacity is exhausted (maximum ${maxConcurrentOperations} concurrent operation${maxConcurrentOperations === 1 ? "" : "s"}).` + ); + } +} + +/** Bounds concurrent operations for the full lifetime of their parsed Player instances. */ +export class ArchiveOperationAdmission { + private activeOperations = 0; + + public constructor(private readonly maxConcurrentOperations = 1) { + if (!Number.isSafeInteger(maxConcurrentOperations) || maxConcurrentOperations <= 0) { + throw new TypeError("Archive operation concurrency must be a positive safe integer."); + } + } + + public async run(operation: () => Promise): Promise { + if (this.activeOperations >= this.maxConcurrentOperations) { + throw new ArchiveOperationCapacityError(this.maxConcurrentOperations); + } + + this.activeOperations += 1; + try { + return await operation(); + } finally { + this.activeOperations -= 1; + } + } +} diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index e6ba15e..ea4ee31 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -1,5 +1,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { ArchiveOperationAdmission } from "./archive-operation-admission.js"; import { compareSessions, compareSessionsInput, @@ -37,6 +39,7 @@ export function createServer(): McpServer { name: SERVER_NAME, version: SERVER_VERSION }); + const archiveOperations = new ArchiveOperationAdmission(1); server.tool("health", "Health check", {}, async () => { return { @@ -74,13 +77,15 @@ export function createServer(): McpServer { "Open an archive and return session-level summary metrics and top issues.", sessionSummaryInput, async ({ path, passphrase, slowRequestMs, topN }) => { - return toTextPayload( - await summarizeSession({ - path, - passphrase, - slowRequestMs, - topN - }) + return archiveOperations.run(async () => + toTextPayload( + await summarizeSession({ + path, + passphrase, + slowRequestMs, + topN + }) + ) ); } ); @@ -103,21 +108,23 @@ export function createServer(): McpServer { includeData, maxDataChars }) => { - return toTextPayload( - await queryEvents({ - path, - passphrase, - text, - types, - levels, - requestId, - monoStart, - monoEnd, - offset, - limit, - includeData, - maxDataChars - }) + return archiveOperations.run(async () => + toTextPayload( + await queryEvents({ + path, + passphrase, + text, + types, + levels, + requestId, + monoStart, + monoEnd, + offset, + limit, + includeData, + maxDataChars + }) + ) ); } ); @@ -127,13 +134,15 @@ export function createServer(): McpServer { "Summarize failed and slow network requests from an archive.", networkIssuesInput, async ({ path, passphrase, minDurationMs, limit }) => { - return toTextPayload( - await summarizeNetworkIssues({ - path, - passphrase, - minDurationMs, - limit - }) + return archiveOperations.run(async () => + toTextPayload( + await summarizeNetworkIssues({ + path, + passphrase, + minDurationMs, + limit + }) + ) ); } ); @@ -155,20 +164,22 @@ export function createServer(): McpServer { projectKey, priority }) => { - return toTextPayload( - await generateBugReportBundle({ - path, - passphrase, - title, - maxItems, - monoStart, - monoEnd, - labels, - assignees, - issueType, - projectKey, - priority - }) + return archiveOperations.run(async () => + toTextPayload( + await generateBugReportBundle({ + path, + passphrase, + title, + maxItems, + monoStart, + monoEnd, + labels, + assignees, + issueType, + projectKey, + priority + }) + ) ); } ); @@ -178,13 +189,15 @@ export function createServer(): McpServer { "Export HAR JSON string from an archive, optionally within a mono range.", exportHarInput, async ({ path, passphrase, monoStart, monoEnd }) => { - return toTextPayload( - await exportHarFromArchive({ - path, - passphrase, - monoStart, - monoEnd - }) + return archiveOperations.run(async () => + toTextPayload( + await exportHarFromArchive({ + path, + passphrase, + monoStart, + monoEnd + }) + ) ); } ); @@ -203,17 +216,19 @@ export function createServer(): McpServer { monoStart, monoEnd }) => { - return toTextPayload( - await generatePlaywrightFromArchive({ - path, - passphrase, - name, - startUrl, - maxActions, - includeHarReplay, - monoStart, - monoEnd - }) + return archiveOperations.run(async () => + toTextPayload( + await generatePlaywrightFromArchive({ + path, + passphrase, + name, + startUrl, + maxActions, + includeHarReplay, + monoStart, + monoEnd + }) + ) ); } ); @@ -223,14 +238,16 @@ export function createServer(): McpServer { "Summarize action spans with trigger/duration plus request, error, and screenshot context.", summarizeActionsInput, async ({ path, passphrase, monoStart, monoEnd, limit }) => { - return toTextPayload( - await summarizeActions({ - path, - passphrase, - monoStart, - monoEnd, - limit - }) + return archiveOperations.run(async () => + toTextPayload( + await summarizeActions({ + path, + passphrase, + monoStart, + monoEnd, + limit + }) + ) ); } ); @@ -240,15 +257,17 @@ export function createServer(): McpServer { "Find likely root-cause signals around errors (nearby failed requests, warn/error console, AI root cause hints).", rootCauseCandidatesInput, async ({ path, passphrase, monoStart, monoEnd, limit, windowMs }) => { - return toTextPayload( - await findRootCauseCandidates({ - path, - passphrase, - monoStart, - monoEnd, - limit, - windowMs - }) + return archiveOperations.run(async () => + toTextPayload( + await findRootCauseCandidates({ + path, + passphrase, + monoStart, + monoEnd, + limit, + windowMs + }) + ) ); } ); @@ -269,19 +288,21 @@ export function createServer(): McpServer { topPerfDiffs, includeStorageHashes }) => { - return toTextPayload( - await compareSessions({ - leftPath, - rightPath, - leftPassphrase, - rightPassphrase, - topTypeDeltas, - topRequestDiffs, - topErrorDiffs, - topActionDiffs, - topPerfDiffs, - includeStorageHashes - }) + return archiveOperations.run(async () => + toTextPayload( + await compareSessions({ + leftPath, + rightPath, + leftPassphrase, + rightPassphrase, + topTypeDeltas, + topRequestDiffs, + topErrorDiffs, + topActionDiffs, + topPerfDiffs, + includeStorageHashes + }) + ) ); } ); diff --git a/apps/mcp-server/src/session-tools.test.ts b/apps/mcp-server/src/session-tools.test.ts index d3e10bb..814b405 100644 --- a/apps/mcp-server/src/session-tools.test.ts +++ b/apps/mcp-server/src/session-tools.test.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdtemp, mkdir, rm, utimes, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, truncate, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import JSZip from "jszip"; @@ -95,6 +95,22 @@ describe("session tools", () => { expect(result.archives.map((row) => row.path)).toEqual([newer]); }); + it("rejects oversized archives from stat metadata before reading them", async () => { + const root = await mkdtemp(join(tmpdir(), "wb-mcp-oversized-")); + tempDirs.push(root); + const archivePath = join(root, "oversized.webblackbox"); + + await writeFile(archivePath, ""); + await truncate(archivePath, 64 * 1024 * 1024 + 1); + + await expect( + summarizeActions({ + path: archivePath, + limit: 1 + }) + ).rejects.toThrow(/archive input exceeds the MCP limit/i); + }); + it("summarizes actions from a real archive fixture", async () => { const root = await mkdtemp(join(tmpdir(), "wb-mcp-positive-actions-")); tempDirs.push(root); diff --git a/apps/mcp-server/src/session-tools.ts b/apps/mcp-server/src/session-tools.ts index 87d08b3..feb675e 100644 --- a/apps/mcp-server/src/session-tools.ts +++ b/apps/mcp-server/src/session-tools.ts @@ -1,8 +1,10 @@ -import { readdir, readFile, stat } from "node:fs/promises"; +import { open, readdir, stat } from "node:fs/promises"; import { extname, resolve } from "node:path"; -import { WebBlackboxPlayer } from "@webblackbox/player-sdk"; +import { DEFAULT_ARCHIVE_RESOURCE_LIMITS, WebBlackboxPlayer } from "@webblackbox/player-sdk"; import { z } from "zod"; +import { readFileHandleBounded } from "./archive-file-reader.js"; + const ARCHIVE_EXTENSIONS = new Set([".webblackbox", ".zip"]); const DEFAULT_LIST_LIMIT = 50; const MAX_LIST_LIMIT = 200; @@ -15,6 +17,7 @@ const DEFAULT_DATA_PREVIEW_CHARS = 2_000; const MAX_DATA_PREVIEW_CHARS = 10_000; const DEFAULT_COMPARE_TOP = 15; const MAX_COMPARE_TOP = 100; +const MAX_ARCHIVE_BYTES = Math.min(DEFAULT_ARCHIVE_RESOURCE_LIMITS.maxInputBytes, 64 * 1024 * 1024); export const listArchivesInput = { dir: z.string().min(1).optional().describe("Directory to scan. Defaults to current working dir."), @@ -1218,16 +1221,34 @@ async function openArchivePlayer( passphrase?: string, range?: { monoStart?: number; monoEnd?: number } ): Promise { + let handle: Awaited> | null = null; + try { - const bytes = await readFile(path); - return await WebBlackboxPlayer.open(new Uint8Array(bytes), { + handle = await open(path, "r"); + const archiveStat = await handle.stat(); + if (!archiveStat.isFile()) { + throw new Error("Archive path is not a regular file."); + } + if (archiveStat.size > MAX_ARCHIVE_BYTES) { + throw new Error( + `Archive input exceeds the MCP limit (${archiveStat.size} > ${MAX_ARCHIVE_BYTES} bytes).` + ); + } + + const bytes = await readFileHandleBounded(handle, archiveStat.size, MAX_ARCHIVE_BYTES); + return await WebBlackboxPlayer.open(bytes, { passphrase, - range + range, + resourceLimits: { + maxInputBytes: MAX_ARCHIVE_BYTES + } }); } catch (error) { throw new Error( `Failed to open archive '${path}': ${error instanceof Error ? error.message : String(error)}` ); + } finally { + await handle?.close(); } } diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 2ea5f90..5f8d568 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -30,7 +30,10 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY`: optional browser bootstrap for `GET /share/:id?key=`. Keep this disabled in production unless the key is short-lived; when enabled, the server redirects to a clean URL and uses a short HttpOnly read-session cookie for page links. - `WEBBLACKBOX_SHARE_BIND_HOST`: bind host for the HTTP server (default `127.0.0.1`). - `WEBBLACKBOX_SHARE_ALLOWED_ORIGIN`: CORS allow origin. Defaults to `same-origin`. Use `*` only for trusted environments. -- `WEBBLACKBOX_SHARE_MAX_UPLOAD_BYTES`: max accepted upload body size in bytes (default `262144000`). +- `WEBBLACKBOX_SHARE_MAX_UPLOAD_BYTES`: max accepted upload body size in bytes (default `262144000`, hard-capped by the Player SDK's 256 MiB input ceiling). +- `WEBBLACKBOX_SHARE_MAX_CONCURRENT_INSPECTIONS`: maximum upload bodies that may be received and inspected concurrently (default `1`). Additional uploads receive `503` with `Retry-After`. +- `WEBBLACKBOX_SHARE_UPLOAD_IDLE_TIMEOUT_MS`: maximum idle time while receiving an upload body (default `15000`). +- `WEBBLACKBOX_SHARE_UPLOAD_TOTAL_TIMEOUT_MS`: maximum total upload-body receive time (default `120000`). - `WEBBLACKBOX_SHARE_ALLOW_PLAINTEXT_UPLOADS`: default `false`. Public deployments should keep this disabled so uploads must be encrypted before reaching the server. - `WEBBLACKBOX_SHARE_DEFAULT_TTL_MS`: default share lifetime in ms (default `604800000`, seven days). - `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days). @@ -69,6 +72,7 @@ Headers: Body: - Raw encrypted `.webblackbox` bytes. Public deployments do not accept plaintext uploads by default. +- Outer ZIP entry count, actual per-entry inflater output, metadata bytes, total expanded bytes, and compression ratio are checked against non-relaxable safety ceilings before the upload is retained. Plaintext uploads that opt into server analysis additionally receive Player event-codec, event-count, and index-cardinality checks. The server has no key for encrypted inner files, so clients must apply those inner checks before encryption and upload. The current client preflight summary is not cryptographically bound to the encrypted plaintext; signed or remotely attested preflight remains a separate deployment concern. - Encrypted uploads must include encryption metadata for every private archive path: `events/*`, `blobs/*`, `index/time.json`, `index/req.json`, `index/inv.json`, and `privacy/manifest.json` when present. Legacy encrypted archives that left private indexes in plaintext must be re-exported with the current exporter before public share upload. Response: @@ -98,6 +102,7 @@ Response: - `POST /api/share/:id/revoke` Expired or revoked shares return `410`. +Archive downloads are streamed from disk and the stream is cancelled when the client disconnects. ## Data storage @@ -112,5 +117,7 @@ Each share writes: - `records/.json` (redacted public summary only) - `audit/share-access.jsonl` (action, outcome, share id, timestamp, and client hash only) +Uploads are streamed to mode-`0600` temporary files under `archives/`, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. + Audit logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. Audit append failures are reported through operational logs but do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/package.json b/apps/share-server/package.json index 221c37b..f171205 100644 --- a/apps/share-server/package.json +++ b/apps/share-server/package.json @@ -16,6 +16,6 @@ }, "dependencies": { "@webblackbox/player-sdk": "workspace:*", - "jszip": "^3.10.1" + "jszip": "3.10.1" } } diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 643b7cb..b8902fe 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1,7 +1,8 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; -import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { request as createHttpRequest } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; @@ -55,6 +56,129 @@ describe("share-server", () => { expect(response.status).toBe(413); }); + it("rejects forged-size ZIP bombs from actual inflater output", async () => { + const server = await startShareServer(); + const zip = new JSZip(); + zip.file("manifest.json", "x".repeat(9 * 1024 * 1024)); + const archive = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 9 } + }); + forgeZipUncompressedSize(archive, "manifest.json", 1); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-filename": "compressed-bomb.webblackbox" + }, + body: Buffer.from(archive) + }); + const payload = (await response.json()) as { + error: string; + resource: string; + limit: number; + }; + + expect(response.status).toBe(413); + expect(payload.resource).toBe("maxMetadataEntryBytes"); + expect(payload.limit).toBe(8 * 1024 * 1024); + expect(payload.error).toMatch(/resource limit exceeded/i); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + const archiveFiles = await readdir(resolve(server.dataDir, "archives")); + expect(archiveFiles).toEqual([]); + }); + + it("validates actual output for unreferenced lazy ZIP entries before retention", async () => { + const server = await startShareServer(); + const zip = new JSZip(); + zip.file("manifest.json", "{}"); + zip.file("index/unreferenced.json", "x".repeat(9 * 1024 * 1024)); + const archive = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 9 } + }); + forgeZipUncompressedSize(archive, "index/unreferenced.json", 1); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey + }, + body: Buffer.from(archive) + }); + const payload = (await response.json()) as { resource: string }; + + expect(response.status).toBe(413); + expect(payload.resource).toBe("maxMetadataEntryBytes"); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toEqual([]); + }); + + it("bounds concurrent upload buffering and inspection", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_MAX_CONCURRENT_INSPECTIONS: "1" + }); + const pendingUpload = createHttpRequest(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "content-length": "1024", + "x-webblackbox-api-key": apiKey + } + }); + pendingUpload.on("error", () => undefined); + pendingUpload.flushHeaders(); + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey + }, + body: Buffer.from("second") + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "Archive inspection capacity is temporarily exhausted." + }); + expect(response.headers.get("retry-after")).toBe("1"); + pendingUpload.destroy(); + }); + + it("releases the upload slot and removes temporary files after an idle timeout", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_UPLOAD_IDLE_TIMEOUT_MS: "50", + WEBBLACKBOX_SHARE_UPLOAD_TOTAL_TIMEOUT_MS: "500" + }); + const pendingUpload = createHttpRequest(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "content-length": "1024", + "x-webblackbox-api-key": apiKey + } + }); + const timedOut = new Promise((resolvePromise) => { + pendingUpload.once("error", () => resolvePromise()); + }); + pendingUpload.flushHeaders(); + await timedOut; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + + const upload = await uploadEncryptedFixture(server); + expect(upload.shareId).toMatch(/^[a-f0-9]+$/); + const archiveFiles = await readdir(resolve(server.dataDir, "archives")); + expect(archiveFiles.some((name) => name.endsWith(".upload"))).toBe(false); + }); + it("does not advertise passphrase upload headers", async () => { const server = await startShareServer(); @@ -315,6 +439,49 @@ describe("share-server", () => { expect(response.status).toBe(410); }); + it("cancels a streamed archive download when the client disconnects", async () => { + const server = await startShareServer(); + const archive = await createLargeEncryptedEnvelopeArchive(8 * 1024 * 1024); + const uploadResponse = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + }, + body: Buffer.from(archive) + }); + const upload = (await uploadResponse.json()) as { shareId: string }; + expect(uploadResponse.status).toBe(201); + + await new Promise((resolvePromise, reject) => { + const download = createHttpRequest(`${server.baseUrl}/api/share/${upload.shareId}/archive`, { + method: "GET", + headers: { "x-webblackbox-api-key": apiKey } + }); + download.once("error", () => undefined); + download.once("response", (archiveResponse) => { + archiveResponse.once("error", () => undefined); + archiveResponse.once("data", () => { + archiveResponse.destroy(); + download.destroy(); + resolvePromise(); + }); + archiveResponse.once("end", () => { + reject(new Error("Expected to abort before the complete archive was streamed.")); + }); + }); + download.end(); + }); + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + const metadataResponse = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(metadataResponse.status).toBe(200); + expect(server.child.exitCode).toBeNull(); + }); + it("revokes shares and writes redacted audit events", async () => { const server = await startShareServer(); const uploadPayload = await uploadEncryptedFixture(server); @@ -434,6 +601,7 @@ describe("share-server", () => { } ); expect(archiveResponse.status).toBe(200); + expect((await archiveResponse.arrayBuffer()).byteLength).toBeGreaterThan(0); }); it("rejects query API keys by default", async () => { @@ -671,6 +839,25 @@ async function createPlaintextEnvelopeArchive(): Promise { return createEnvelopeArchive(false); } +async function createLargeEncryptedEnvelopeArchive(blobBytes: number): Promise { + const source = await createEncryptedEnvelopeArchive(); + const zip = await JSZip.loadAsync(source); + const integrityFile = zip.file("integrity/hashes.json"); + if (!integrityFile) { + throw new Error("Missing fixture integrity manifest."); + } + + const integrity = JSON.parse(await integrityFile.async("string")) as { + manifestSha256: string; + files: Record; + }; + const ciphertext = randomBytes(blobBytes); + zip.file(BLOB_FIXTURE_PATH, ciphertext); + integrity.files[BLOB_FIXTURE_PATH] = createHash("sha256").update(ciphertext).digest("hex"); + zip.file("integrity/hashes.json", JSON.stringify(integrity)); + return zip.generateAsync({ type: "uint8array", compression: "STORE" }); +} + async function createEnvelopeArchive( encrypted: boolean, options: { @@ -793,3 +980,26 @@ function addPrivateFile( function toBase64(bytes: Uint8Array): string { return Buffer.from(bytes).toString("base64"); } + +function forgeZipUncompressedSize(bytes: Uint8Array, targetName: string, size: number): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + + for (let offset = 0; offset + 46 <= bytes.byteLength; offset += 1) { + const signature = view.getUint32(offset, true); + if (signature === 0x04034b50) { + const nameBytes = view.getUint16(offset + 26, true); + const name = decoder.decode(bytes.subarray(offset + 30, offset + 30 + nameBytes)); + if (name === targetName) { + view.setUint32(offset + 22, size, true); + } + } + if (signature === 0x02014b50) { + const nameBytes = view.getUint16(offset + 28, true); + const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameBytes)); + if (name === targetName) { + view.setUint32(offset + 24, size, true); + } + } + } +} diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 66d6b1d..0e2fcf0 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,10 +1,31 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; -import { appendFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { createReadStream } from "node:fs"; +import { + appendFile, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile +} from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { join, resolve } from "node:path"; +import { pipeline } from "node:stream/promises"; import JSZip from "jszip"; -import { WebBlackboxPlayer } from "@webblackbox/player-sdk"; +import { + ArchiveDecodeTimeoutError, + ArchiveResourceLimitError, + BoundedZipReader, + DEFAULT_ARCHIVE_RESOURCE_LIMITS, + WebBlackboxPlayer, + assertArchiveInputResourceLimits, + assertLoadedArchiveResourceLimits, + resolveArchiveResourceLimits +} from "@webblackbox/player-sdk"; import { parseShareApiCredentials, type ShareApiScope } from "./auth-config.js"; @@ -90,9 +111,24 @@ type ShareSummary = { const DEFAULT_PORT = 8787; const DEFAULT_HOST = "127.0.0.1"; -const MAX_UPLOAD_BYTES = parsePositiveInteger( - process.env.WEBBLACKBOX_SHARE_MAX_UPLOAD_BYTES, - 250 * 1024 * 1024 +const MAX_UPLOAD_BYTES = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_UPLOAD_BYTES, 250 * 1024 * 1024), + DEFAULT_ARCHIVE_RESOURCE_LIMITS.maxInputBytes +); +const SHARE_ARCHIVE_RESOURCE_LIMITS = resolveArchiveResourceLimits({ + maxInputBytes: MAX_UPLOAD_BYTES +}); +const MAX_CONCURRENT_UPLOAD_INSPECTIONS = parsePositiveInteger( + process.env.WEBBLACKBOX_SHARE_MAX_CONCURRENT_INSPECTIONS, + 1 +); +const UPLOAD_IDLE_TIMEOUT_MS = parsePositiveInteger( + process.env.WEBBLACKBOX_SHARE_UPLOAD_IDLE_TIMEOUT_MS, + 15_000 +); +const UPLOAD_TOTAL_TIMEOUT_MS = parsePositiveInteger( + process.env.WEBBLACKBOX_SHARE_UPLOAD_TOTAL_TIMEOUT_MS, + 120_000 ); const DEFAULT_BASE_URL = `http://${DEFAULT_HOST}:${DEFAULT_PORT}`; const DATA_ROOT = resolve(process.env.WEBBLACKBOX_SHARE_DATA_DIR ?? ".webblackbox-share-data"); @@ -133,6 +169,7 @@ const UPLOAD_RATE_LIMIT_WINDOW_MS = parseRateLimitWindowMs( ); const SHARE_SUMMARY_HEADER = "x-webblackbox-share-summary"; const MAX_SHARE_SUMMARY_HEADER_BYTES = 16 * 1024; +const PLAINTEXT_INSPECTION_SAMPLE_BYTES = 64 * 1024; const AES_GCM_IV_BYTES = 12; const SHARE_READ_SESSION_COOKIE = "webblackbox_share_read"; const SHARE_READ_SESSION_TTL_MS = 10 * 60 * 1000; @@ -142,6 +179,7 @@ const MAX_TRACKED_RATE_BUCKETS = 4096; const uploadRateWindows = new Map(); const shareReadSessions = new Map(); let rateLimitCleanupCounter = 0; +let activeUploadInspections = 0; void startShareServer().catch((error) => { console.error("[share-server] startup failed", error); @@ -273,22 +311,78 @@ async function handleUpload( const rateLimited = consumeUploadRateLimitToken(resolveClientKey(request)); if (!rateLimited.ok) { + closeRequestAfterResponse(request, response); respondJson(response, 429, { error: `Upload rate limit exceeded. Retry in ${rateLimited.retryAfterSec}s.` }); return; } + if (!tryAcquireUploadInspectionSlot()) { + closeRequestAfterResponse(request, response); + response.setHeader("retry-after", "1"); + respondJson(response, 503, { + error: "Archive inspection capacity is temporarily exhausted." + }); + return; + } + + try { + await handleUploadWithinInspectionSlot(request, response, requestUrl); + } finally { + activeUploadInspections -= 1; + } +} + +async function handleUploadWithinInspectionSlot( + request: IncomingMessage, + response: ServerResponse, + requestUrl: URL +): Promise { + const id = randomUUID().replaceAll("-", ""); + const upload = { + id, + tempPath: join(ARCHIVES_DIR, `.${id}.upload`), + archivePath: archivePathForId(id), + committed: false + }; + + try { + await processUploadWithinInspectionSlot(request, response, requestUrl, upload); + } finally { + if (!upload.committed) { + await Promise.all([ + rm(upload.tempPath, { force: true }), + rm(upload.archivePath, { force: true }) + ]); + } + } +} + +async function processUploadWithinInspectionSlot( + request: IncomingMessage, + response: ServerResponse, + requestUrl: URL, + upload: { id: string; tempPath: string; archivePath: string; committed: boolean } +): Promise { + const { id, tempPath, archivePath } = upload; let bytes: Uint8Array; + let checksumSha256: string; try { - bytes = await readRequestBody(request, MAX_UPLOAD_BYTES); + const stored = await readRequestBodyToTempFile(request, tempPath, MAX_UPLOAD_BYTES); + bytes = stored.bytes; + checksumSha256 = stored.checksumSha256; } catch (error) { if (error instanceof PayloadTooLargeError) { + closeRequestAfterResponse(request, response); respondJson(response, 413, { error: `Upload payload exceeds ${error.maxBytes} bytes.` }); return; } + if (error instanceof UploadTimeoutError || request.aborted) { + return; + } throw error; } @@ -300,14 +394,11 @@ async function handleUpload( return; } - const id = randomUUID().replaceAll("-", ""); const filenameHeader = request.headers["x-webblackbox-filename"]; const fileName = publicArchiveFileName( id, typeof filenameHeader === "string" ? filenameHeader : undefined ); - const archivePath = archivePathForId(id); - const checksumSha256 = createHash("sha256").update(bytes).digest("hex"); let clientSummary: ShareSummary | null; try { @@ -323,8 +414,66 @@ async function handleUpload( throw error; } - const archiveEnvelope = await inspectArchiveEnvelope(bytes); - const archiveEnvelopeSummary = await buildShareSummary(bytes, archiveEnvelope); + let archiveEnvelope: ArchiveEnvelopeSummary; + let archiveEnvelopeSummary: ShareSummary; + + try { + archiveEnvelope = await inspectArchiveEnvelope(bytes); + if (archiveEnvelope.analysisError) { + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked" + }); + respondJson(response, 400, { + error: "Upload is not a valid WebBlackbox archive." + }); + return; + } + archiveEnvelopeSummary = await buildShareSummary(bytes, archiveEnvelope); + } catch (error) { + if ( + !(error instanceof ArchiveResourceLimitError) && + !(error instanceof ArchiveDecodeTimeoutError) + ) { + throw error; + } + + const resource = + error instanceof ArchiveResourceLimitError ? error.resource : "decodeTimeoutMs"; + const limit = error instanceof ArchiveResourceLimitError ? error.limit : error.timeoutMs; + + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked", + details: { + resource, + ...(error instanceof ArchiveResourceLimitError ? { actual: error.actual } : {}), + limit + } + }); + respondJson(response, error instanceof ArchiveResourceLimitError ? 413 : 422, { + error: error.message, + resource, + limit + }); + return; + } + + if (!archiveEnvelope.encrypted && !archiveEnvelopeSummary.analyzed) { + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked" + }); + respondJson(response, 400, { + error: "Upload is not a valid WebBlackbox archive." + }); + return; + } + const sizeBytes = bytes.byteLength; + bytes = new Uint8Array(0); const summary = clientSummary ? applyArchiveEnvelopeToClientSummary(clientSummary, archiveEnvelopeSummary) : archiveEnvelopeSummary; @@ -419,20 +568,21 @@ async function handleUpload( createdAt, expiresAt: createdAt + ttlMs, fileName, - sizeBytes: bytes.byteLength, + sizeBytes, checksumSha256, shareUrl, summary }; - await writeFile(archivePath, bytes); + await rename(tempPath, archivePath); await writeRecord(record); + upload.committed = true; await writeShareAuditEvent(request, { action: "upload", shareId: id, outcome: "ok", details: { - sizeBytes: bytes.byteLength, + sizeBytes, ttlMs } }); @@ -442,7 +592,7 @@ async function handleUpload( shareUrl, expiresAt: record.expiresAt, fileName, - sizeBytes: bytes.byteLength, + sizeBytes, summary }); } @@ -493,31 +643,87 @@ async function handleDownloadArchive( return; } + const archivePath = archivePathForId(id); + try { - const bytes = await readFile(archivePathForId(id)); + const archiveStat = await stat(archivePath); + if (!archiveStat.isFile()) { + throw new ArchiveFileNotFoundError(); + } + response.writeHead(200, { "content-type": "application/zip", - "content-length": String(bytes.byteLength), + "content-length": String(archiveStat.size), "content-disposition": `attachment; filename="${record.fileName}"` }); - response.end(bytes); + + await streamArchiveDownload(request, response, archivePath); await writeShareAuditEvent(request, { action: "download", shareId: id, outcome: "ok" }); - } catch { - respondJson(response, 404, { - error: "Archive file not found." - }); + } catch (error) { + const clientAborted = error instanceof ClientAbortedDownloadError; + + if (!clientAborted && !response.headersSent && !response.writableEnded) { + respondJson(response, 404, { + error: "Archive file not found." + }); + } else if (!clientAborted && !response.writableEnded) { + response.destroy(error instanceof Error ? error : undefined); + } + await writeShareAuditEvent(request, { action: "download", shareId: id, - outcome: "not-found" + outcome: clientAborted ? "error" : "not-found" }); } } +async function streamArchiveDownload( + request: IncomingMessage, + response: ServerResponse, + archivePath: string +): Promise { + const source = createReadStream(archivePath); + const abortError = new ClientAbortedDownloadError(); + const abort = () => { + if (!source.destroyed) { + source.destroy(abortError); + } + }; + const close = () => { + if (!response.writableFinished) { + abort(); + } + }; + + request.once("aborted", abort); + response.once("close", close); + + try { + await pipeline(source, response); + } catch (error) { + if (request.aborted || abortError === error || !response.writableFinished) { + throw abortError; + } + throw error; + } finally { + request.off("aborted", abort); + response.off("close", close); + } +} + +class ClientAbortedDownloadError extends Error { + public constructor() { + super("Archive download was aborted by the client."); + } +} + +class ArchiveFileNotFoundError extends Error {} + async function handleSharePage( request: IncomingMessage, response: ServerResponse, @@ -684,7 +890,9 @@ async function buildShareSummary( envelope: ArchiveEnvelopeSummary ): Promise { try { - const player = await WebBlackboxPlayer.open(bytes); + const player = await WebBlackboxPlayer.open(bytes, { + resourceLimits: SHARE_ARCHIVE_RESOURCE_LIMITS + }); const manifest = player.archive.manifest; const derived = player.buildDerived(); const actions = player.getActionTimeline(); @@ -724,6 +932,10 @@ async function buildShareSummary( } }; } catch (error) { + if (error instanceof ArchiveResourceLimitError || error instanceof ArchiveDecodeTimeoutError) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); return { schemaVersion: 1, @@ -737,8 +949,11 @@ async function buildShareSummary( async function inspectArchiveEnvelope(bytes: Uint8Array): Promise { try { + assertArchiveInputResourceLimits(bytes, SHARE_ARCHIVE_RESOURCE_LIMITS); const zip = await JSZip.loadAsync(bytes); - const manifest = asRecord(JSON.parse(await readZipText(zip, "manifest.json"))); + assertLoadedArchiveResourceLimits(zip, SHARE_ARCHIVE_RESOURCE_LIMITS); + const zipReader = new BoundedZipReader(zip, SHARE_ARCHIVE_RESOURCE_LIMITS, false); + const manifest = asRecord(JSON.parse(await readZipText(zipReader, "manifest.json"))); const encryption = asRecord(manifest.encryption); const encrypted = Object.keys(encryption).length > 0; const encryptedFiles = asRecord(encryption.files); @@ -746,9 +961,13 @@ async function inspectArchiveEnvelope(bytes: Uint8Array): Promise !isEncryptedFileMeta(encryptedFiles[path])) : []; - const plaintextEncryptedPaths = encrypted - ? await collectPlaintextEncryptedPrivatePaths(zip, privatePaths, encryptedFiles) - : []; + const plaintextEncryptedPaths = await inspectActualArchiveEntries( + zip, + zipReader, + encrypted, + new Set(privatePaths), + encryptedFiles + ); return { encrypted, @@ -758,6 +977,10 @@ async function inspectArchiveEnvelope(bytes: Uint8Array): Promise= MAX_CONCURRENT_UPLOAD_INSPECTIONS) { + return false; + } + + activeUploadInspections += 1; + return true; +} + function collectArchivePrivatePaths(zip: JSZip): string[] { return Object.entries(zip.files) .filter(([, file]) => !file.dir) @@ -798,25 +1030,27 @@ function isEncryptedFileMeta(value: unknown): boolean { return iv !== null && iv.byteLength === AES_GCM_IV_BYTES; } -async function collectPlaintextEncryptedPrivatePaths( +async function inspectActualArchiveEntries( zip: JSZip, - privatePaths: string[], + zipReader: BoundedZipReader, + encrypted: boolean, + privatePaths: Set, encryptedFiles: Record ): Promise { const plaintextPaths: string[] = []; - for (const path of privatePaths) { - if (!isEncryptedFileMeta(encryptedFiles[path])) { + for (const [path, file] of Object.entries(zip.files)) { + if (file.dir || path === "manifest.json") { continue; } - const file = zip.file(path); - if (!file) { - continue; - } - - const bytes = await file.async("uint8array"); - if (looksLikePlaintextPrivateArchiveFile(path, bytes)) { + const bytes = await zipReader.read(path); + if ( + encrypted && + privatePaths.has(path) && + isEncryptedFileMeta(encryptedFiles[path]) && + looksLikePlaintextPrivateArchiveFile(path, bytes) + ) { plaintextPaths.push(path); } } @@ -825,34 +1059,37 @@ async function collectPlaintextEncryptedPrivatePaths( } function looksLikePlaintextPrivateArchiveFile(path: string, bytes: Uint8Array): boolean { + const truncated = bytes.byteLength > PLAINTEXT_INSPECTION_SAMPLE_BYTES; + const sample = truncated ? bytes.subarray(0, PLAINTEXT_INSPECTION_SAMPLE_BYTES) : bytes; + if (path === "index/time.json" || path === "index/req.json" || path === "index/inv.json") { - return isPlainJsonBytes(bytes); + return isPlainJsonBytes(sample, truncated); } if (path === "privacy/manifest.json") { - return isPlainJsonBytes(bytes); + return isPlainJsonBytes(sample, truncated); } if (path.startsWith("events/") && path.endsWith(".ndjson")) { - return isPlainNdjsonBytes(bytes); + return isPlainNdjsonBytes(sample, truncated); } if (path.startsWith("blobs/")) { - return looksLikePlaintextBlobFile(path, bytes); + return looksLikePlaintextBlobFile(path, sample, truncated); } return false; } -function looksLikePlaintextBlobFile(path: string, bytes: Uint8Array): boolean { +function looksLikePlaintextBlobFile(path: string, bytes: Uint8Array, truncated: boolean): boolean { const normalizedPath = path.toLowerCase(); if (normalizedPath.endsWith(".json")) { - return isPlainJsonBytes(bytes); + return isPlainJsonBytes(bytes, truncated); } if (normalizedPath.endsWith(".html")) { - return isPlainHtmlBytes(bytes); + return isPlainHtmlBytes(bytes, truncated); } if (normalizedPath.endsWith(".png")) { @@ -863,11 +1100,15 @@ function looksLikePlaintextBlobFile(path: string, bytes: Uint8Array): boolean { return hasWebpSignature(bytes); } - return isPlainJsonBytes(bytes) || isPlainHtmlBytes(bytes) || isPlainTextBytes(bytes); + return ( + isPlainJsonBytes(bytes, truncated) || + isPlainHtmlBytes(bytes, truncated) || + isPlainTextBytes(bytes, truncated) + ); } -function isPlainJsonBytes(bytes: Uint8Array): boolean { - const text = decodeUtf8Strict(bytes); +function isPlainJsonBytes(bytes: Uint8Array, truncated = false): boolean { + const text = decodeUtf8Strict(bytes, truncated); if (!text) { return false; } @@ -877,6 +1118,10 @@ function isPlainJsonBytes(bytes: Uint8Array): boolean { return false; } + if (truncated) { + return isMostlyPrintableText(trimmed); + } + try { JSON.parse(trimmed); return true; @@ -885,8 +1130,8 @@ function isPlainJsonBytes(bytes: Uint8Array): boolean { } } -function isPlainNdjsonBytes(bytes: Uint8Array): boolean { - const text = decodeUtf8Strict(bytes); +function isPlainNdjsonBytes(bytes: Uint8Array, truncated = false): boolean { + const text = decodeUtf8Strict(bytes, truncated); if (!text) { return false; } @@ -905,12 +1150,12 @@ function isPlainNdjsonBytes(bytes: Uint8Array): boolean { } return true; } catch { - return false; + return truncated && text.trimStart().startsWith("{") && isMostlyPrintableText(text); } } -function isPlainHtmlBytes(bytes: Uint8Array): boolean { - const text = decodeUtf8Strict(bytes); +function isPlainHtmlBytes(bytes: Uint8Array, truncated = false): boolean { + const text = decodeUtf8Strict(bytes, truncated); if (!text) { return false; } @@ -924,8 +1169,8 @@ function isPlainHtmlBytes(bytes: Uint8Array): boolean { ); } -function isPlainTextBytes(bytes: Uint8Array): boolean { - const text = decodeUtf8Strict(bytes); +function isPlainTextBytes(bytes: Uint8Array, truncated = false): boolean { + const text = decodeUtf8Strict(bytes, truncated); if (!text) { return false; } @@ -935,17 +1180,21 @@ function isPlainTextBytes(bytes: Uint8Array): boolean { return false; } + return isMostlyPrintableText(trimmed); +} + +function isMostlyPrintableText(value: string): boolean { let printable = 0; - for (let index = 0; index < trimmed.length; index += 1) { - const code = trimmed.charCodeAt(index); + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code !== 0x7f)) { printable += 1; } } - return printable / trimmed.length >= 0.9; + return value.length > 0 && printable / value.length >= 0.9; } function hasPngSignature(bytes: Uint8Array): boolean { @@ -976,12 +1225,20 @@ function hasWebpSignature(bytes: Uint8Array): boolean { ); } -function decodeUtf8Strict(bytes: Uint8Array): string | null { - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return null; +function decodeUtf8Strict(bytes: Uint8Array, truncated = false): string | null { + const maxTrim = truncated ? Math.min(3, bytes.byteLength) : 0; + + for (let trim = 0; trim <= maxTrim; trim += 1) { + try { + return new TextDecoder("utf-8", { fatal: true }).decode( + trim === 0 ? bytes : bytes.subarray(0, bytes.byteLength - trim) + ); + } catch { + // A bounded UTF-8 prefix can end in the middle of one multi-byte code point. + } } + + return null; } function decodeBase64Strict(value: string): Buffer | null { @@ -996,14 +1253,8 @@ function decodeBase64Strict(value: string): Buffer | null { return normalizedInput === normalizedOutput ? decoded : null; } -async function readZipText(zip: JSZip, path: string): Promise { - const file = zip.file(path); - - if (!file) { - throw new Error(`Archive is missing required file: ${path}`); - } - - return file.async("string"); +async function readZipText(zipReader: BoundedZipReader, path: string): Promise { + return new TextDecoder().decode(await zipReader.read(path)); } function readClientShareSummary(request: IncomingMessage): ShareSummary | null { @@ -1395,25 +1646,136 @@ function normalizeAllowedOrigin(rawValue: string | undefined): string { return resolved.toLowerCase() === "same-origin" ? "same-origin" : resolved; } -async function readRequestBody(request: IncomingMessage, maxBytes: number): Promise { - const chunks: Buffer[] = []; +async function readRequestBodyToTempFile( + request: IncomingMessage, + tempPath: string, + maxBytes: number +): Promise<{ bytes: Uint8Array; checksumSha256: string }> { + const declaredBytes = readDeclaredContentLength(request); + if (declaredBytes !== null && declaredBytes > maxBytes) { + throw new PayloadTooLargeError(maxBytes); + } + + const handle = await open(tempPath, "wx+", 0o600); + const checksum = createHash("sha256"); let totalBytes = 0; + let storedBytes: Buffer | null = null; + let idleTimer: ReturnType | undefined; + let timeoutError: UploadTimeoutError | null = null; + const expire = (kind: "idle" | "total") => { + timeoutError ??= new UploadTimeoutError(kind); + request.destroy(timeoutError); + }; + const armIdleTimer = () => { + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => expire("idle"), UPLOAD_IDLE_TIMEOUT_MS); + }; + const totalTimer = setTimeout(() => expire("total"), UPLOAD_TOTAL_TIMEOUT_MS); + const clearUploadTimers = () => { + clearTimeout(totalTimer); + if (idleTimer !== undefined) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + }; + armIdleTimer(); - for await (const chunk of request) { - if (!(chunk instanceof Buffer)) { - continue; + try { + for await (const chunk of request) { + armIdleTimer(); + const bytes = chunk instanceof Buffer ? chunk : Buffer.from(chunk as Uint8Array); + + const nextTotalBytes = totalBytes + bytes.byteLength; + if (nextTotalBytes > maxBytes) { + throw new PayloadTooLargeError(maxBytes); + } + + await writeFileHandleFully(handle, bytes); + checksum.update(bytes); + totalBytes = nextTotalBytes; + } + + clearUploadTimers(); + if (timeoutError || request.aborted) { + throw timeoutError ?? new Error("Upload request was aborted."); + } + + const storedStat = await handle.stat(); + if (storedStat.size !== totalBytes) { + throw new Error("Upload temporary file size changed unexpectedly."); + } + storedBytes = await readFileHandleExactly(handle, totalBytes); + } catch (error) { + throw timeoutError ?? error; + } finally { + clearUploadTimers(); + await handle.close(); + } + + if (!storedBytes) { + throw new Error("Upload temporary file was not read."); + } + const checksumSha256 = checksum.digest("hex"); + const storedChecksumSha256 = createHash("sha256").update(storedBytes).digest("hex"); + if (storedChecksumSha256 !== checksumSha256) { + throw new Error("Upload temporary file contents changed unexpectedly."); + } + + return { + bytes: storedBytes, + checksumSha256 + }; +} + +async function readFileHandleExactly( + handle: Awaited>, + expectedBytes: number +): Promise { + const output = Buffer.allocUnsafe(expectedBytes); + let offset = 0; + + while (offset < expectedBytes) { + const { bytesRead } = await handle.read(output, offset, expectedBytes - offset, offset); + if (bytesRead === 0) { + throw new Error("Upload temporary file ended before its recorded size."); } + offset += bytesRead; + } + + const probe = Buffer.allocUnsafe(1); + const { bytesRead: trailingBytes } = await handle.read(probe, 0, 1, expectedBytes); + if (trailingBytes > 0) { + throw new PayloadTooLargeError(MAX_UPLOAD_BYTES); + } + + return output; +} + +async function writeFileHandleFully( + handle: Awaited>, + bytes: Buffer +): Promise { + let offset = 0; - totalBytes += chunk.byteLength; - if (totalBytes > maxBytes) { - throw new PayloadTooLargeError(maxBytes); + while (offset < bytes.byteLength) { + const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset, null); + if (bytesWritten <= 0) { + throw new Error("Upload temporary file write made no progress."); } + offset += bytesWritten; + } +} - chunks.push(chunk); +function readDeclaredContentLength(request: IncomingMessage): number | null { + const raw = request.headers["content-length"]; + if (typeof raw !== "string" || !/^\d+$/.test(raw)) { + return null; } - const merged = Buffer.concat(chunks); - return new Uint8Array(merged.buffer, merged.byteOffset, merged.byteLength); + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; } class PayloadTooLargeError extends Error { @@ -1422,6 +1784,17 @@ class PayloadTooLargeError extends Error { } } +class UploadTimeoutError extends Error { + public constructor(kind: "idle" | "total") { + super(`Upload ${kind} timeout exceeded.`); + } +} + +function closeRequestAfterResponse(request: IncomingMessage, response: ServerResponse): void { + response.setHeader("connection", "close"); + response.once("finish", () => request.destroy()); +} + class ShareSummaryHeaderError extends Error {} function asRecord(value: unknown): Record { diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f510b98..e1a31a7 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -29,7 +29,7 @@ Plaintext synthetic or local-debug export exemptions require a well-formed `capt ## Player Safety -The player treats archives as untrusted input. It does not load captured external resources by default, limits replay resources to inert local object/data URLs, revokes screenshot object URLs after a short TTL, and serves player/share views with no-referrer and restrictive CSP controls. Remote share servers must use HTTPS; plaintext HTTP is accepted only for loopback development endpoints so share API credentials are not sent over an unprotected remote connection. +The player treats archives as untrusted input. It does not load captured external resources by default, limits replay resources to inert local object/data URLs, revokes screenshot object URLs after a short TTL, and serves player/share views with no-referrer and restrictive CSP controls. Archive input bytes, ZIP entries, expanded bytes, compression ratios, decoded event bytes, event counts, and codec time are subject to non-relaxable safety ceilings; callers may only tighten them. Remote share servers must use HTTPS; plaintext HTTP is accepted only for loopback development endpoints so share API credentials are not sent over an unprotected remote connection. ## Share Server diff --git a/packages/player-sdk/README.md b/packages/player-sdk/README.md index 35ec106..e253770 100644 --- a/packages/player-sdk/README.md +++ b/packages/player-sdk/README.md @@ -56,11 +56,27 @@ const scopedPlayer = await WebBlackboxPlayer.open(archiveBytes, { range: { monoStart: 12000, monoEnd: 45000 } }); +// Resource limits have safe ceilings and may only be tightened per open. +const constrainedPlayer = await WebBlackboxPlayer.open(archiveBytes, { + resourceLimits: { + maxInputBytes: 32 * 1024 * 1024, + maxEntryCount: 2_000, + maxEventCount: 250_000 + } +}); + console.log(player.status); // "loaded" console.log(player.archive.manifest); // ExportManifest console.log(player.events.length); // Total event count ``` +The built-in ceilings are 256 MiB input, 10,000 physical ZIP entries, 128 MiB per expanded +entry, 256 MiB total ZIP expansion, 8 MiB per/24 MiB total JSON metadata, 10,000x compression +ratio, 1,000,000 events, 500,000 index records, 2,000,000 index event references, 32 MiB per +decoded event chunk, 64 MiB total decoded event bytes, and 5 seconds per ZIP/codec stream. +Actual inflater output is counted; declared ZIP sizes are only an early-rejection hint. Values +supplied through `resourceLimits` may only be lower than these ceilings. + ### Querying Events ```typescript @@ -348,6 +364,23 @@ type PlayerOpenInput = ArrayBuffer | Uint8Array | Blob; type PlayerOpenOptions = { passphrase?: string; range?: PlayerRange; + resourceLimits?: Partial; +}; + +type ArchiveResourceLimits = { + maxInputBytes: number; + maxEntryCount: number; + maxEntryUncompressedBytes: number; + maxTotalUncompressedBytes: number; + maxMetadataEntryBytes: number; + maxTotalMetadataBytes: number; + maxCompressionRatio: number; + maxEventCount: number; + maxIndexRecords: number; + maxIndexEventReferences: number; + maxChunkDecodedBytes: number; + maxTotalDecodedBytes: number; + decodeTimeoutMs: number; }; type PlayerQuery = { diff --git a/packages/player-sdk/package.json b/packages/player-sdk/package.json index ecb01fe..4c117d5 100644 --- a/packages/player-sdk/package.json +++ b/packages/player-sdk/package.json @@ -50,6 +50,6 @@ }, "dependencies": { "@webblackbox/protocol": "workspace:*", - "jszip": "^3.10.1" + "jszip": "3.10.1" } } diff --git a/packages/player-sdk/src/archive-resource-limits.test.ts b/packages/player-sdk/src/archive-resource-limits.test.ts new file mode 100644 index 0000000..5e702d7 --- /dev/null +++ b/packages/player-sdk/src/archive-resource-limits.test.ts @@ -0,0 +1,138 @@ +import JSZip from "jszip"; +import { describe, expect, it } from "vitest"; + +import { + ArchiveDecodeBudget, + ArchiveResourceLimitError, + assertArchiveInputResourceLimits, + assertLoadedArchiveResourceLimits, + resolveArchiveResourceLimits +} from "./archive-resource-limits.js"; + +describe("archive resource limits", () => { + it("accepts exact input and entry-size boundaries", async () => { + const zip = new JSZip(); + zip.file("payload.bin", new Uint8Array(1024)); + const bytes = await zip.generateAsync({ type: "uint8array", compression: "STORE" }); + const loaded = await JSZip.loadAsync(bytes); + const limits = resolveArchiveResourceLimits({ + maxInputBytes: bytes.byteLength, + maxEntryCount: 1, + maxEntryUncompressedBytes: 1024, + maxTotalUncompressedBytes: 1024, + maxCompressionRatio: 1 + }); + + expect(() => assertArchiveInputResourceLimits(bytes, limits)).not.toThrow(); + expect(() => assertLoadedArchiveResourceLimits(loaded, limits)).not.toThrow(); + expect(() => + assertLoadedArchiveResourceLimits( + loaded, + resolveArchiveResourceLimits({ maxEntryUncompressedBytes: 1023 }) + ) + ).toThrowError( + expect.objectContaining>({ + resource: "maxEntryUncompressedBytes", + actual: 1024 + }) + ); + }); + + it("rejects entry floods from the end-of-central-directory record before loading", async () => { + const zip = new JSZip(); + for (let index = 0; index < 6; index += 1) { + zip.file(`entry-${index}.txt`, ""); + } + const bytes = await zip.generateAsync({ type: "uint8array" }); + const bytesWithTrailingData = new Uint8Array(bytes.byteLength + 1); + bytesWithTrailingData.set(bytes); + const limits = resolveArchiveResourceLimits({ maxEntryCount: 5 }); + + expect(() => assertArchiveInputResourceLimits(bytesWithTrailingData, limits)).toThrowError( + expect.objectContaining>({ + resource: "maxEntryCount", + actual: 6 + }) + ); + }); + + it("rejects high compression ratios without inflating an entry", async () => { + const zip = new JSZip(); + zip.file("repetitive.txt", "a".repeat(512 * 1024)); + const bytes = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 9 } + }); + const loaded = await JSZip.loadAsync(bytes); + const limits = resolveArchiveResourceLimits({ maxCompressionRatio: 10 }); + + expect(() => assertLoadedArchiveResourceLimits(loaded, limits)).toThrowError( + expect.objectContaining>({ + resource: "maxCompressionRatio" + }) + ); + }); + + it("enforces cumulative expanded bytes and decoded event counts", async () => { + const zip = new JSZip(); + zip.file("left.bin", new Uint8Array(800)); + zip.file("right.bin", new Uint8Array(800)); + const bytes = await zip.generateAsync({ type: "uint8array", compression: "STORE" }); + const loaded = await JSZip.loadAsync(bytes); + const limits = resolveArchiveResourceLimits({ + maxTotalUncompressedBytes: 1500, + maxEventCount: 2 + }); + + expect(() => assertLoadedArchiveResourceLimits(loaded, limits)).toThrowError( + expect.objectContaining>({ + resource: "maxTotalUncompressedBytes" + }) + ); + + const budget = new ArchiveDecodeBudget(limits); + budget.commitEvents("events/one.ndjson", 2); + expect(() => budget.commitEvents("events/two.ndjson", 1)).toThrowError( + expect.objectContaining>({ + resource: "maxEventCount", + actual: 3 + }) + ); + + const decodedBudget = new ArchiveDecodeBudget( + resolveArchiveResourceLimits({ + maxChunkDecodedBytes: 10, + maxTotalDecodedBytes: 15, + maxCompressionRatio: 10 + }) + ); + decodedBudget.commitDecoded("events/one.ndjson", 10, 10); + decodedBudget.commitDecoded("events/two.ndjson", 5, 5); + expect(() => decodedBudget.commitDecoded("events/three.ndjson", 1, 1)).toThrowError( + expect.objectContaining>({ + resource: "maxTotalDecodedBytes", + actual: 16 + }) + ); + + const indexBudget = new ArchiveDecodeBudget( + resolveArchiveResourceLimits({ maxIndexRecords: 2, maxIndexEventReferences: 3 }) + ); + indexBudget.commitIndex("index/req.json", 1, 2); + expect(() => indexBudget.commitIndex("index/inv.json", 2, 2)).toThrowError( + expect.objectContaining>({ + resource: "maxIndexRecords", + actual: 3 + }) + ); + }); + + it("allows callers to tighten but not relax safety ceilings", () => { + expect(resolveArchiveResourceLimits({ maxInputBytes: 1024 }).maxInputBytes).toBe(1024); + expect(() => resolveArchiveResourceLimits({ maxInputBytes: Number.MAX_SAFE_INTEGER })).toThrow( + /may only tighten/i + ); + expect(() => resolveArchiveResourceLimits({ maxCompressionRatio: 0.5 })).toThrow(/at least 1/i); + }); +}); diff --git a/packages/player-sdk/src/archive-resource-limits.ts b/packages/player-sdk/src/archive-resource-limits.ts new file mode 100644 index 0000000..ad2898d --- /dev/null +++ b/packages/player-sdk/src/archive-resource-limits.ts @@ -0,0 +1,429 @@ +import type JSZip from "jszip"; + +const MEBIBYTE = 1024 * 1024; +const ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50; +const ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06064b50; +const ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE = 0x07064b50; +const ZIP_CENTRAL_DIRECTORY_ENTRY_SIGNATURE = 0x02014b50; +const ZIP_CENTRAL_DIRECTORY_DIGITAL_SIGNATURE = 0x05054b50; +const ZIP_END_OF_CENTRAL_DIRECTORY_MIN_BYTES = 22; +const ZIP_MAX_COMMENT_BYTES = 0xffff; + +/** Resource limits applied before and while an archive is opened. Overrides may only tighten them. */ +export type ArchiveResourceLimits = { + maxInputBytes: number; + maxEntryCount: number; + maxEntryUncompressedBytes: number; + maxTotalUncompressedBytes: number; + maxMetadataEntryBytes: number; + maxTotalMetadataBytes: number; + maxCompressionRatio: number; + maxEventCount: number; + maxIndexRecords: number; + maxIndexEventReferences: number; + maxChunkDecodedBytes: number; + maxTotalDecodedBytes: number; + decodeTimeoutMs: number; +}; + +/** Safe upper bounds used by archive consumers unless a caller supplies tighter values. */ +export const DEFAULT_ARCHIVE_RESOURCE_LIMITS: Readonly = Object.freeze({ + maxInputBytes: 256 * MEBIBYTE, + maxEntryCount: 10_000, + maxEntryUncompressedBytes: 128 * MEBIBYTE, + maxTotalUncompressedBytes: 256 * MEBIBYTE, + maxMetadataEntryBytes: 8 * MEBIBYTE, + maxTotalMetadataBytes: 24 * MEBIBYTE, + maxCompressionRatio: 10_000, + maxEventCount: 1_000_000, + maxIndexRecords: 500_000, + maxIndexEventReferences: 2_000_000, + maxChunkDecodedBytes: 32 * MEBIBYTE, + maxTotalDecodedBytes: 64 * MEBIBYTE, + decodeTimeoutMs: 5_000 +}); + +export type ArchiveResourceName = keyof ArchiveResourceLimits; + +/** Raised when an archive would exceed a configured resource limit. */ +export class ArchiveResourceLimitError extends Error { + public override readonly name = "ArchiveResourceLimitError"; + + public constructor( + public readonly resource: ArchiveResourceName, + public readonly limit: number, + public readonly actual: number, + detail?: string + ) { + super( + `Archive resource limit exceeded for ${resource}: ${formatNumber(actual)} > ${formatNumber( + limit + )}${detail ? ` (${detail})` : ""}.` + ); + } +} + +/** Resolves caller overrides and rejects attempts to weaken the built-in safety ceiling. */ +export function resolveArchiveResourceLimits( + overrides: Partial = {} +): ArchiveResourceLimits { + const resolved = { ...DEFAULT_ARCHIVE_RESOURCE_LIMITS }; + + for (const resource of Object.keys(DEFAULT_ARCHIVE_RESOURCE_LIMITS) as ArchiveResourceName[]) { + const value = overrides[resource]; + + if (value === undefined) { + continue; + } + + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`Archive resource limit '${resource}' must be a positive finite number.`); + } + + if (resource !== "maxCompressionRatio" && !Number.isSafeInteger(value)) { + throw new TypeError(`Archive resource limit '${resource}' must be a safe integer.`); + } + + if (resource === "maxCompressionRatio" && value < 1) { + throw new TypeError("Archive resource limit 'maxCompressionRatio' must be at least 1."); + } + + const safeMaximum = DEFAULT_ARCHIVE_RESOURCE_LIMITS[resource]; + if (value > safeMaximum) { + throw new TypeError( + `Archive resource limit '${resource}' may only tighten the safe default (${safeMaximum}).` + ); + } + + resolved[resource] = value; + } + + return resolved; +} + +/** Performs checks available from the ZIP bytes before JSZip constructs its entry table. */ +export function assertArchiveInputResourceLimits( + bytes: Uint8Array, + limits: Readonly +): void { + assertWithinLimit("maxInputBytes", bytes.byteLength, limits.maxInputBytes); + + const declaredEntryCount = readDeclaredZipEntryCount(bytes); + if (declaredEntryCount === null) { + throw new Error( + "Invalid WebBlackbox archive: ZIP end-of-central-directory record is missing or outside the supported trailing-data window." + ); + } + + assertWithinLimit("maxEntryCount", declaredEntryCount, limits.maxEntryCount); +} + +/** Uses declared central-directory sizes for early rejection; actual output is checked on read. */ +export function assertLoadedArchiveResourceLimits( + zip: JSZip, + limits: Readonly +): void { + const entries = Object.values(zip.files); + assertWithinLimit("maxEntryCount", entries.length, limits.maxEntryCount); + + let totalUncompressedBytes = 0; + + for (const entry of entries) { + if (entry.dir) { + continue; + } + + const sizes = readZipEntrySizes(entry); + if (!sizes) { + throw new ArchiveResourceLimitError( + "maxEntryUncompressedBytes", + limits.maxEntryUncompressedBytes, + Number.POSITIVE_INFINITY, + `missing declared size metadata for '${entry.name}'` + ); + } + + assertWithinLimit( + "maxEntryUncompressedBytes", + sizes.uncompressedSize, + limits.maxEntryUncompressedBytes, + entry.name + ); + + totalUncompressedBytes = addWithoutOverflow(totalUncompressedBytes, sizes.uncompressedSize); + assertWithinLimit( + "maxTotalUncompressedBytes", + totalUncompressedBytes, + limits.maxTotalUncompressedBytes + ); + + const ratio = compressionRatio(sizes.uncompressedSize, sizes.compressedSize); + assertWithinLimit("maxCompressionRatio", ratio, limits.maxCompressionRatio, entry.name); + } +} + +/** Tracks nested event-codec output and parsed event counts across one open operation. */ +export class ArchiveDecodeBudget { + private totalDecodedBytes = 0; + + private totalEvents = 0; + + private totalIndexRecords = 0; + + private totalIndexEventReferences = 0; + + public constructor(public readonly limits: Readonly) {} + + public assertDecodedSize(path: string, compressedBytes: number, decodedBytes: number): void { + assertWithinLimit("maxChunkDecodedBytes", decodedBytes, this.limits.maxChunkDecodedBytes, path); + assertWithinLimit( + "maxCompressionRatio", + compressionRatio(decodedBytes, compressedBytes), + this.limits.maxCompressionRatio, + path + ); + assertWithinLimit( + "maxTotalDecodedBytes", + addWithoutOverflow(this.totalDecodedBytes, decodedBytes), + this.limits.maxTotalDecodedBytes + ); + } + + public commitDecoded(path: string, compressedBytes: number, decodedBytes: number): void { + this.assertDecodedSize(path, compressedBytes, decodedBytes); + this.totalDecodedBytes += decodedBytes; + } + + public commitEvents(path: string, count: number): void { + const nextTotal = addWithoutOverflow(this.totalEvents, count); + assertWithinLimit("maxEventCount", nextTotal, this.limits.maxEventCount, path); + this.totalEvents = nextTotal; + } + + public commitIndex(path: string, records: number, eventReferences: number): void { + const nextRecords = addWithoutOverflow(this.totalIndexRecords, records); + assertWithinLimit("maxIndexRecords", nextRecords, this.limits.maxIndexRecords, path); + const nextReferences = addWithoutOverflow(this.totalIndexEventReferences, eventReferences); + assertWithinLimit( + "maxIndexEventReferences", + nextReferences, + this.limits.maxIndexEventReferences, + path + ); + this.totalIndexRecords = nextRecords; + this.totalIndexEventReferences = nextReferences; + } +} + +function assertWithinLimit( + resource: ArchiveResourceName, + actual: number, + limit: number, + detail?: string +): void { + if (!Number.isFinite(actual) || actual > limit) { + throw new ArchiveResourceLimitError(resource, limit, actual, detail); + } +} + +function readZipEntrySizes( + entry: JSZip.JSZipObject +): { compressedSize: number; uncompressedSize: number } | null { + // JSZip 3.x intentionally omits this loadAsync metadata from its public typings. Fail closed if + // a future implementation stops exposing the central-directory sizes before decompression. + const data = ( + entry as JSZip.JSZipObject & { + _data?: { compressedSize?: unknown; uncompressedSize?: unknown }; + } + )._data; + const compressedSize = data?.compressedSize; + const uncompressedSize = data?.uncompressedSize; + + if ( + typeof compressedSize !== "number" || + !Number.isSafeInteger(compressedSize) || + compressedSize < 0 || + typeof uncompressedSize !== "number" || + !Number.isSafeInteger(uncompressedSize) || + uncompressedSize < 0 + ) { + return null; + } + + return { compressedSize, uncompressedSize }; +} + +function compressionRatio(uncompressedBytes: number, compressedBytes: number): number { + if (uncompressedBytes === 0) { + return 0; + } + + if (compressedBytes === 0) { + return Number.POSITIVE_INFINITY; + } + + return uncompressedBytes / compressedBytes; +} + +function addWithoutOverflow(left: number, right: number): number { + const sum = left + right; + return Number.isSafeInteger(sum) ? sum : Number.POSITIVE_INFINITY; +} + +function readDeclaredZipEntryCount(bytes: Uint8Array): number | null { + if (bytes.byteLength < ZIP_END_OF_CENTRAL_DIRECTORY_MIN_BYTES) { + return null; + } + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const searchStart = Math.max( + 0, + bytes.byteLength - ZIP_END_OF_CENTRAL_DIRECTORY_MIN_BYTES - ZIP_MAX_COMMENT_BYTES + ); + + for ( + let offset = bytes.byteLength - ZIP_END_OF_CENTRAL_DIRECTORY_MIN_BYTES; + offset >= searchStart; + offset -= 1 + ) { + if (view.getUint32(offset, true) !== ZIP_END_OF_CENTRAL_DIRECTORY_SIGNATURE) { + continue; + } + + const commentBytes = view.getUint16(offset + 20, true); + if (offset + ZIP_END_OF_CENTRAL_DIRECTORY_MIN_BYTES + commentBytes > bytes.byteLength) { + continue; + } + + const entryCount = view.getUint16(offset + 10, true); + if (entryCount !== 0xffff) { + const centralDirectoryBytes = view.getUint32(offset + 12, true); + const centralDirectoryOffset = view.getUint32(offset + 16, true); + if (centralDirectoryOffset + centralDirectoryBytes > offset) { + continue; + } + + const physicalEntryCount = countPhysicalCentralDirectoryEntries( + view, + offset - centralDirectoryBytes, + offset + ); + return physicalEntryCount === null + ? Number.POSITIVE_INFINITY + : Math.max(entryCount, physicalEntryCount); + } + + const zip64 = readZip64CentralDirectory(view, offset); + if (!zip64) { + return Number.POSITIVE_INFINITY; + } + + const physicalEntryCount = countPhysicalCentralDirectoryEntries( + view, + zip64.recordOffset - zip64.centralDirectoryBytes, + zip64.recordOffset + ); + return physicalEntryCount === null + ? Number.POSITIVE_INFINITY + : Math.max(zip64.entryCount, physicalEntryCount); + } + + return null; +} + +function countPhysicalCentralDirectoryEntries( + view: DataView, + centralDirectoryOffset: number, + centralDirectoryEnd: number +): number | null { + if (centralDirectoryOffset < 0 || centralDirectoryEnd > view.byteLength) { + return null; + } + + let count = 0; + let offset = centralDirectoryOffset; + + while (offset < centralDirectoryEnd) { + if (offset + 4 > centralDirectoryEnd) { + return null; + } + + const signature = view.getUint32(offset, true); + if (signature === ZIP_CENTRAL_DIRECTORY_DIGITAL_SIGNATURE) { + if (offset + 6 > centralDirectoryEnd) { + return null; + } + offset += 6 + view.getUint16(offset + 4, true); + continue; + } + if (signature !== ZIP_CENTRAL_DIRECTORY_ENTRY_SIGNATURE || offset + 46 > centralDirectoryEnd) { + return null; + } + + const fileNameBytes = view.getUint16(offset + 28, true); + const extraBytes = view.getUint16(offset + 30, true); + const commentBytes = view.getUint16(offset + 32, true); + offset += 46 + fileNameBytes + extraBytes + commentBytes; + count += 1; + } + + return offset === centralDirectoryEnd ? count : null; +} + +function readZip64CentralDirectory( + view: DataView, + eocdOffset: number +): { + entryCount: number; + centralDirectoryBytes: number; + recordOffset: number; +} | null { + const locatorOffset = eocdOffset - 20; + if ( + locatorOffset < 0 || + view.getUint32(locatorOffset, true) !== ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE + ) { + return null; + } + + const zip64OffsetBigInt = view.getBigUint64(locatorOffset + 8, true); + if (zip64OffsetBigInt > BigInt(Number.MAX_SAFE_INTEGER)) { + return { + entryCount: Number.POSITIVE_INFINITY, + centralDirectoryBytes: Number.POSITIVE_INFINITY, + recordOffset: 0 + }; + } + + const zip64Offset = Number(zip64OffsetBigInt); + if ( + zip64Offset < 0 || + zip64Offset + 56 > view.byteLength || + view.getUint32(zip64Offset, true) !== ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE + ) { + return null; + } + + const entryCount = view.getBigUint64(zip64Offset + 32, true); + const centralDirectoryBytes = view.getBigUint64(zip64Offset + 40, true); + if ( + entryCount > BigInt(Number.MAX_SAFE_INTEGER) || + centralDirectoryBytes > BigInt(Number.MAX_SAFE_INTEGER) + ) { + return { + entryCount: Number.POSITIVE_INFINITY, + centralDirectoryBytes: Number.POSITIVE_INFINITY, + recordOffset: zip64Offset + }; + } + + return { + entryCount: Number(entryCount), + centralDirectoryBytes: Number(centralDirectoryBytes), + recordOffset: zip64Offset + }; +} + +function formatNumber(value: number): string { + return Number.isFinite(value) ? String(Number(value.toFixed(2))) : "unbounded"; +} diff --git a/packages/player-sdk/src/bounded-stream-reader.test.ts b/packages/player-sdk/src/bounded-stream-reader.test.ts new file mode 100644 index 0000000..5e4fbe5 --- /dev/null +++ b/packages/player-sdk/src/bounded-stream-reader.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readBoundedReadableStream } from "./bounded-stream-reader.js"; + +describe("bounded stream reader", () => { + it("cancels the reader when accumulated bytes exceed a limit", async () => { + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(4)); + controller.enqueue(new Uint8Array(4)); + }, + cancel + }); + + await expect( + readBoundedReadableStream(stream, { + timeoutMs: 100, + detail: "resource-test", + maxBytes: 8, + validateTotalBytes(totalBytes) { + if (totalBytes > 6) { + throw new Error("decoded byte limit"); + } + } + }) + ).rejects.toThrow("decoded byte limit"); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("cancels a pending reader when decoding times out", async () => { + const cancel = vi.fn(); + const stream = new ReadableStream({ + pull() { + return new Promise(() => undefined); + }, + cancel + }); + + await expect( + readBoundedReadableStream(stream, { + timeoutMs: 5, + detail: "timeout-test", + maxBytes: 8, + validateTotalBytes() {} + }) + ).rejects.toThrow(/timed out after 5ms/i); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("rejects on time even when underlying cancellation never settles", async () => { + const cancel = vi.fn(() => new Promise(() => undefined)); + const stream = new ReadableStream({ + pull() { + return new Promise(() => undefined); + }, + cancel + }); + const startedAt = Date.now(); + + await expect( + readBoundedReadableStream(stream, { + timeoutMs: 5, + detail: "hanging-cancel-test", + maxBytes: 8, + validateTotalBytes() {} + }) + ).rejects.toThrow(/timed out after 5ms/i); + expect(Date.now() - startedAt).toBeLessThan(500); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/player-sdk/src/bounded-stream-reader.ts b/packages/player-sdk/src/bounded-stream-reader.ts new file mode 100644 index 0000000..71c91f2 --- /dev/null +++ b/packages/player-sdk/src/bounded-stream-reader.ts @@ -0,0 +1,114 @@ +/** Raised when a codec stream does not complete within the configured open budget. */ +export class ArchiveDecodeTimeoutError extends Error { + public override readonly name = "ArchiveDecodeTimeoutError"; + + public constructor( + public readonly timeoutMs: number, + detail: string + ) { + super(`Archive decode timed out after ${timeoutMs}ms (${detail}).`); + } +} + +/** Reads a web stream while validating growth and always cancels it on timeout or failure. */ +export async function readBoundedReadableStream( + stream: ReadableStream, + options: { + timeoutMs: number; + detail: string; + maxBytes: number; + validateTotalBytes: (totalBytes: number) => void; + } +): Promise { + const reader = stream.getReader(); + const accumulator = new StreamByteAccumulator(options.maxBytes); + let totalBytes = 0; + let timer: ReturnType | undefined; + let cancellation: Promise | undefined; + const timeoutError = new ArchiveDecodeTimeoutError(options.timeoutMs, options.detail); + const cancel = (reason: unknown) => { + cancellation ??= cancelReader(reader, reason); + return cancellation; + }; + + const readPromise = (async () => { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + if (!value) { + continue; + } + + const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); + totalBytes += chunk.byteLength; + options.validateTotalBytes(totalBytes); + accumulator.append(chunk); + } + + return accumulator.bytes(); + })(); + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + void cancel(timeoutError); + reject(timeoutError); + }, options.timeoutMs); + }); + + try { + return await Promise.race([readPromise, timeoutPromise]); + } catch (error) { + void cancel(error); + throw error; + } finally { + if (timer !== undefined) { + clearTimeout(timer); + } + } +} + +class StreamByteAccumulator { + private output = new Uint8Array(0); + + private length = 0; + + public constructor(private readonly maxBytes: number) {} + + public append(chunk: Uint8Array): void { + const nextLength = this.length + chunk.byteLength; + if (nextLength > this.maxBytes) { + throw new Error(`Bounded stream exceeded its allocation ceiling (${this.maxBytes} bytes).`); + } + + if (nextLength > this.output.byteLength) { + const grownCapacity = Math.max( + nextLength, + Math.ceil(Math.max(1, this.output.byteLength) * 1.5) + ); + const grown = new Uint8Array(Math.min(this.maxBytes, grownCapacity)); + grown.set(this.output.subarray(0, this.length)); + this.output = grown; + } + + this.output.set(chunk, this.length); + this.length = nextLength; + } + + public bytes(): Uint8Array { + return this.output.subarray(0, this.length); + } +} + +async function cancelReader( + reader: ReadableStreamDefaultReader, + reason: unknown +): Promise { + try { + await reader.cancel(reason); + } catch { + // The original timeout/resource error is more useful than a secondary cancellation failure. + } +} diff --git a/packages/player-sdk/src/bounded-zip-reader.test.ts b/packages/player-sdk/src/bounded-zip-reader.test.ts new file mode 100644 index 0000000..a4563ec --- /dev/null +++ b/packages/player-sdk/src/bounded-zip-reader.test.ts @@ -0,0 +1,194 @@ +import JSZip from "jszip"; +import { describe, expect, it, vi } from "vitest"; + +import { + assertArchiveInputResourceLimits, + assertLoadedArchiveResourceLimits, + resolveArchiveResourceLimits +} from "./archive-resource-limits.js"; +import { BoundedZipReader } from "./bounded-zip-reader.js"; + +describe("BoundedZipReader", () => { + it("counts actual inflater output when declared uncompressed sizes are forged", async () => { + const zip = new JSZip(); + zip.file("bomb.bin", "x".repeat(2 * 1024 * 1024)); + zip.file("safe.txt", "safe"); + const source = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 9 } + }); + forgeZipUncompressedSize(source, "bomb.bin", 1); + + const loaded = await JSZip.loadAsync(source); + const limits = resolveArchiveResourceLimits({ + maxEntryUncompressedBytes: 1024 * 1024, + maxTotalUncompressedBytes: 2 * 1024 * 1024 + }); + expect(() => assertLoadedArchiveResourceLimits(loaded, limits)).not.toThrow(); + + const reader = new BoundedZipReader(loaded, limits); + await expect(reader.read("bomb.bin")).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxEntryUncompressedBytes" + }); + await expect(reader.read("safe.txt")).resolves.toEqual(new TextEncoder().encode("safe")); + }); + + it("accepts a legitimate highly compressible first-party-sized entry by default", async () => { + const zip = new JSZip(); + zip.file("blobs/sha256-repeat.bin", new Uint8Array(1024 * 1024)); + const source = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 6 } + }); + const loaded = await JSZip.loadAsync(source); + const limits = resolveArchiveResourceLimits(); + const reader = new BoundedZipReader(loaded, limits); + + await expect(reader.read("blobs/sha256-repeat.bin")).resolves.toHaveLength(1024 * 1024); + }); + + it("accounts concurrent distinct entries against one aggregate output budget", async () => { + const zip = new JSZip(); + zip.file("left.bin", new Uint8Array(2 * 1024 * 1024)); + zip.file("right.bin", new Uint8Array(2 * 1024 * 1024)); + const source = await zip.generateAsync({ type: "uint8array", compression: "STORE" }); + const loaded = await JSZip.loadAsync(source); + const limits = resolveArchiveResourceLimits({ + maxEntryUncompressedBytes: 3 * 1024 * 1024, + maxTotalUncompressedBytes: 3 * 1024 * 1024 + }); + const reader = new BoundedZipReader(loaded, limits, true); + + const results = await Promise.allSettled([reader.read("left.bin"), reader.read("right.bin")]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toEqual([ + expect.objectContaining({ + reason: expect.objectContaining({ resource: "maxTotalUncompressedBytes" }) + }) + ]); + }); + + it("memoizes concurrent and subsequent reads of the same entry", async () => { + const zip = new JSZip(); + zip.file("once.bin", new Uint8Array(256 * 1024)); + const source = await zip.generateAsync({ type: "uint8array", compression: "DEFLATE" }); + const loaded = await JSZip.loadAsync(source); + const file = loaded.file("once.bin") as JSZip.JSZipObject & { + internalStream: (type: "uint8array") => unknown; + }; + const internalStream = vi.spyOn(file, "internalStream"); + const reader = new BoundedZipReader(loaded, resolveArchiveResourceLimits(), true); + + const [first, second, third] = await Promise.all([ + reader.read("once.bin"), + reader.read("once.bin"), + reader.read("once.bin") + ]); + expect(first).toBe(second); + expect(second).toBe(third); + expect(internalStream).toHaveBeenCalledOnce(); + }); + + it("deduplicates only in-flight reads when completed-result caching is disabled", async () => { + const zip = new JSZip(); + zip.file("once.bin", new Uint8Array(256 * 1024)); + const source = await zip.generateAsync({ type: "uint8array", compression: "DEFLATE" }); + const loaded = await JSZip.loadAsync(source); + const file = loaded.file("once.bin") as JSZip.JSZipObject & { + internalStream: (type: "uint8array") => unknown; + }; + const internalStream = vi.spyOn(file, "internalStream"); + const reader = new BoundedZipReader(loaded, resolveArchiveResourceLimits(), false); + + await Promise.all([reader.read("once.bin"), reader.read("once.bin")]); + expect(internalStream).toHaveBeenCalledOnce(); + await reader.read("once.bin"); + expect(internalStream).toHaveBeenCalledTimes(2); + }); + + it("counts physical central-directory records even when EOCD under-reports them", async () => { + const zip = new JSZip(); + for (let index = 0; index < 6; index += 1) { + zip.file(`entry-${index}.txt`, ""); + } + const source = await zip.generateAsync({ type: "uint8array" }); + forgeEocdEntryCount(source, 1); + + expect(() => + assertArchiveInputResourceLimits(source, resolveArchiveResourceLimits({ maxEntryCount: 5 })) + ).toThrowError(expect.objectContaining({ resource: "maxEntryCount", actual: 6 })); + }); + + it("counts prepended archives using the physical central-directory location", async () => { + const zip = new JSZip(); + for (let index = 0; index < 6; index += 1) { + zip.file(`entry-${index}.txt`, ""); + } + const generated = await zip.generateAsync({ type: "uint8array" }); + forgeEocdEntryCount(generated, 1); + const source = new Uint8Array(generated.byteLength + 128); + source.fill(0x41, 0, 128); + source.set(generated, 128); + + expect(() => + assertArchiveInputResourceLimits(source, resolveArchiveResourceLimits({ maxEntryCount: 5 })) + ).toThrowError(expect.objectContaining({ resource: "maxEntryCount", actual: 6 })); + }); + + it("fails closed before JSZip when trailing data hides the ZIP directory record", async () => { + const zip = new JSZip(); + for (let index = 0; index < 6; index += 1) { + zip.file(`entry-${index}.txt`, ""); + } + const generated = await zip.generateAsync({ type: "uint8array" }); + forgeEocdEntryCount(generated, 1); + const source = new Uint8Array(generated.byteLength + 70 * 1024); + source.set(generated); + + await expect(JSZip.loadAsync(source)).resolves.toBeInstanceOf(JSZip); + expect(() => + assertArchiveInputResourceLimits(source, resolveArchiveResourceLimits({ maxEntryCount: 5 })) + ).toThrow(/end-of-central-directory.*supported trailing-data window/i); + }); +}); + +function forgeZipUncompressedSize(bytes: Uint8Array, targetName: string, size: number): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + + for (let offset = 0; offset + 46 <= bytes.byteLength; offset += 1) { + const signature = view.getUint32(offset, true); + if (signature === 0x04034b50) { + const nameBytes = view.getUint16(offset + 26, true); + const name = decoder.decode(bytes.subarray(offset + 30, offset + 30 + nameBytes)); + if (name === targetName) { + view.setUint32(offset + 22, size, true); + } + } + if (signature === 0x02014b50) { + const nameBytes = view.getUint16(offset + 28, true); + const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameBytes)); + if (name === targetName) { + view.setUint32(offset + 24, size, true); + } + } + } +} + +function forgeEocdEntryCount(bytes: Uint8Array, count: number): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let offset = bytes.byteLength - 22; offset >= 0; offset -= 1) { + if (view.getUint32(offset, true) !== 0x06054b50) { + continue; + } + + view.setUint16(offset + 8, count, true); + view.setUint16(offset + 10, count, true); + return; + } + + throw new Error("Fixture ZIP is missing EOCD."); +} diff --git a/packages/player-sdk/src/bounded-zip-reader.ts b/packages/player-sdk/src/bounded-zip-reader.ts new file mode 100644 index 0000000..a69a3ab --- /dev/null +++ b/packages/player-sdk/src/bounded-zip-reader.ts @@ -0,0 +1,371 @@ +import type JSZip from "jszip"; + +import { + type ArchiveResourceLimits, + ArchiveResourceLimitError +} from "./archive-resource-limits.js"; +import { ArchiveDecodeTimeoutError } from "./bounded-stream-reader.js"; + +type JSZipWorker = { + error?: (reason: Error) => boolean; + resume?: () => boolean; + isFinished?: boolean; +}; + +type JSZipStreamHelper = { + _worker?: JSZipWorker; + on(event: "data", callback: (data: Uint8Array) => void): JSZipStreamHelper; + on(event: "error", callback: (error: Error) => void): JSZipStreamHelper; + on(event: "end", callback: () => void): JSZipStreamHelper; + pause(): JSZipStreamHelper; + resume(): JSZipStreamHelper; +}; + +type JSZipObjectWithInternals = JSZip.JSZipObject & { + internalStream?: (type: "uint8array") => JSZipStreamHelper; + _data?: { + compressedContent?: unknown; + }; +}; + +type EntryState = { + bytes: number; + metadata: boolean; +}; + +/** Reads JSZip entries with limits based on actual inflater output instead of declared ZIP sizes. */ +export class BoundedZipReader { + private readonly budget: ZipOutputBudget; + + private readonly reads = new Map>(); + + public constructor( + private readonly zip: JSZip, + private readonly limits: Readonly, + private readonly cacheReads = true + ) { + this.budget = new ZipOutputBudget(limits); + } + + public has(path: string): boolean { + return Boolean(this.zip.file(path)); + } + + public read(path: string): Promise { + const cached = this.reads.get(path); + if (cached) { + return cached; + } + + const pending = this.readEntry(path); + this.reads.set(path, pending); + if (!this.cacheReads) { + void pending.then( + () => this.deleteRead(path, pending), + () => this.deleteRead(path, pending) + ); + } + return pending; + } + + /** Evicts a completed cached result after its caller no longer needs the inflated bytes. */ + public release(path: string): void { + this.reads.delete(path); + } + + private deleteRead(path: string, pending: Promise): void { + if (this.reads.get(path) === pending) { + this.reads.delete(path); + } + } + + private async readEntry(path: string): Promise { + const file = this.zip.file(path) as JSZipObjectWithInternals | null; + if (!file) { + throw new Error(`Archive is missing required file: ${path}`); + } + if (typeof file.internalStream !== "function") { + throw new Error("The installed JSZip runtime does not expose bounded entry streaming."); + } + + const compressedBytes = readCompressedContentBytes(file); + if (compressedBytes === null) { + throw new Error(`Unable to determine compressed input size for archive entry '${path}'.`); + } + + const helper = file.internalStream("uint8array"); + const accumulator = new ByteAccumulator(this.limits.maxEntryUncompressedBytes); + const metadata = isArchiveMetadataPath(path); + this.budget.begin(path, metadata); + + return new Promise((resolve, reject) => { + let terminal = false; + let timer: ReturnType | undefined; + + const clearTimer = () => { + if (timer !== undefined) { + clearTimeout(timer); + timer = undefined; + } + }; + const stopWorker = (reason: Error) => { + try { + helper.pause(); + } catch { + // Actual byte accounting already rejected the public promise; cleanup is best effort. + } + queueMicrotask(() => { + try { + const worker = helper._worker; + if (typeof worker?.error !== "function" || typeof worker.resume !== "function") { + return; + } + + worker.error(reason); + if (!worker.isFinished) { + worker.resume(); + } + } catch { + // JSZip's private worker API is pinned and used only to release work after rejection. + } + }); + }; + const fail = (error: unknown, stop = true) => { + if (terminal) { + return; + } + + terminal = true; + clearTimer(); + this.budget.abort(path); + const resolvedError = error instanceof Error ? error : new Error(String(error)); + if (stop) { + stopWorker(resolvedError); + } + reject(resolvedError); + }; + + try { + helper + .on("data", (chunk) => { + if (terminal) { + return; + } + + try { + const nextBytes = accumulator.length + chunk.byteLength; + this.budget.progress(path, compressedBytes, nextBytes); + accumulator.append(chunk); + } catch (error) { + fail(error); + } + }) + .on("error", (error) => { + fail(error, false); + }) + .on("end", () => { + if (terminal) { + return; + } + + try { + this.budget.commit(path, compressedBytes, accumulator.length); + terminal = true; + clearTimer(); + resolve(accumulator.bytes()); + } catch (error) { + fail(error, false); + } + }); + + timer = setTimeout(() => { + fail(new ArchiveDecodeTimeoutError(this.limits.decodeTimeoutMs, `ZIP entry '${path}'`)); + }, this.limits.decodeTimeoutMs); + helper.resume(); + } catch (error) { + fail(error, false); + } + }); + } +} + +class ZipOutputBudget { + private readonly committed = new Map(); + + private readonly inFlight = new Map(); + + private committedBytes = 0; + + private committedMetadataBytes = 0; + + public constructor(private readonly limits: Readonly) {} + + public begin(path: string, metadata: boolean): void { + if (!this.inFlight.has(path)) { + this.inFlight.set(path, { bytes: 0, metadata }); + } + } + + public progress(path: string, compressedBytes: number, actualBytes: number): void { + const state = this.inFlight.get(path); + if (!state) { + throw new Error(`Archive ZIP budget was not initialized for '${path}'.`); + } + + assertLimit( + "maxEntryUncompressedBytes", + actualBytes, + this.limits.maxEntryUncompressedBytes, + path + ); + if (state.metadata) { + assertLimit("maxMetadataEntryBytes", actualBytes, this.limits.maxMetadataEntryBytes, path); + } + assertLimit( + "maxCompressionRatio", + compressionRatio(actualBytes, compressedBytes), + this.limits.maxCompressionRatio, + path + ); + + const alreadyCommitted = this.committed.has(path); + const otherInFlightBytes = sumUncommittedEntryBytes(this.inFlight, this.committed, path); + assertLimit( + "maxTotalUncompressedBytes", + this.committedBytes + otherInFlightBytes + (alreadyCommitted ? 0 : actualBytes), + this.limits.maxTotalUncompressedBytes + ); + + if (state.metadata) { + const otherMetadataBytes = sumUncommittedMetadataBytes(this.inFlight, this.committed, path); + assertLimit( + "maxTotalMetadataBytes", + this.committedMetadataBytes + otherMetadataBytes + (alreadyCommitted ? 0 : actualBytes), + this.limits.maxTotalMetadataBytes + ); + } + + state.bytes = actualBytes; + } + + public commit(path: string, compressedBytes: number, actualBytes: number): void { + this.progress(path, compressedBytes, actualBytes); + const state = this.inFlight.get(path); + this.inFlight.delete(path); + + if (!state || this.committed.has(path)) { + return; + } + + this.committed.set(path, { ...state }); + this.committedBytes += state.bytes; + if (state.metadata) { + this.committedMetadataBytes += state.bytes; + } + } + + public abort(path: string): void { + this.inFlight.delete(path); + } +} + +class ByteAccumulator { + private output = new Uint8Array(0); + + public length = 0; + + public constructor(private readonly maxBytes: number) {} + + public append(chunk: Uint8Array): void { + const nextLength = this.length + chunk.byteLength; + if (nextLength > this.output.byteLength) { + const nextCapacity = Math.min( + this.maxBytes, + Math.max(nextLength, Math.max(1, this.output.byteLength) * 2) + ); + const grown = new Uint8Array(nextCapacity); + grown.set(this.output.subarray(0, this.length)); + this.output = grown; + } + + this.output.set(chunk, this.length); + this.length = nextLength; + } + + public bytes(): Uint8Array { + return this.output.byteLength === this.length ? this.output : this.output.slice(0, this.length); + } +} + +function readCompressedContentBytes(file: JSZipObjectWithInternals): number | null { + const content = file._data?.compressedContent; + if (typeof content === "string" || Array.isArray(content)) { + return content.length; + } + if ( + content instanceof ArrayBuffer || + (typeof SharedArrayBuffer !== "undefined" && content instanceof SharedArrayBuffer) + ) { + return content.byteLength; + } + if (ArrayBuffer.isView(content)) { + return content.byteLength; + } + return null; +} + +function isArchiveMetadataPath(path: string): boolean { + return ( + path === "manifest.json" || + path === "integrity/hashes.json" || + path.startsWith("index/") || + path === "privacy/manifest.json" + ); +} + +function sumUncommittedEntryBytes( + entries: Map, + committed: Map, + currentPath: string +): number { + let total = 0; + for (const [path, entry] of entries) { + if (path === currentPath || committed.has(path)) { + continue; + } + total += entry.bytes; + } + return total; +} + +function sumUncommittedMetadataBytes( + entries: Map, + committed: Map, + currentPath: string +): number { + let total = 0; + for (const [path, entry] of entries) { + if (path !== currentPath && !committed.has(path) && entry.metadata) { + total += entry.bytes; + } + } + return total; +} + +function compressionRatio(uncompressedBytes: number, compressedBytes: number): number { + if (uncompressedBytes === 0) { + return 0; + } + return compressedBytes === 0 ? Number.POSITIVE_INFINITY : uncompressedBytes / compressedBytes; +} + +function assertLimit( + resource: keyof ArchiveResourceLimits, + actual: number, + limit: number, + detail?: string +): void { + if (!Number.isFinite(actual) || actual > limit) { + throw new ArchiveResourceLimitError(resource, limit, actual, detail); + } +} diff --git a/packages/player-sdk/src/index.test.ts b/packages/player-sdk/src/index.test.ts index c636f13..c829048 100644 --- a/packages/player-sdk/src/index.test.ts +++ b/packages/player-sdk/src/index.test.ts @@ -70,6 +70,22 @@ describe("WebBlackboxPlayer", () => { expect(Array.from(blob?.bytes ?? [])).toEqual([1, 2, 3]); }); + it("memoizes lazy blob work without exposing mutable cached bytes", async () => { + const player = await WebBlackboxPlayer.open(await createFixtureArchive()); + const first = await player.getBlob("blob1"); + const second = await player.getBlob("blob1"); + + expect(first?.bytes).not.toBe(second?.bytes); + if (!first || !second) { + throw new Error("Missing fixture blob."); + } + first.bytes[0] = 99; + expect(Array.from(second.bytes)).toEqual([1, 2, 3]); + await expect(player.getBlob("blob1")).resolves.toMatchObject({ + bytes: Uint8Array.from([1, 2, 3]) + }); + }); + it("builds an explainable privacy protection report", async () => { const bytes = await createPrivacyFixtureArchive(); const player = await WebBlackboxPlayer.open(bytes); @@ -118,6 +134,111 @@ describe("WebBlackboxPlayer", () => { expect(fromBlob.events.length).toBeGreaterThan(0); }); + it("enforces input-byte limits at the boundary before reading Blob contents", async () => { + const bytes = await createFixtureArchive(); + + await expect( + WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxInputBytes: bytes.byteLength } + }) + ).resolves.toBeInstanceOf(WebBlackboxPlayer); + + const blob = new Blob([toArrayBuffer(bytes)]); + const arrayBufferSpy = vi.spyOn(blob, "arrayBuffer"); + await expect( + WebBlackboxPlayer.open(blob, { + resourceLimits: { maxInputBytes: bytes.byteLength - 1 } + }) + ).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxInputBytes" + }); + expect(arrayBufferSpy).not.toHaveBeenCalled(); + }); + + it("enforces event-count limits at the exact boundary", async () => { + const bytes = await createFixtureArchive(); + + await expect( + WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxEventCount: 5 } + }) + ).resolves.toBeInstanceOf(WebBlackboxPlayer); + await expect( + WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxEventCount: 4 } + }) + ).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxEventCount", + actual: 5 + }); + }); + + it("stops before parsing later NDJSON lines after the event budget is exhausted", async () => { + const source = await createFixtureArchive(); + const zip = await JSZip.loadAsync(source); + const eventFile = zip.file("events/chunk-000001.ndjson"); + if (!eventFile) { + throw new Error("Missing fixture event chunk."); + } + const lines = (await eventFile.async("string")).split("\n"); + zip.file("events/chunk-000001.ndjson", `${lines[0]}\n${lines[1]}\n{ malformed`); + await writeIntegrityHashes(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect( + WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxEventCount: 1 } + }) + ).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxEventCount", + actual: 2 + }); + }); + + it("applies compression-ratio limits to nested event codecs", async () => { + const bytes = await createCompressedCodecArchive("gzip"); + + await expect( + WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxCompressionRatio: 1 } + }) + ).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxCompressionRatio" + }); + }); + + it("applies the same output limit to the Node codec fallback", async () => { + const bytes = await createCompressedCodecArchive("gzip"); + const originalDecompressionStream = globalThis.DecompressionStream; + + Object.defineProperty(globalThis, "DecompressionStream", { + configurable: true, + writable: true, + value: undefined + }); + + try { + await expect( + WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxCompressionRatio: 1 } + }) + ).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxCompressionRatio" + }); + } finally { + Object.defineProperty(globalThis, "DecompressionStream", { + configurable: true, + writable: true, + value: originalDecompressionStream + }); + } + }); + it("opens plain archives without global Web Crypto when Node crypto is available", async () => { const bytes = await createFixtureArchive(); const originalCrypto = (globalThis as unknown as { crypto?: Crypto }).crypto; @@ -184,7 +305,7 @@ describe("WebBlackboxPlayer", () => { const parseCountAfterFirstRead = parseSpy.mock.calls.length; expect(firstEvents.length).toBeGreaterThan(0); - expect(parseCountAfterFirstRead).toBe(firstEvents.length); + expect(parseCountAfterFirstRead).toBe(0); const secondEvents = player.events; const firstDerived = player.buildDerived(); @@ -258,6 +379,35 @@ describe("WebBlackboxPlayer", () => { } }); + it("opens a normally DEFLATE-compressed exporter-sized ZIP under default limits", async () => { + const source = await createFixtureArchive(); + const zip = await JSZip.loadAsync(source); + const bytes = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 6 } + }); + + const player = await WebBlackboxPlayer.open(bytes); + expect(player.events).toHaveLength(5); + }); + + it("opens a legitimate highly compressible exporter blob under default limits", async () => { + const zip = await JSZip.loadAsync(await createFixtureArchive()); + zip.file("blobs/sha256-blob1.webp", new Uint8Array(1024 * 1024)); + await writeIntegrityHashes(zip); + const bytes = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 6 } + }); + + const player = await WebBlackboxPlayer.open(bytes); + await expect(player.getBlob("blob1")).resolves.toMatchObject({ + bytes: expect.objectContaining({ byteLength: 1024 * 1024 }) + }); + }); + it("opens encrypted archives when passphrase is provided", async () => { const bytes = await createEncryptedArchive(await createFixtureArchive(), "test-passphrase"); @@ -536,6 +686,26 @@ describe("WebBlackboxPlayer", () => { await expect(player.getBlob("blob1")).rejects.toThrow(/integrity mismatch/i); }); + it("applies actual ZIP output limits to lazily loaded blobs", async () => { + const zip = await JSZip.loadAsync(await createFixtureArchive()); + zip.file("blobs/sha256-blob1.webp", new Uint8Array(2 * 1024 * 1024)); + await writeIntegrityHashes(zip); + const bytes = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 9 } + }); + forgeZipEntryUncompressedSize(bytes, "blobs/sha256-blob1.webp", 1); + + const player = await WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxEntryUncompressedBytes: 1024 * 1024 } + }); + await expect(player.getBlob("blob1")).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxEntryUncompressedBytes" + }); + }); + it("rejects archives with undeclared event chunks", async () => { const source = await createFixtureArchive(); const zip = await JSZip.loadAsync(source); @@ -2492,3 +2662,26 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { copy.set(bytes); return copy.buffer; } + +function forgeZipEntryUncompressedSize(bytes: Uint8Array, targetName: string, size: number): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + + for (let offset = 0; offset + 46 <= bytes.byteLength; offset += 1) { + const signature = view.getUint32(offset, true); + if (signature === 0x04034b50) { + const nameBytes = view.getUint16(offset + 26, true); + const name = decoder.decode(bytes.subarray(offset + 30, offset + 30 + nameBytes)); + if (name === targetName) { + view.setUint32(offset + 22, size, true); + } + } + if (signature === 0x02014b50) { + const nameBytes = view.getUint16(offset + 28, true); + const name = decoder.decode(bytes.subarray(offset + 46, offset + 46 + nameBytes)); + if (name === targetName) { + view.setUint32(offset + 24, size, true); + } + } + } +} diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index b43a3e6..19b5224 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -27,6 +27,28 @@ import { parseTimeIndex } from "@webblackbox/protocol"; +import { + ArchiveDecodeBudget, + ArchiveResourceLimitError, + assertArchiveInputResourceLimits, + assertLoadedArchiveResourceLimits, + resolveArchiveResourceLimits, + type ArchiveResourceLimits +} from "./archive-resource-limits.js"; +import { ArchiveDecodeTimeoutError, readBoundedReadableStream } from "./bounded-stream-reader.js"; +import { BoundedZipReader } from "./bounded-zip-reader.js"; + +export { + ArchiveResourceLimitError, + DEFAULT_ARCHIVE_RESOURCE_LIMITS, + assertArchiveInputResourceLimits, + assertLoadedArchiveResourceLimits, + resolveArchiveResourceLimits, + type ArchiveResourceLimits +} from "./archive-resource-limits.js"; +export { ArchiveDecodeTimeoutError } from "./bounded-stream-reader.js"; +export { BoundedZipReader } from "./bounded-zip-reader.js"; + /** Player lifecycle status. */ export type PlayerStatus = "idle" | "loaded"; @@ -37,6 +59,7 @@ export type PlayerOpenInput = ArrayBuffer | Uint8Array | Blob; export type PlayerOpenOptions = { passphrase?: string; range?: PlayerRange; + resourceLimits?: Partial; }; /** Monotonic-time query range in milliseconds. */ @@ -415,14 +438,25 @@ type BlobRef = { mime: string; }; +type PlayerBlob = { + mime: string; + bytes: Uint8Array; +}; + type ArchiveEncryptedFileMeta = { ivBase64: string; }; type NodeZlibLike = { - gunzipSync?: (input: Uint8Array) => Uint8Array; - brotliDecompressSync?: (input: Uint8Array) => Uint8Array; - zstdDecompressSync?: (input: Uint8Array) => Uint8Array; + createGunzip?: (options: { chunkSize: number }) => NodeJS.ReadWriteStream; + createBrotliDecompress?: (options: { chunkSize: number }) => NodeJS.ReadWriteStream; + createZstdDecompress?: (options: { chunkSize: number }) => NodeJS.ReadWriteStream; +}; + +type NodeStreamLike = { + Readable?: { + toWeb?: (stream: NodeJS.ReadableStream) => ReadableStream; + }; }; const ACTION_TRIGGER_TYPES = new Set([ @@ -458,7 +492,6 @@ const DEFAULT_TIMELINE_SCREENSHOT_LOOKAHEAD_MS = 2000; const DEFAULT_TIMELINE_REQUEST_LIMIT = 5; const DEFAULT_TIMELINE_ERROR_LIMIT = 5; const DEFAULT_DECODED_CHUNK_CACHE_SIZE = 12; -const STREAM_CODEC_TIMEOUT_MS = 5_000; type EventChunkSource = { chunkId: string; @@ -467,6 +500,7 @@ type EventChunkSource = { monoStart: number; monoEnd: number; bytes: Uint8Array; + events: WebBlackboxEvent[]; }; type EventChunkDescriptor = { @@ -495,7 +529,7 @@ export class WebBlackboxPlayer { /** Parsed archive metadata and indexes. */ public readonly archive: PlayerArchive; - private readonly zip: JSZip; + private readonly zipReader: BoundedZipReader; private readonly eventChunks: EventChunkSource[]; @@ -517,18 +551,21 @@ export class WebBlackboxPlayer { private readonly blobsByHash = new Map(); + private readonly blobReads = new Map>(); + private readonly archiveKey: CryptoKey | null; private readonly encryptedFiles: Record; private constructor( zip: JSZip, + zipReader: BoundedZipReader, archive: PlayerArchive, eventChunks: EventChunkSource[], archiveKey: CryptoKey | null, encryptedFiles: Record ) { - this.zip = zip; + this.zipReader = zipReader; this.archive = archive; this.archiveKey = archiveKey; this.encryptedFiles = encryptedFiles; @@ -572,44 +609,59 @@ export class WebBlackboxPlayer { input: PlayerOpenInput, options: PlayerOpenOptions = {} ): Promise { - const bytes = await normalizeOpenInput(input); + const resourceLimits = resolveArchiveResourceLimits(options.resourceLimits); + const bytes = await normalizeOpenInput(input, resourceLimits); const zip = await JSZip.loadAsync(bytes); + assertLoadedArchiveResourceLimits(zip, resourceLimits); + const zipReader = new BoundedZipReader(zip, resourceLimits, true); + const decodeBudget = new ArchiveDecodeBudget(resourceLimits); - const integrity = parseHashesManifest(await readJsonValue(zip, "integrity/hashes.json")); + const integrity = parseHashesManifest(await readJsonValue(zipReader, "integrity/hashes.json")); assertArchiveFileSet(zip, integrity); - await assertManifestIntegrity(zip, integrity); - const manifest = parseExportManifest(await readJsonValue(zip, "manifest.json")); + await assertManifestIntegrity(zipReader, integrity); + const manifest = parseExportManifest(await readJsonValue(zipReader, "manifest.json")); const archiveKey = await resolveArchiveReadKey(manifest, options.passphrase); const encryptedFiles = manifest.encryption?.files ?? {}; const timeIndex = parseTimeIndex( await readIntegrityArchiveJsonValue( - zip, + zipReader, integrity, "index/time.json", archiveKey, encryptedFiles ) ); + decodeBudget.commitIndex("index/time.json", timeIndex.length, 0); const requestIndex = parseRequestIndex( await readIntegrityArchiveJsonValue( - zip, + zipReader, integrity, "index/req.json", archiveKey, encryptedFiles ) ); + decodeBudget.commitIndex( + "index/req.json", + requestIndex.length, + countIndexEventReferences(requestIndex) + ); const invertedIndex = parseInvertedIndex( await readIntegrityArchiveJsonValue( - zip, + zipReader, integrity, "index/inv.json", archiveKey, encryptedFiles ) ); + decodeBudget.commitIndex( + "index/inv.json", + invertedIndex.length, + countIndexEventReferences(invertedIndex) + ); const privacyValue = await readOptionalIntegrityArchiveJsonValue( - zip, + zipReader, integrity, "privacy/manifest.json", archiveKey, @@ -627,20 +679,22 @@ export class WebBlackboxPlayer { }); const eventChunkResult = await readEventChunkSources( - zip, + zipReader, archiveKey, encryptedFiles, { range: options.range, timeIndex }, - integrity + integrity, + decodeBudget ); assertArchiveEventIndexes(manifest, eventChunkResult.events, requestIndex, invertedIndex); return new WebBlackboxPlayer( zip, + zipReader, { manifest, timeIndex, @@ -828,7 +882,7 @@ export class WebBlackboxPlayer { return cached; } - const parsed = parseChunkEvents(chunk); + const parsed = chunk.events; this.decodedChunkCache.set(chunk.chunkId, parsed); while (this.decodedChunkCache.size > DEFAULT_DECODED_CHUNK_CACHE_SIZE) { @@ -865,27 +919,33 @@ export class WebBlackboxPlayer { } /** Resolves a stored blob by hash or blob path alias. */ - public async getBlob(hash: string): Promise<{ mime: string; bytes: Uint8Array } | null> { + public async getBlob(hash: string): Promise { const blob = resolveBlobByKey(this.blobsByHash, hash); if (!blob) { return null; } - const file = this.zip.file(blob.path); - - if (!file) { + if (!this.zipReader.has(blob.path)) { return null; } - const rawBytes = await file.async("uint8array"); - await assertArchiveFileIntegrity(this.zip, this.archive.integrity, blob.path, rawBytes); - const bytes = await this.decryptArchiveFile(blob.path, rawBytes); + const cached = this.blobReads.get(blob.path); + if (cached) { + return clonePlayerBlob(await cached); + } - return { - mime: blob.mime, - bytes - }; + const pending = readTransientZipEntry(this.zipReader, blob.path, async (rawBytes) => { + await assertArchiveFileIntegrity(this.archive.integrity, blob.path, rawBytes); + const bytes = await this.decryptArchiveFile(blob.path, rawBytes); + + return { + mime: blob.mime, + bytes + }; + }); + this.blobReads.set(blob.path, pending); + return clonePlayerBlob(await pending); } /** Builds action-span aggregates and total counters for the selected range. */ @@ -3097,14 +3157,15 @@ async function resolveArchiveReadKey( } async function readEventChunkSources( - zip: JSZip, + zipReader: BoundedZipReader, archiveKey: CryptoKey | null, encryptedFiles: Record, options: { range?: PlayerRange; timeIndex?: ChunkTimeIndexEntry[]; - } = {}, - integrity?: HashesManifest + }, + integrity: HashesManifest, + decodeBudget: ArchiveDecodeBudget ): Promise { const descriptors = buildEventChunkDescriptors(options); const chunks: EventChunkSource[] = []; @@ -3112,29 +3173,24 @@ async function readEventChunkSources( for (const descriptor of descriptors) { const { path } = descriptor; - const file = zip.file(path); - - if (!file) { - throw new Error(`Invalid WebBlackbox archive: missing indexed event chunk '${path}'.`); - } - - const rawBytes = await file.async("uint8array"); - - if (integrity) { - await assertArchiveFileIntegrity(zip, integrity, path, rawBytes); - } - - const decrypted = await decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); - const bytes = await decodeChunkBytes(decrypted, descriptor.codec); + const decrypted = await readTransientZipEntry(zipReader, path, async (rawBytes) => { + await assertArchiveFileIntegrity(integrity, path, rawBytes); + return decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); + }); + const bytes = await decodeChunkBytes(decrypted, descriptor.codec, path, decodeBudget); const chunk: EventChunkSource = { chunkId: descriptor.chunkId, path, seq: descriptor.seq, monoStart: descriptor.monoStart, monoEnd: descriptor.monoEnd, - bytes + bytes, + events: [] }; - const chunkEvents = parseChunkEvents(chunk); + const chunkEvents = parseChunkEvents(chunk, decodeBudget); + if (descriptor.selected) { + chunk.events = chunkEvents; + } assertArchiveChunk({ path, @@ -3143,12 +3199,15 @@ async function readEventChunkSources( encodedSha256: await sha256Hex(decrypted), events: chunkEvents }); + chunk.bytes = new Uint8Array(0); if (descriptor.selected) { chunks.push(chunk); } - events.push(...chunkEvents); + for (const event of chunkEvents) { + events.push(event); + } } return { @@ -3176,28 +3235,58 @@ function buildEventChunkDescriptors(options: { })); } -function parseChunkEvents(chunk: EventChunkSource): WebBlackboxEvent[] { +function parseChunkEvents( + chunk: EventChunkSource, + decodeBudget: ArchiveDecodeBudget +): WebBlackboxEvent[] { const content = new TextDecoder().decode(chunk.bytes); - const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); const events: WebBlackboxEvent[] = []; + let lineStart = 0; + let lineNumber = 0; - for (const [lineIndex, line] of lines.entries()) { + for (let cursor = 0; cursor <= content.length; cursor += 1) { + if (cursor < content.length && content.charCodeAt(cursor) !== 10) { + continue; + } + + lineNumber += 1; + const lineEnd = cursor; + if (!hasNonWhitespace(content, lineStart, lineEnd)) { + lineStart = cursor + 1; + continue; + } + + decodeBudget.commitEvents(chunk.path, 1); + const line = content.slice(lineStart, lineEnd); try { - events.push(parseArchivedEvent(JSON.parse(line) as unknown, chunk.path, lineIndex + 1)); + events.push(parseArchivedEvent(JSON.parse(line) as unknown, chunk.path, lineNumber)); } catch (error) { if (error instanceof SyntaxError) { throw new Error( - `Invalid WebBlackbox archive: '${chunk.path}' contains malformed JSON at event line ${lineIndex + 1}.` + `Invalid WebBlackbox archive: '${chunk.path}' contains malformed JSON at event line ${lineNumber}.` ); } throw error; } + + lineStart = cursor + 1; } return events; } +function hasNonWhitespace(value: string, start: number, end: number): boolean { + for (let index = start; index < end; index += 1) { + const code = value.charCodeAt(index); + if (code !== 9 && code !== 10 && code !== 13 && code !== 32) { + return true; + } + } + + return false; +} + function chunkSourceIntersectsRange(chunk: EventChunkSource, range: PlayerRange): boolean { if ( Number.isFinite(chunk.monoEnd) && @@ -3259,20 +3348,28 @@ async function decryptArchiveBytes( } } -async function decodeChunkBytes(bytes: Uint8Array, codec: ChunkCodec): Promise { +async function decodeChunkBytes( + bytes: Uint8Array, + codec: ChunkCodec, + path: string, + decodeBudget: ArchiveDecodeBudget +): Promise { if (codec === "none") { + decodeBudget.commitDecoded(path, bytes.byteLength, bytes.byteLength); return bytes; } - const fromStreams = await tryDecodeChunkWithStreams(bytes, codec); + const fromStreams = await tryDecodeChunkWithStreams(bytes, codec, path, decodeBudget); if (fromStreams) { + decodeBudget.commitDecoded(path, bytes.byteLength, fromStreams.byteLength); return fromStreams; } - const fromNodeZlib = await tryDecodeChunkWithNodeZlib(bytes, codec); + const fromNodeZlib = await tryDecodeChunkWithNodeZlib(bytes, codec, path, decodeBudget); if (fromNodeZlib) { + decodeBudget.commitDecoded(path, bytes.byteLength, fromNodeZlib.byteLength); return fromNodeZlib; } @@ -3281,7 +3378,9 @@ async function decodeChunkBytes(bytes: Uint8Array, codec: ChunkCodec): Promise { if (typeof DecompressionStream === "undefined" || typeof Blob === "undefined") { return null; @@ -3289,11 +3388,24 @@ async function tryDecodeChunkWithStreams( for (const format of codecFormats(codec)) { try { - const stream = new Blob([toArrayBuffer(bytes)]) - .stream() - .pipeThrough(new DecompressionStream(format as CompressionFormat)); - return await readReadableStreamWithTimeout(stream, codec, format); - } catch { + const decompressor = new DecompressionStream(format as CompressionFormat); + const stream = new Blob([toArrayBuffer(bytes)]).stream().pipeThrough(decompressor); + return await readBoundedReadableStream(stream, { + timeoutMs: decodeBudget.limits.decodeTimeoutMs, + detail: `${path}, codec '${codec}', format '${format}'`, + maxBytes: decodeBudget.limits.maxChunkDecodedBytes, + validateTotalBytes(totalBytes) { + decodeBudget.assertDecodedSize(path, bytes.byteLength, totalBytes); + } + }); + } catch (error) { + if ( + error instanceof ArchiveResourceLimitError || + error instanceof ArchiveDecodeTimeoutError + ) { + throw error; + } + continue; } } @@ -3301,99 +3413,80 @@ async function tryDecodeChunkWithStreams( return null; } -async function readReadableStream(stream: ReadableStream): Promise { - const reader = stream.getReader(); - const chunks: Uint8Array[] = []; - let totalLength = 0; +async function tryDecodeChunkWithNodeZlib( + bytes: Uint8Array, + codec: ChunkCodec, + path: string, + decodeBudget: ArchiveDecodeBudget +): Promise { + const [zlib, nodeStream] = await Promise.all([loadNodeZlib(), loadNodeStream()]); - while (true) { - const { done, value } = await reader.read(); + if (!zlib || !nodeStream?.Readable?.toWeb) { + return null; + } - if (done) { - break; + const options = { chunkSize: 16 * 1024 }; + let codecStream: NodeJS.ReadWriteStream | null = null; + + try { + if (codec === "gzip" && typeof zlib.createGunzip === "function") { + codecStream = zlib.createGunzip(options); } - if (!value) { - continue; + if (codec === "br" && typeof zlib.createBrotliDecompress === "function") { + codecStream = zlib.createBrotliDecompress(options); } - const chunk = value instanceof Uint8Array ? value : new Uint8Array(value); - chunks.push(chunk); - totalLength += chunk.byteLength; + if (codec === "zst" && typeof zlib.createZstdDecompress === "function") { + codecStream = zlib.createZstdDecompress(options); + } + } catch { + return null; } - const output = new Uint8Array(totalLength); - let cursor = 0; - - for (const chunk of chunks) { - output.set(chunk, cursor); - cursor += chunk.byteLength; + if (!codecStream) { + return null; } - return output; -} - -async function readReadableStreamWithTimeout( - stream: ReadableStream, - codec: ChunkCodec, - format: string -): Promise { - return withTimeout( - readReadableStream(stream), - STREAM_CODEC_TIMEOUT_MS, - `Chunk codec '${codec}' decode timed out for format '${format}'.` - ); -} - -async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { - let timer: ReturnType | null = null; + const stream = nodeStream.Readable.toWeb(codecStream); + codecStream.end(bytes); try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timer = setTimeout(() => { - reject(new Error(message)); - }, timeoutMs); - }) - ]); - } finally { - if (timer) { - clearTimeout(timer); + return await readBoundedReadableStream(stream, { + timeoutMs: decodeBudget.limits.decodeTimeoutMs, + detail: `${path}, Node codec '${codec}'`, + maxBytes: decodeBudget.limits.maxChunkDecodedBytes, + validateTotalBytes(totalBytes) { + decodeBudget.assertDecodedSize(path, bytes.byteLength, totalBytes); + } + }); + } catch (error) { + if (error instanceof ArchiveResourceLimitError || error instanceof ArchiveDecodeTimeoutError) { + throw error; } + + return null; } } -async function tryDecodeChunkWithNodeZlib( - bytes: Uint8Array, - codec: ChunkCodec -): Promise { - const zlib = await loadNodeZlib(); - - if (!zlib) { +async function loadNodeZlib(): Promise { + if ( + typeof process === "undefined" || + typeof process.versions !== "object" || + typeof process.versions?.node !== "string" + ) { return null; } try { - if (codec === "gzip" && typeof zlib.gunzipSync === "function") { - return cloneBytes(zlib.gunzipSync(bytes)); - } - - if (codec === "br" && typeof zlib.brotliDecompressSync === "function") { - return cloneBytes(zlib.brotliDecompressSync(bytes)); - } - - if (codec === "zst" && typeof zlib.zstdDecompressSync === "function") { - return cloneBytes(zlib.zstdDecompressSync(bytes)); - } + const module = await import("node:zlib"); + return module as unknown as NodeZlibLike; } catch { return null; } - - return null; } -async function loadNodeZlib(): Promise { +async function loadNodeStream(): Promise { if ( typeof process === "undefined" || typeof process.versions !== "object" || @@ -3403,8 +3496,8 @@ async function loadNodeZlib(): Promise { } try { - const module = await import("node:zlib"); - return module as unknown as NodeZlibLike; + const module = await import("node:stream"); + return module as unknown as NodeStreamLike; } catch { return null; } @@ -3556,36 +3649,71 @@ function updateActionStats(span: ActionSpan, event: WebBlackboxEvent): void { } } -async function readJsonValue(zip: JSZip, path: string): Promise { - const content = await readZipFileText(zip, path); - return parseArchiveJson(content, path); +function countIndexEventReferences(entries: Array<{ eventIds: string[] }>): number { + let total = 0; + + for (const entry of entries) { + total += entry.eventIds.length; + if (!Number.isSafeInteger(total)) { + return Number.POSITIVE_INFINITY; + } + } + + return total; +} + +function clonePlayerBlob(blob: PlayerBlob): PlayerBlob { + return { + mime: blob.mime, + bytes: blob.bytes.slice() + }; +} + +async function readJsonValue(zipReader: BoundedZipReader, path: string): Promise { + return readTransientZipEntry(zipReader, path, (bytes) => { + const content = new TextDecoder().decode(bytes); + return parseArchiveJson(content, path); + }); } async function readIntegrityArchiveJsonValue( - zip: JSZip, + zipReader: BoundedZipReader, integrity: HashesManifest, path: string, archiveKey: CryptoKey | null, encryptedFiles: Record ): Promise { - const rawBytes = await readZipFileBytes(zip, path); - await assertArchiveFileIntegrity(zip, integrity, path, rawBytes); - const bytes = await decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); - return parseArchiveJson(new TextDecoder().decode(bytes), path); + return readTransientZipEntry(zipReader, path, async (rawBytes) => { + await assertArchiveFileIntegrity(integrity, path, rawBytes); + const bytes = await decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); + return parseArchiveJson(new TextDecoder().decode(bytes), path); + }); +} + +async function readTransientZipEntry( + zipReader: BoundedZipReader, + path: string, + consume: (bytes: Uint8Array) => T | Promise +): Promise { + try { + return await consume(await zipReader.read(path)); + } finally { + zipReader.release(path); + } } async function readOptionalIntegrityArchiveJsonValue( - zip: JSZip, + zipReader: BoundedZipReader, integrity: HashesManifest, path: string, archiveKey: CryptoKey | null, encryptedFiles: Record ): Promise { - if (!zip.file(path)) { + if (!zipReader.has(path)) { return null; } - return readIntegrityArchiveJsonValue(zip, integrity, path, archiveKey, encryptedFiles); + return readIntegrityArchiveJsonValue(zipReader, integrity, path, archiveKey, encryptedFiles); } function parseArchiveJson(content: string, path: string): unknown { @@ -3602,28 +3730,11 @@ function archiveFilePaths(zip: JSZip): string[] { .map(([path]) => path); } -async function readZipFileBytes(zip: JSZip, path: string): Promise { - const file = zip.file(path); - - if (!file) { - throw new Error(`Archive is missing required file: ${path}`); - } - - return file.async("uint8array"); -} - -async function readZipFileText(zip: JSZip, path: string): Promise { - const file = zip.file(path); - - if (!file) { - throw new Error(`Archive is missing required file: ${path}`); - } - - return file.async("string"); -} - -async function assertManifestIntegrity(zip: JSZip, integrity: HashesManifest): Promise { - const actual = await sha256Hex(await readZipFileBytes(zip, "manifest.json")); +async function assertManifestIntegrity( + zipReader: BoundedZipReader, + integrity: HashesManifest +): Promise { + const actual = await sha256Hex(await zipReader.read("manifest.json")); if (actual !== integrity.manifestSha256) { throw new Error("Archive integrity mismatch for manifest.json"); @@ -3650,10 +3761,9 @@ function assertArchiveFileSet(zip: JSZip, integrity: HashesManifest): void { } async function assertArchiveFileIntegrity( - zip: JSZip, integrity: HashesManifest, path: string, - bytes?: Uint8Array + bytes: Uint8Array ): Promise { const expected = integrity.files[path]; @@ -3661,7 +3771,7 @@ async function assertArchiveFileIntegrity( throw new Error(`Archive integrity manifest is missing hash for ${path}`); } - const actual = await sha256Hex(bytes ?? (await readZipFileBytes(zip, path))); + const actual = await sha256Hex(bytes); if (actual !== expected) { throw new Error(`Archive integrity mismatch for ${path}`); @@ -3845,29 +3955,44 @@ function fromBase64(value: string): Uint8Array { } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { + if (bytes.buffer instanceof ArrayBuffer) { + if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) { + return bytes.buffer; + } + + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + } + const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); return copy.buffer; } -function cloneBytes(bytes: Uint8Array): Uint8Array { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return copy; -} +async function normalizeOpenInput( + input: PlayerOpenInput, + resourceLimits: Readonly +): Promise { + let bytes: Uint8Array; -async function normalizeOpenInput(input: PlayerOpenInput): Promise { if (input instanceof Uint8Array) { - return input; - } - - if (input instanceof ArrayBuffer) { - return new Uint8Array(input); - } + bytes = input; + } else if (input instanceof ArrayBuffer) { + bytes = new Uint8Array(input); + } else if (typeof Blob !== "undefined" && input instanceof Blob) { + if (input.size > resourceLimits.maxInputBytes) { + throw new ArchiveResourceLimitError( + "maxInputBytes", + resourceLimits.maxInputBytes, + input.size, + "Blob size" + ); + } - if (typeof Blob !== "undefined" && input instanceof Blob) { - return new Uint8Array(await input.arrayBuffer()); + bytes = new Uint8Array(await input.arrayBuffer()); + } else { + throw new Error("Unsupported archive input type."); } - throw new Error("Unsupported archive input type."); + assertArchiveInputResourceLimits(bytes, resourceLimits); + return bytes; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5309d5c..e5524d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,7 +179,7 @@ importers: specifier: workspace:* version: link:../../packages/player-sdk jszip: - specifier: ^3.10.1 + specifier: 3.10.1 version: 3.10.1 packages/cdp-router: @@ -207,7 +207,7 @@ importers: specifier: workspace:* version: link:../protocol jszip: - specifier: ^3.10.1 + specifier: 3.10.1 version: 3.10.1 packages/protocol: From d06ca8b3dec3e1df64ce8aee1d793fd0e144db6e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:27:21 +0800 Subject: [PATCH 046/181] fix(share): pin canonical public origin --- apps/share-server/README.md | 1 + apps/share-server/src/index.test.ts | 80 ++++++++++++++++++++ apps/share-server/src/index.ts | 113 +++++++++++++++++----------- 3 files changed, 151 insertions(+), 43 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 5f8d568..70f83ca 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -29,6 +29,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_API_KEYS`: semicolon-separated scoped keys for rotation and least privilege. Format: `secret:scope,scope;next-secret:scope`. Supported scopes are `upload`, `read`, `list`, `revoke`, and `admin`. `admin` covers all scopes. Unknown or empty scopes and duplicate secrets fail startup instead of falling back to `admin`. A key without `:scope` retains the legacy explicit-admin behavior. Keep an old key and a new key configured during rotation, then remove the old key after clients are updated. - `WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY`: optional browser bootstrap for `GET /share/:id?key=`. Keep this disabled in production unless the key is short-lived; when enabled, the server redirects to a clean URL and uses a short HttpOnly read-session cookie for page links. - `WEBBLACKBOX_SHARE_BIND_HOST`: bind host for the HTTP server (default `127.0.0.1`). +- `WEBBLACKBOX_SHARE_PUBLIC_ORIGIN`: canonical external origin used for share URLs, same-origin CORS, and secure-cookie policy. It is required when binding to a non-loopback host, must be HTTPS unless it names a loopback host, and must not include credentials, a path, query, or fragment. Request `Host` and `X-Forwarded-Proto` headers are never used as public-origin authority. - `WEBBLACKBOX_SHARE_ALLOWED_ORIGIN`: CORS allow origin. Defaults to `same-origin`. Use `*` only for trusted environments. - `WEBBLACKBOX_SHARE_MAX_UPLOAD_BYTES`: max accepted upload body size in bytes (default `262144000`, hard-capped by the Player SDK's 256 MiB input ceiling). - `WEBBLACKBOX_SHARE_MAX_CONCURRENT_INSPECTIONS`: maximum upload bodies that may be received and inspected concurrently (default `1`). Additional uploads receive `503` with `Retry-After`. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index b8902fe..094e77d 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -615,6 +615,81 @@ describe("share-server", () => { expect(response.status).toBe(401); }); + it("does not trust Host or forwarded-protocol headers for public URLs and CORS", async () => { + const server = await startShareServer(); + const encryptedArchive = await createEncryptedEnvelopeArchive(); + const uploadResponse = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + host: "attacker.example", + origin: "https://attacker.example", + "x-forwarded-proto": "https", + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + }, + body: Buffer.from(encryptedArchive) + }); + const upload = (await uploadResponse.json()) as { shareUrl: string }; + + expect(uploadResponse.status).toBe(201); + expect(upload.shareUrl).toMatch(new RegExp(`^${escapeRegExp(server.baseUrl)}/share/`)); + expect(upload.shareUrl).not.toContain("attacker.example"); + expect(uploadResponse.headers.get("access-control-allow-origin")).toBeNull(); + + const allowedPreflight = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "OPTIONS", + headers: { + host: "attacker.example", + origin: server.baseUrl, + "x-forwarded-proto": "javascript" + } + }); + expect(allowedPreflight.headers.get("access-control-allow-origin")).toBe(server.baseUrl); + }); + + it("uses the configured HTTPS public origin and secures browser session cookies", async () => { + const publicOrigin = "https://shares.example.test"; + const server = await startShareServer({ + WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY: "true", + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: publicOrigin + }); + const uploadPayload = await uploadEncryptedFixture(server); + + const metadataResponse = await fetch( + `${server.baseUrl}/api/share/${uploadPayload.shareId}/meta`, + { + headers: { + "x-webblackbox-api-key": apiKey + } + } + ); + const metadata = (await metadataResponse.json()) as { shareUrl: string }; + expect(metadata.shareUrl).toBe(`${publicOrigin}/share/${uploadPayload.shareId}`); + + const redirectResponse = await fetch( + `${server.baseUrl}/share/${uploadPayload.shareId}?key=${apiKey}`, + { redirect: "manual" } + ); + expect(redirectResponse.headers.get("set-cookie")).toContain("; Secure"); + }); + + it("requires an explicit secure public origin for non-loopback binds", async () => { + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_BIND_HOST: "0.0.0.0", + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "" + }) + ).rejects.toThrow(/WEBBLACKBOX_SHARE_PUBLIC_ORIGIN is required/); + + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_BIND_HOST: "0.0.0.0", + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "http://shares.example.test" + }) + ).rejects.toThrow(/must use HTTPS for non-loopback hosts/); + }); + it("supports opt-in query API key bootstrap without propagating the key", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY: "true" @@ -659,6 +734,7 @@ async function startShareServer( PORT: String(port), WEBBLACKBOX_SHARE_API_KEY: apiKey, WEBBLACKBOX_SHARE_BIND_HOST: "127.0.0.1", + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "", WEBBLACKBOX_SHARE_DATA_DIR: dataDir, ...envOverrides }, @@ -765,6 +841,10 @@ function readSetCookiePair(response: Response): string { return response.headers.get("set-cookie")?.split(";")[0] ?? ""; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + async function uploadEncryptedFixture( server: RunningShareServer, credential = apiKey diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 0e2fcf0..15aced3 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -180,6 +180,7 @@ const uploadRateWindows = new Map(); let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; +let sharePublicOrigin = DEFAULT_BASE_URL; void startShareServer().catch((error) => { console.error("[share-server] startup failed", error); @@ -192,6 +193,7 @@ async function startShareServer(): Promise { const port = parsePort(process.env.PORT); const host = parseBindHost(process.env.WEBBLACKBOX_SHARE_BIND_HOST); + sharePublicOrigin = resolvePublicOrigin(process.env.WEBBLACKBOX_SHARE_PUBLIC_ORIGIN, host, port); const server = createServer((request, response) => { void routeRequest(request, response).catch((error) => { console.warn("[share-server] request failed", error); @@ -211,13 +213,14 @@ async function startShareServer(): Promise { server.listen(port, host, () => { console.info(`[share-server] listening on http://${host}:${port}`); + console.info(`[share-server] public origin: ${sharePublicOrigin}`); console.info(`[share-server] data root: ${DATA_ROOT}`); }); } async function routeRequest(request: IncomingMessage, response: ServerResponse): Promise { - const requestUrl = new URL(request.url ?? "/", requestBaseUrl(request)); - applyCorsHeaders(response, request, requestUrl); + const requestUrl = new URL(request.url ?? "/", sharePublicOrigin); + applyCorsHeaders(response, request); if (request.method === "OPTIONS") { response.writeHead(204); @@ -261,7 +264,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): } if (method === "POST" && pathname === "/api/share/upload") { - await handleUpload(request, response, requestUrl); + await handleUpload(request, response); return; } @@ -303,11 +306,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): }); } -async function handleUpload( - request: IncomingMessage, - response: ServerResponse, - requestUrl: URL -): Promise { +async function handleUpload(request: IncomingMessage, response: ServerResponse): Promise { const rateLimited = consumeUploadRateLimitToken(resolveClientKey(request)); if (!rateLimited.ok) { @@ -328,7 +327,7 @@ async function handleUpload( } try { - await handleUploadWithinInspectionSlot(request, response, requestUrl); + await handleUploadWithinInspectionSlot(request, response); } finally { activeUploadInspections -= 1; } @@ -336,8 +335,7 @@ async function handleUpload( async function handleUploadWithinInspectionSlot( request: IncomingMessage, - response: ServerResponse, - requestUrl: URL + response: ServerResponse ): Promise { const id = randomUUID().replaceAll("-", ""); const upload = { @@ -348,7 +346,7 @@ async function handleUploadWithinInspectionSlot( }; try { - await processUploadWithinInspectionSlot(request, response, requestUrl, upload); + await processUploadWithinInspectionSlot(request, response, upload); } finally { if (!upload.committed) { await Promise.all([ @@ -362,7 +360,6 @@ async function handleUploadWithinInspectionSlot( async function processUploadWithinInspectionSlot( request: IncomingMessage, response: ServerResponse, - requestUrl: URL, upload: { id: string; tempPath: string; archivePath: string; committed: boolean } ): Promise { const { id, tempPath, archivePath } = upload; @@ -562,7 +559,7 @@ async function processUploadWithinInspectionSlot( const createdAt = Date.now(); const ttlMs = resolveShareTtlMs(request); - const shareUrl = `${requestOrigin(request, requestUrl)}/share/${id}`; + const shareUrl = `${sharePublicOrigin}/share/${id}`; const record: ShareRecord = { id, createdAt, @@ -1636,6 +1633,62 @@ function parseBindHost(rawValue: string | undefined): string { return trimmed.length > 0 ? trimmed : DEFAULT_HOST; } +function resolvePublicOrigin(rawValue: string | undefined, bindHost: string, port: number): string { + const configured = rawValue?.trim(); + + if (!configured) { + if (!isLoopbackHost(bindHost)) { + throw new Error( + "WEBBLACKBOX_SHARE_PUBLIC_ORIGIN is required when the share server binds to a non-loopback host." + ); + } + + return new URL(`http://${formatHostForUrl(bindHost)}:${port}`).origin; + } + + let parsed: URL; + try { + parsed = new URL(configured); + } catch { + throw new Error("WEBBLACKBOX_SHARE_PUBLIC_ORIGIN must be a valid absolute URL."); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("WEBBLACKBOX_SHARE_PUBLIC_ORIGIN must use HTTP or HTTPS."); + } + + if ( + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + throw new Error( + "WEBBLACKBOX_SHARE_PUBLIC_ORIGIN must contain only a scheme, host, and optional port." + ); + } + + if (parsed.protocol !== "https:" && !isLoopbackHost(parsed.hostname)) { + throw new Error("WEBBLACKBOX_SHARE_PUBLIC_ORIGIN must use HTTPS for non-loopback hosts."); + } + + return parsed.origin; +} + +function isLoopbackHost(host: string): boolean { + const normalized = host + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ""); + return normalized === "localhost" || normalized === "::1" || normalized.startsWith("127."); +} + +function formatHostForUrl(host: string): string { + const normalized = host.trim().replace(/^\[|\]$/g, ""); + return normalized.includes(":") ? `[${normalized}]` : normalized; +} + function normalizeAllowedOrigin(rawValue: string | undefined): string { const resolved = (rawValue ?? "same-origin").trim(); @@ -1843,37 +1896,15 @@ function readScannerStatus(value: unknown): "passed" | "blocked" | "unknown" { return value === "passed" || value === "blocked" || value === "unknown" ? value : "unknown"; } -function requestBaseUrl(request: IncomingMessage): string { - const host = request.headers.host; - return host && host.length > 0 ? `http://${host}` : DEFAULT_BASE_URL; -} - -function requestOrigin(request: IncomingMessage, requestUrl: URL): string { - const forwardedProto = request.headers["x-forwarded-proto"]; - const protocol = - typeof forwardedProto === "string" && forwardedProto.length > 0 - ? forwardedProto - : requestUrl.protocol.replace(":", ""); - const host = request.headers.host ?? requestUrl.host; - return `${protocol}://${host}`; -} - function publicArchiveFileName(id: string, rawName: string | undefined): string { const trimmed = rawName?.trim().toLowerCase() ?? ""; const extension = trimmed.endsWith(".zip") ? ".zip" : ".webblackbox"; return `webblackbox-share-${id.slice(0, 12)}${extension}`; } -function applyCorsHeaders( - response: ServerResponse, - request: IncomingMessage, - requestUrl: URL -): void { +function applyCorsHeaders(response: ServerResponse, request: IncomingMessage): void { const requestOrigin = request.headers.origin; - const allowOrigin = resolveAllowedOrigin( - requestOrigin, - requestOriginFromUrl(request, requestUrl) - ); + const allowOrigin = resolveAllowedOrigin(requestOrigin, sharePublicOrigin); if (allowOrigin) { response.setHeader("access-control-allow-origin", allowOrigin); @@ -2090,7 +2121,7 @@ function issueShareReadSessionCookie(response: ServerResponse, shareId: string): response.setHeader( "set-cookie", - `${SHARE_READ_SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeSeconds}` + `${SHARE_READ_SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAgeSeconds}${sharePublicOrigin.startsWith("https:") ? "; Secure" : ""}` ); } @@ -2135,10 +2166,6 @@ function readCookie(request: IncomingMessage, name: string): string | null { return null; } -function requestOriginFromUrl(request: IncomingMessage, requestUrl: URL): string { - return requestOrigin(request, requestUrl); -} - function isLoopbackRequest(request: IncomingMessage): boolean { const address = resolveClientAddress(request); return Boolean(address && isLoopbackAddress(address)); From 43be79d7aabda2b6b5aae08cd27615146727990e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:33:17 +0800 Subject: [PATCH 047/181] fix(mcp): bound filesystem scans and tool output --- apps/mcp-server/README.md | 3 +- apps/mcp-server/src/index.test.ts | 17 ++++- apps/mcp-server/src/index.ts | 29 ++++++++- apps/mcp-server/src/session-tools.test.ts | 23 +++++++ apps/mcp-server/src/session-tools.ts | 79 ++++++++++++++++++----- 5 files changed, 131 insertions(+), 20 deletions(-) diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 3eeb8d6..eaed108 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -90,8 +90,9 @@ node dist/cli.js --version ## Notes -- Archive paths are resolved from the current working directory if relative. +- Archive paths are resolved from the current working directory if relative and are limited to 4096 characters. Recursive archive discovery streams directory entries and fails closed after 10,000 entries, 1,000 directories, or five seconds; choose a narrower directory or disable recursion if a scan reaches a ceiling. - Archive size is checked against a 64 MiB MCP ceiling on one open file descriptor before bytes are read; the Player SDK then enforces ZIP expansion and event budgets. The production MCP server admits one archive-heavy tool call at a time; excess concurrent calls fail fast instead of multiplying retained Player memory. `compare_sessions` is one admitted operation and can hold its two bounded archives. +- Serialized tool responses are limited to 4 MiB of UTF-8 JSON. Narrow a time range or result count when an export would exceed that ceiling. - Encrypted archives require `passphrase`. - `query_events` defaults to payload-hidden output (`includeData=false`) to avoid huge responses. - Range-scoped tools (`monoStart` / `monoEnd`) preload only intersecting chunks when opening archives. diff --git a/apps/mcp-server/src/index.test.ts b/apps/mcp-server/src/index.test.ts index bd49244..92838f8 100644 --- a/apps/mcp-server/src/index.test.ts +++ b/apps/mcp-server/src/index.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from "vitest"; -import { SERVER_NAME, createServer, nowUtcIsoString } from "./index.js"; +import { + MAX_MCP_TEXT_PAYLOAD_BYTES, + McpOutputLimitError, + SERVER_NAME, + createServer, + nowUtcIsoString, + toTextPayload +} from "./index.js"; describe("mcp-server", () => { it("creates server instance", () => { @@ -11,4 +18,12 @@ describe("mcp-server", () => { expect(SERVER_NAME).toBe("webblackbox-mcp-server"); expect(nowUtcIsoString()).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); + + it("bounds serialized tool output by UTF-8 byte size", () => { + expect(toTextPayload({ status: "ok" }).content[0]?.text).toContain('"status": "ok"'); + + expect(() => + toTextPayload({ value: "\u754c".repeat(Math.ceil(MAX_MCP_TEXT_PAYLOAD_BYTES / 3)) }) + ).toThrow(McpOutputLimitError); + }); }); diff --git a/apps/mcp-server/src/index.ts b/apps/mcp-server/src/index.ts index ea4ee31..b35883b 100644 --- a/apps/mcp-server/src/index.ts +++ b/apps/mcp-server/src/index.ts @@ -29,6 +29,20 @@ export const SERVER_NAME = "webblackbox-mcp-server"; export const SERVER_VERSION = typeof __MCP_SERVER_VERSION__ !== "undefined" ? __MCP_SERVER_VERSION__ : "0.1.0"; export const nowUtcInput = {}; +export const MAX_MCP_TEXT_PAYLOAD_BYTES = 4 * 1024 * 1024; + +export class McpOutputLimitError extends Error { + public override readonly name = "McpOutputLimitError"; + + public constructor( + public readonly actualBytes: number, + public readonly maxBytes = MAX_MCP_TEXT_PAYLOAD_BYTES + ) { + super( + `MCP tool output exceeds the ${maxBytes}-byte limit (${actualBytes} bytes). Narrow the requested range or result count.` + ); + } +} export function nowUtcIsoString(): string { return new Date().toISOString(); @@ -316,12 +330,23 @@ export async function startServer(): Promise { await server.connect(transport); } -function toTextPayload(value: unknown): { content: Array<{ type: "text"; text: string }> } { +export function toTextPayload(value: unknown): { + content: Array<{ type: "text"; text: string }>; +} { + const serialized = JSON.stringify(value, null, 2); + if (serialized === undefined) { + throw new TypeError("MCP tool output must be JSON-serializable."); + } + const outputBytes = Buffer.byteLength(serialized, "utf8"); + if (outputBytes > MAX_MCP_TEXT_PAYLOAD_BYTES) { + throw new McpOutputLimitError(outputBytes); + } + return { content: [ { type: "text", - text: JSON.stringify(value, null, 2) + text: serialized } ] }; diff --git a/apps/mcp-server/src/session-tools.test.ts b/apps/mcp-server/src/session-tools.test.ts index 814b405..b5a72d4 100644 --- a/apps/mcp-server/src/session-tools.test.ts +++ b/apps/mcp-server/src/session-tools.test.ts @@ -7,12 +7,16 @@ import { afterEach, describe, expect, it } from "vitest"; import type { ExportManifest, WebBlackboxEvent } from "@webblackbox/protocol"; import { + MAX_MCP_PATH_CHARS, + MCP_ARCHIVE_SCAN_LIMITS, + assertArchiveScanBudget, compareSessions, exportHarFromArchive, findRootCauseCandidates, generateBugReportBundle, generatePlaywrightFromArchive, listArchives, + sessionSummaryInput, summarizeActions } from "./session-tools.js"; @@ -30,6 +34,25 @@ afterEach(async () => { }); describe("session tools", () => { + it("rejects oversized paths and bounded-scan exhaustion", async () => { + expect(sessionSummaryInput.path.safeParse("x".repeat(MAX_MCP_PATH_CHARS + 1)).success).toBe( + false + ); + await expect( + listArchives({ + dir: "x".repeat(MAX_MCP_PATH_CHARS + 1), + recursive: false + }) + ).rejects.toThrow(/path exceeds the MCP limit/i); + + expect(() => + assertArchiveScanBudget(Date.now(), MCP_ARCHIVE_SCAN_LIMITS.entries + 1, 1) + ).toThrow(/entry limit/i); + expect(() => + assertArchiveScanBudget(Date.now(), 1, MCP_ARCHIVE_SCAN_LIMITS.directories + 1) + ).toThrow(/directory limit/i); + }); + it("lists archive files and ignores non-archive files", async () => { const root = await mkdtemp(join(tmpdir(), "wb-mcp-list-")); tempDirs.push(root); diff --git a/apps/mcp-server/src/session-tools.ts b/apps/mcp-server/src/session-tools.ts index feb675e..feaa1a3 100644 --- a/apps/mcp-server/src/session-tools.ts +++ b/apps/mcp-server/src/session-tools.ts @@ -1,4 +1,4 @@ -import { open, readdir, stat } from "node:fs/promises"; +import { open, opendir, stat } from "node:fs/promises"; import { extname, resolve } from "node:path"; import { DEFAULT_ARCHIVE_RESOURCE_LIMITS, WebBlackboxPlayer } from "@webblackbox/player-sdk"; import { z } from "zod"; @@ -18,9 +18,19 @@ const MAX_DATA_PREVIEW_CHARS = 10_000; const DEFAULT_COMPARE_TOP = 15; const MAX_COMPARE_TOP = 100; const MAX_ARCHIVE_BYTES = Math.min(DEFAULT_ARCHIVE_RESOURCE_LIMITS.maxInputBytes, 64 * 1024 * 1024); +export const MAX_MCP_PATH_CHARS = 4_096; +export const MCP_ARCHIVE_SCAN_LIMITS = Object.freeze({ + entries: 10_000, + directories: 1_000, + durationMs: 5_000 +}); + +function archivePathSchema(description: string): z.ZodString { + return z.string().min(1).max(MAX_MCP_PATH_CHARS).describe(description); +} export const listArchivesInput = { - dir: z.string().min(1).optional().describe("Directory to scan. Defaults to current working dir."), + dir: archivePathSchema("Directory to scan. Defaults to current working dir.").optional(), recursive: z.boolean().optional().describe("Recursively scan subdirectories. Defaults to true."), limit: z .number() @@ -38,7 +48,7 @@ export type ListArchivesArgs = { }; export const sessionSummaryInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), slowRequestMs: z .number() @@ -64,7 +74,7 @@ export type SessionSummaryArgs = { }; export const queryEventsInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), text: z.string().min(1).max(512).optional().describe("Free-text query."), types: z.array(z.string().min(1).max(128)).max(100).optional().describe("Filter by event types."), @@ -113,7 +123,7 @@ export type QueryEventsArgs = { }; export const networkIssuesInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), minDurationMs: z .number() @@ -139,7 +149,7 @@ export type NetworkIssuesArgs = { }; export const generateBugReportInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), title: z.string().min(1).max(512).optional().describe("Optional report title."), maxItems: z @@ -173,7 +183,7 @@ export type GenerateBugReportArgs = { }; export const exportHarInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), monoStart: z.number().finite().optional().describe("Optional mono range start."), monoEnd: z.number().finite().optional().describe("Optional mono range end.") @@ -187,7 +197,7 @@ export type ExportHarArgs = { }; export const summarizeActionsInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), monoStart: z.number().finite().optional().describe("Optional mono range start."), monoEnd: z.number().finite().optional().describe("Optional mono range end."), @@ -209,7 +219,7 @@ export type SummarizeActionsArgs = { }; export const rootCauseCandidatesInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), monoStart: z.number().finite().optional().describe("Optional mono range start."), monoEnd: z.number().finite().optional().describe("Optional mono range end."), @@ -239,7 +249,7 @@ export type RootCauseCandidatesArgs = { }; export const generatePlaywrightInput = { - path: z.string().min(1).describe("Path to a .webblackbox or .zip archive."), + path: archivePathSchema("Path to a .webblackbox or .zip archive."), passphrase: z.string().min(1).max(4096).optional().describe("Passphrase for encrypted archives."), name: z.string().min(1).max(128).optional().describe("Playwright test name."), startUrl: z.string().min(1).max(2048).optional().describe("Optional navigation URL override."), @@ -270,8 +280,8 @@ export type GeneratePlaywrightArgs = { }; export const compareSessionsInput = { - leftPath: z.string().min(1).describe("Baseline archive path."), - rightPath: z.string().min(1).describe("Compared archive path."), + leftPath: archivePathSchema("Baseline archive path."), + rightPath: archivePathSchema("Compared archive path."), leftPassphrase: z .string() .min(1) @@ -1253,6 +1263,16 @@ async function openArchivePlayer( } function resolveArchivePath(pathLike: string): string { + if (pathLike.length === 0) { + throw new Error("Archive path must not be empty."); + } + if (pathLike.length > MAX_MCP_PATH_CHARS) { + throw new Error(`Archive path exceeds the MCP limit (${MAX_MCP_PATH_CHARS} characters).`); + } + if (pathLike.includes("\0")) { + throw new Error("Archive path must not contain NUL characters."); + } + return resolve(process.cwd(), pathLike); } @@ -1268,6 +1288,9 @@ async function collectArchiveFiles( }> > { const directories = [rootDir]; + const scanStartedAt = Date.now(); + let visitedEntries = 0; + let visitedDirectories = 0; const rows: Array<{ path: string; sizeBytes: number; @@ -1281,11 +1304,13 @@ async function collectArchiveFiles( break; } - const entries = await readdir(currentDir, { - withFileTypes: true - }); + visitedDirectories += 1; + assertArchiveScanBudget(scanStartedAt, visitedEntries, visitedDirectories); + const directory = await opendir(currentDir); - for (const entry of entries) { + for await (const entry of directory) { + visitedEntries += 1; + assertArchiveScanBudget(scanStartedAt, visitedEntries, visitedDirectories); const fullPath = resolve(currentDir, entry.name); if (entry.isDirectory()) { @@ -1317,6 +1342,28 @@ async function collectArchiveFiles( .slice(0, limit); } +export function assertArchiveScanBudget( + scanStartedAt: number, + visitedEntries: number, + visitedDirectories: number +): void { + if (visitedEntries > MCP_ARCHIVE_SCAN_LIMITS.entries) { + throw new Error( + `Archive scan exceeded the MCP entry limit (${MCP_ARCHIVE_SCAN_LIMITS.entries}). Choose a narrower directory or disable recursion.` + ); + } + if (visitedDirectories > MCP_ARCHIVE_SCAN_LIMITS.directories) { + throw new Error( + `Archive scan exceeded the MCP directory limit (${MCP_ARCHIVE_SCAN_LIMITS.directories}). Choose a narrower directory or disable recursion.` + ); + } + if (Date.now() - scanStartedAt > MCP_ARCHIVE_SCAN_LIMITS.durationMs) { + throw new Error( + `Archive scan exceeded the MCP time limit (${MCP_ARCHIVE_SCAN_LIMITS.durationMs} ms). Choose a narrower directory or disable recursion.` + ); + } +} + function isArchiveFile(path: string): boolean { const extension = extname(path).toLowerCase(); return ARCHIVE_EXTENSIONS.has(extension); From 06a88523c7aca816fcdce960bb1138a899fb2035 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:35:35 +0800 Subject: [PATCH 048/181] fix(player): reject ambiguous ZIP entry names --- .../src/archive-resource-limits.test.ts | 54 +++++++++++++++++++ .../player-sdk/src/archive-resource-limits.ts | 51 +++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/packages/player-sdk/src/archive-resource-limits.test.ts b/packages/player-sdk/src/archive-resource-limits.test.ts index 5e702d7..2d32f39 100644 --- a/packages/player-sdk/src/archive-resource-limits.test.ts +++ b/packages/player-sdk/src/archive-resource-limits.test.ts @@ -56,6 +56,32 @@ describe("archive resource limits", () => { ); }); + it("rejects duplicate or normalization-ambiguous physical ZIP entry names", async () => { + const duplicateZip = new JSZip(); + duplicateZip.file("entry-one.json", "first"); + duplicateZip.file("entry-two.json", "second"); + const duplicateBytes = await duplicateZip.generateAsync({ + type: "uint8array", + compression: "STORE" + }); + expect(replaceAsciiInPlace(duplicateBytes, "entry-two.json", "entry-one.json")).toBe(2); + + expect(() => + assertArchiveInputResourceLimits(duplicateBytes, resolveArchiveResourceLimits()) + ).toThrow(/duplicate ZIP entry 'entry-one\.json'/i); + + const ambiguousZip = new JSZip(); + ambiguousZip.file("safe/../manifest.json", "{}"); + const ambiguousBytes = await ambiguousZip.generateAsync({ + type: "uint8array", + compression: "STORE" + }); + + expect(() => + assertArchiveInputResourceLimits(ambiguousBytes, resolveArchiveResourceLimits()) + ).toThrow(/non-canonical ZIP entry name/i); + }); + it("rejects high compression ratios without inflating an entry", async () => { const zip = new JSZip(); zip.file("repetitive.txt", "a".repeat(512 * 1024)); @@ -136,3 +162,31 @@ describe("archive resource limits", () => { expect(() => resolveArchiveResourceLimits({ maxCompressionRatio: 0.5 })).toThrow(/at least 1/i); }); }); + +function replaceAsciiInPlace(bytes: Uint8Array, search: string, replacement: string): number { + const searchBytes = new TextEncoder().encode(search); + const replacementBytes = new TextEncoder().encode(replacement); + if (searchBytes.byteLength !== replacementBytes.byteLength) { + throw new Error("ZIP test replacement names must have the same byte length."); + } + + let replacements = 0; + for (let offset = 0; offset <= bytes.byteLength - searchBytes.byteLength; offset += 1) { + let matches = true; + for (let index = 0; index < searchBytes.byteLength; index += 1) { + if (bytes[offset + index] !== searchBytes[index]) { + matches = false; + break; + } + } + if (!matches) { + continue; + } + + bytes.set(replacementBytes, offset); + replacements += 1; + offset += searchBytes.byteLength - 1; + } + + return replacements; +} diff --git a/packages/player-sdk/src/archive-resource-limits.ts b/packages/player-sdk/src/archive-resource-limits.ts index ad2898d..e418484 100644 --- a/packages/player-sdk/src/archive-resource-limits.ts +++ b/packages/player-sdk/src/archive-resource-limits.ts @@ -342,6 +342,7 @@ function countPhysicalCentralDirectoryEntries( let count = 0; let offset = centralDirectoryOffset; + const names = new Set(); while (offset < centralDirectoryEnd) { if (offset + 4 > centralDirectoryEnd) { @@ -363,13 +364,61 @@ function countPhysicalCentralDirectoryEntries( const fileNameBytes = view.getUint16(offset + 28, true); const extraBytes = view.getUint16(offset + 30, true); const commentBytes = view.getUint16(offset + 32, true); - offset += 46 + fileNameBytes + extraBytes + commentBytes; + const fileNameOffset = offset + 46; + const nextOffset = fileNameOffset + fileNameBytes + extraBytes + commentBytes; + if (nextOffset > centralDirectoryEnd) { + return null; + } + + const fileName = readCanonicalArchiveEntryName(view, fileNameOffset, fileNameBytes); + if (names.has(fileName)) { + throw new Error(`Invalid WebBlackbox archive: duplicate ZIP entry '${fileName}'.`); + } + names.add(fileName); + + offset = nextOffset; count += 1; } return offset === centralDirectoryEnd ? count : null; } +function readCanonicalArchiveEntryName( + view: DataView, + fileNameOffset: number, + fileNameBytes: number +): string { + if (fileNameBytes === 0) { + throw new Error("Invalid WebBlackbox archive: ZIP entries must have a name."); + } + + let name = ""; + for (let index = 0; index < fileNameBytes; index += 1) { + const byte = view.getUint8(fileNameOffset + index); + if (byte < 0x20 || byte > 0x7e) { + throw new Error( + "Invalid WebBlackbox archive: ZIP entry names must use canonical printable ASCII." + ); + } + name += String.fromCharCode(byte); + } + + if (name.includes("\\") || name.startsWith("/") || name.includes("//")) { + throw new Error(`Invalid WebBlackbox archive: non-canonical ZIP entry name '${name}'.`); + } + + const path = name.endsWith("/") ? name.slice(0, -1) : name; + const segments = path.split("/"); + if ( + path.length === 0 || + segments.some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`Invalid WebBlackbox archive: non-canonical ZIP entry name '${name}'.`); + } + + return name; +} + function readZip64CentralDirectory( view: DataView, eocdOffset: number From b31c2719a10f99370f32fe7a2d334e20b28bf213 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:39:31 +0800 Subject: [PATCH 049/181] fix(share): scan full private entries for plaintext --- apps/share-server/README.md | 2 +- apps/share-server/src/index.test.ts | 70 ++++++++- apps/share-server/src/index.ts | 225 +++++++++++++--------------- 3 files changed, 171 insertions(+), 126 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 70f83ca..aab34af 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -74,7 +74,7 @@ Body: - Raw encrypted `.webblackbox` bytes. Public deployments do not accept plaintext uploads by default. - Outer ZIP entry count, actual per-entry inflater output, metadata bytes, total expanded bytes, and compression ratio are checked against non-relaxable safety ceilings before the upload is retained. Plaintext uploads that opt into server analysis additionally receive Player event-codec, event-count, and index-cardinality checks. The server has no key for encrypted inner files, so clients must apply those inner checks before encryption and upload. The current client preflight summary is not cryptographically bound to the encrypted plaintext; signed or remotely attested preflight remains a separate deployment concern. -- Encrypted uploads must include encryption metadata for every private archive path: `events/*`, `blobs/*`, `index/time.json`, `index/req.json`, `index/inv.json`, and `privacy/manifest.json` when present. Legacy encrypted archives that left private indexes in plaintext must be re-exported with the current exporter before public share upload. +- Encrypted uploads must include encryption metadata for every private archive path: `events/*`, `blobs/*`, `index/time.json`, `index/req.json`, `index/inv.json`, and `privacy/manifest.json` when present. The server scans the full bounded contents of those entries for recognizable plaintext formats rather than trusting a fixed prefix sample. This is a rejection heuristic, not cryptographic proof that opaque bytes are ciphertext. Legacy encrypted archives that left private indexes in plaintext must be re-exported with the current exporter before public share upload. Response: diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 094e77d..0202e87 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -419,6 +419,33 @@ describe("share-server", () => { expect(payload.plaintextEncryptedPaths).toContain(TEXT_BLOB_FIXTURE_PATH); }); + it("scans complete private entries for plaintext beyond the former prefix window", async () => { + const server = await startShareServer(); + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + }, + body: Buffer.from( + await createEncryptedEnvelopeArchive({ + privateFileMode: "plaintext", + textBlobFileMode: "plaintext", + plaintextPrefixBytes: 64 * 1024 + 1 + }) + ) + }); + const payload = (await response.json()) as { + plaintextEncryptedPaths: string[]; + }; + + expect(response.status).toBe(400); + expect(payload.plaintextEncryptedPaths).toEqual( + expect.arrayContaining(["index/time.json", TEXT_BLOB_FIXTURE_PATH]) + ); + }); + it("expires shares and blocks archive download after ttl", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000" @@ -910,6 +937,7 @@ async function createEncryptedEnvelopeArchive( privateFileMode?: "ciphertext" | "plaintext"; blobFileMode?: "ciphertext" | "plaintext"; textBlobFileMode?: "ciphertext" | "plaintext"; + plaintextPrefixBytes?: number; } = {} ): Promise { return createEnvelopeArchive(true, options); @@ -945,6 +973,7 @@ async function createEnvelopeArchive( privateFileMode?: "ciphertext" | "plaintext"; blobFileMode?: "ciphertext" | "plaintext"; textBlobFileMode?: "ciphertext" | "plaintext"; + plaintextPrefixBytes?: number; } = {} ): Promise { const zip = new JSZip(); @@ -998,16 +1027,41 @@ async function createEnvelopeArchive( }; addJsonFile(zip, files, "manifest.json", manifest); - addPrivateFile(zip, files, "index/time.json", [], encrypted, options.privateFileMode); - addPrivateFile(zip, files, "index/req.json", [], encrypted, options.privateFileMode); - addPrivateFile(zip, files, "index/inv.json", [], encrypted, options.privateFileMode); + addPrivateFile( + zip, + files, + "index/time.json", + [], + encrypted, + options.privateFileMode, + options.plaintextPrefixBytes + ); + addPrivateFile( + zip, + files, + "index/req.json", + [], + encrypted, + options.privateFileMode, + options.plaintextPrefixBytes + ); + addPrivateFile( + zip, + files, + "index/inv.json", + [], + encrypted, + options.privateFileMode, + options.plaintextPrefixBytes + ); addPrivateFile( zip, files, BLOB_FIXTURE_PATH, { token: "blob-secret-token" }, encrypted, - options.blobFileMode + options.blobFileMode, + options.plaintextPrefixBytes ); addPrivateFile( zip, @@ -1015,7 +1069,8 @@ async function createEnvelopeArchive( TEXT_BLOB_FIXTURE_PATH, "plain-text-secret-token", encrypted, - options.textBlobFileMode + options.textBlobFileMode, + options.plaintextPrefixBytes ); addJsonFile(zip, files, "integrity/hashes.json", { manifestSha256: files["manifest.json"], @@ -1045,13 +1100,14 @@ function addPrivateFile( path: string, value: unknown, encrypted: boolean, - mode: "ciphertext" | "plaintext" = "ciphertext" + mode: "ciphertext" | "plaintext" = "ciphertext", + plaintextPrefixBytes = 0 ): void { const content = Buffer.from(JSON.stringify(value)); const bytes = encrypted && mode === "ciphertext" ? randomBytes(Math.max(32, content.byteLength + 16)) - : content; + : Buffer.concat([Buffer.alloc(plaintextPrefixBytes, 0x20), content]); zip.file(path, bytes); files[path] = createHash("sha256").update(bytes).digest("hex"); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 15aced3..fea31dd 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -169,7 +169,6 @@ const UPLOAD_RATE_LIMIT_WINDOW_MS = parseRateLimitWindowMs( ); const SHARE_SUMMARY_HEADER = "x-webblackbox-share-summary"; const MAX_SHARE_SUMMARY_HEADER_BYTES = 16 * 1024; -const PLAINTEXT_INSPECTION_SAMPLE_BYTES = 64 * 1024; const AES_GCM_IV_BYTES = 12; const SHARE_READ_SESSION_COOKIE = "webblackbox_share_read"; const SHARE_READ_SESSION_TTL_MS = 10 * 60 * 1000; @@ -1056,188 +1055,178 @@ async function inspectActualArchiveEntries( } function looksLikePlaintextPrivateArchiveFile(path: string, bytes: Uint8Array): boolean { - const truncated = bytes.byteLength > PLAINTEXT_INSPECTION_SAMPLE_BYTES; - const sample = truncated ? bytes.subarray(0, PLAINTEXT_INSPECTION_SAMPLE_BYTES) : bytes; - if (path === "index/time.json" || path === "index/req.json" || path === "index/inv.json") { - return isPlainJsonBytes(sample, truncated); + return isPlainJsonBytes(bytes); } if (path === "privacy/manifest.json") { - return isPlainJsonBytes(sample, truncated); + return isPlainJsonBytes(bytes); } if (path.startsWith("events/") && path.endsWith(".ndjson")) { - return isPlainNdjsonBytes(sample, truncated); + return isPlainNdjsonBytes(bytes); } if (path.startsWith("blobs/")) { - return looksLikePlaintextBlobFile(path, sample, truncated); + return looksLikePlaintextBlobFile(path, bytes); } return false; } -function looksLikePlaintextBlobFile(path: string, bytes: Uint8Array, truncated: boolean): boolean { +function looksLikePlaintextBlobFile(path: string, bytes: Uint8Array): boolean { const normalizedPath = path.toLowerCase(); if (normalizedPath.endsWith(".json")) { - return isPlainJsonBytes(bytes, truncated); + return isPlainJsonBytes(bytes); } if (normalizedPath.endsWith(".html")) { - return isPlainHtmlBytes(bytes, truncated); + return isPlainHtmlBytes(bytes); } if (normalizedPath.endsWith(".png")) { - return hasPngSignature(bytes); + return hasPngSignature(bytes, firstNonWhitespaceByte(bytes)); } if (normalizedPath.endsWith(".webp")) { - return hasWebpSignature(bytes); + return hasWebpSignature(bytes, firstNonWhitespaceByte(bytes)); } + return isPlainTextBytes(bytes); +} + +function isPlainJsonBytes(bytes: Uint8Array): boolean { + const start = firstNonWhitespaceByte(bytes); return ( - isPlainJsonBytes(bytes, truncated) || - isPlainHtmlBytes(bytes, truncated) || - isPlainTextBytes(bytes, truncated) + start < bytes.byteLength && + (bytes[start] === 0x7b || bytes[start] === 0x5b) && + isMostlyPrintableUtf8Bytes(bytes) ); } -function isPlainJsonBytes(bytes: Uint8Array, truncated = false): boolean { - const text = decodeUtf8Strict(bytes, truncated); - if (!text) { - return false; - } - - const trimmed = text.trim(); - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { - return false; - } - - if (truncated) { - return isMostlyPrintableText(trimmed); - } +function isPlainNdjsonBytes(bytes: Uint8Array): boolean { + const start = firstNonWhitespaceByte(bytes); + return start < bytes.byteLength && bytes[start] === 0x7b && isMostlyPrintableUtf8Bytes(bytes); +} - try { - JSON.parse(trimmed); - return true; - } catch { - return false; - } +function isPlainHtmlBytes(bytes: Uint8Array): boolean { + const start = firstNonWhitespaceByte(bytes); + return ( + isMostlyPrintableUtf8Bytes(bytes) && + (startsWithAsciiCaseInsensitive(bytes, start, " line.trim().length > 0); - if (lines.length === 0) { - return false; - } +function isMostlyPrintableUtf8Bytes(bytes: Uint8Array): boolean { + const decoder = new TextDecoder("utf-8", { fatal: true }); + const chunkBytes = 64 * 1024; + let characters = 0; + let printable = 0; try { - for (const line of lines.slice(0, 32)) { - JSON.parse(line); + for (let offset = 0; offset < bytes.byteLength; offset += chunkBytes) { + const text = decoder.decode(bytes.subarray(offset, offset + chunkBytes), { stream: true }); + for (let index = 0; index < text.length; index += 1) { + const code = text.charCodeAt(index); + characters += 1; + if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code !== 0x7f)) { + printable += 1; + } + } } - return true; - } catch { - return truncated && text.trimStart().startsWith("{") && isMostlyPrintableText(text); - } -} -function isPlainHtmlBytes(bytes: Uint8Array, truncated = false): boolean { - const text = decodeUtf8Strict(bytes, truncated); - if (!text) { + const trailing = decoder.decode(); + for (let index = 0; index < trailing.length; index += 1) { + const code = trailing.charCodeAt(index); + characters += 1; + if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code !== 0x7f)) { + printable += 1; + } + } + } catch { return false; } - const trimmed = text.trim().toLowerCase(); - return ( - trimmed.startsWith(" 0 && printable / characters >= 0.9; } -function isPlainTextBytes(bytes: Uint8Array, truncated = false): boolean { - const text = decodeUtf8Strict(bytes, truncated); - if (!text) { - return false; +function firstNonWhitespaceByte(bytes: Uint8Array): number { + let offset = 0; + while (offset < bytes.byteLength) { + const byte = bytes[offset]; + if (byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && byte !== 0x20) { + break; + } + offset += 1; } + return offset; +} - const trimmed = text.trim(); - if (trimmed.length === 0) { +function startsWithAsciiCaseInsensitive( + bytes: Uint8Array, + offset: number, + expected: string +): boolean { + if (offset + expected.length > bytes.byteLength) { return false; } - - return isMostlyPrintableText(trimmed); + for (let index = 0; index < expected.length; index += 1) { + if (toLowerAscii(bytes[offset + index] ?? -1) !== expected.charCodeAt(index)) { + return false; + } + } + return true; } -function isMostlyPrintableText(value: string): boolean { - let printable = 0; - - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - - if (code === 0x09 || code === 0x0a || code === 0x0d || (code >= 0x20 && code !== 0x7f)) { - printable += 1; +function includesAsciiCaseInsensitive(bytes: Uint8Array, expected: string): boolean { + for (let offset = 0; offset <= bytes.byteLength - expected.length; offset += 1) { + if (startsWithAsciiCaseInsensitive(bytes, offset, expected)) { + return true; } } + return false; +} - return value.length > 0 && printable / value.length >= 0.9; +function toLowerAscii(byte: number): number { + return byte >= 0x41 && byte <= 0x5a ? byte + 0x20 : byte; } -function hasPngSignature(bytes: Uint8Array): boolean { +function hasPngSignature(bytes: Uint8Array, offset = 0): boolean { return ( - bytes.length >= 8 && - bytes[0] === 0x89 && - bytes[1] === 0x50 && - bytes[2] === 0x4e && - bytes[3] === 0x47 && - bytes[4] === 0x0d && - bytes[5] === 0x0a && - bytes[6] === 0x1a && - bytes[7] === 0x0a + bytes.length - offset >= 8 && + bytes[offset] === 0x89 && + bytes[offset + 1] === 0x50 && + bytes[offset + 2] === 0x4e && + bytes[offset + 3] === 0x47 && + bytes[offset + 4] === 0x0d && + bytes[offset + 5] === 0x0a && + bytes[offset + 6] === 0x1a && + bytes[offset + 7] === 0x0a ); } -function hasWebpSignature(bytes: Uint8Array): boolean { +function hasWebpSignature(bytes: Uint8Array, offset = 0): boolean { return ( - bytes.length >= 12 && - bytes[0] === 0x52 && - bytes[1] === 0x49 && - bytes[2] === 0x46 && - bytes[3] === 0x46 && - bytes[8] === 0x57 && - bytes[9] === 0x45 && - bytes[10] === 0x42 && - bytes[11] === 0x50 + bytes.length - offset >= 12 && + bytes[offset] === 0x52 && + bytes[offset + 1] === 0x49 && + bytes[offset + 2] === 0x46 && + bytes[offset + 3] === 0x46 && + bytes[offset + 8] === 0x57 && + bytes[offset + 9] === 0x45 && + bytes[offset + 10] === 0x42 && + bytes[offset + 11] === 0x50 ); } -function decodeUtf8Strict(bytes: Uint8Array, truncated = false): string | null { - const maxTrim = truncated ? Math.min(3, bytes.byteLength) : 0; - - for (let trim = 0; trim <= maxTrim; trim += 1) { - try { - return new TextDecoder("utf-8", { fatal: true }).decode( - trim === 0 ? bytes : bytes.subarray(0, bytes.byteLength - trim) - ); - } catch { - // A bounded UTF-8 prefix can end in the middle of one multi-byte code point. - } - } - - return null; -} - function decodeBase64Strict(value: string): Buffer | null { if (!/^[A-Za-z0-9+/]+={0,2}$/.test(value)) { return null; From b772187bd76fda748c35c120151318f775edbfe9 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:43:40 +0800 Subject: [PATCH 050/181] fix(share): bind and label client privacy claims --- apps/player/README.md | 1 + apps/player/src/lib/hash.test.ts | 7 +- apps/player/src/lib/hash.ts | 14 +- apps/player/src/lib/i18n.ts | 13 +- apps/player/src/lib/share-privacy.test.ts | 13 + apps/player/src/lib/share-privacy.ts | 10 + apps/player/src/main.ts | 51 ++- apps/share-server/README.md | 32 +- apps/share-server/package.json | 1 + apps/share-server/scripts/e2e-share-flow.mjs | 37 +- apps/share-server/src/index.test.ts | 328 +++++++++++++-- apps/share-server/src/index.ts | 401 +++++++++++++++---- docs/PRIVACY.md | 2 +- docs/SECURITY.md | 6 +- pnpm-lock.yaml | 3 + 15 files changed, 763 insertions(+), 156 deletions(-) create mode 100644 apps/player/src/lib/share-privacy.test.ts create mode 100644 apps/player/src/lib/share-privacy.ts diff --git a/apps/player/README.md b/apps/player/README.md index a5461de..3b7612a 100644 --- a/apps/player/README.md +++ b/apps/player/README.md @@ -139,6 +139,7 @@ pnpm player:pages:deploy - Jira issue templates - HAR export - Share upload and link-based reload via `@webblackbox/share-server` +- Share upload stays disabled unless the archive carries a passed pre-encryption scanner result. The Player binds its allowlisted public privacy claim to the exact uploaded bytes with SHA-256; the Share server exposes that claim as client-unverified, not as server attestation. - Share API keys are retained only in page memory after a successful request; they are never persisted to `localStorage`, and legacy persisted keys are removed on startup ### Session Comparison diff --git a/apps/player/src/lib/hash.test.ts b/apps/player/src/lib/hash.test.ts index f1de820..b5e7240 100644 --- a/apps/player/src/lib/hash.test.ts +++ b/apps/player/src/lib/hash.test.ts @@ -1,10 +1,15 @@ import { describe, expect, it } from "vitest"; -import { sha256HexFromText } from "./hash.js"; +import { sha256HexFromBytes, sha256HexFromText } from "./hash.js"; describe("sha256HexFromText", () => { it("returns deterministic sha256 hash for text input", async () => { const hash = await sha256HexFromText("abc"); expect(hash).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); }); + + it("returns deterministic sha256 hash for archive bytes", async () => { + const hash = await sha256HexFromBytes(new Uint8Array([0x61, 0x62, 0x63])); + expect(hash).toBe("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); + }); }); diff --git a/apps/player/src/lib/hash.ts b/apps/player/src/lib/hash.ts index 63b6ede..aa89f3e 100644 --- a/apps/player/src/lib/hash.ts +++ b/apps/player/src/lib/hash.ts @@ -11,7 +11,19 @@ export async function sha256HexFromText(value: string): Promise { try { const bytes = new TextEncoder().encode(value); - const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes); + return await sha256HexFromBytes(bytes); + } catch { + return null; + } +} + +export async function sha256HexFromBytes(value: BufferSource): Promise { + if (typeof globalThis.crypto === "undefined" || !globalThis.crypto?.subtle) { + return null; + } + + try { + const digest = await globalThis.crypto.subtle.digest("SHA-256", value); return toHex(new Uint8Array(digest)); } catch { return null; diff --git a/apps/player/src/lib/i18n.ts b/apps/player/src/lib/i18n.ts index 57c67ef..cacb32c 100644 --- a/apps/player/src/lib/i18n.ts +++ b/apps/player/src/lib/i18n.ts @@ -268,7 +268,9 @@ type PlayerMessages = { feedbackPlaywrightMocksExported: string; feedbackLoadArchiveBeforeSharing: string; feedbackInvalidShareServerUrl: string; + feedbackSharePreflightRequired: string; feedbackShareUploadProgress: string; + feedbackShareDigestUnavailable: string; feedbackShareMissingUrl: string; feedbackShareSucceeded: string; feedbackShareFailed: string; @@ -411,7 +413,7 @@ const PLAYER_MESSAGES: Record = { sharePlaceholderApiKeyRequired: "Required when server auth is enabled", sharePrivacyPreflightTitle: "Privacy Preflight", sharePrivacyPreflightDescription: - "Review redaction coverage and detected sensitive signals before uploading.", + "Review redaction coverage and detected sensitive signals before uploading. Sharing requires a passed pre-encryption scanner result.", sharePrivacyRedactionProfile: "Redaction profile", sharePrivacyDetectedSignals: "Detected signals", sharePrivacySensitivePreview: "Sensitive preview", @@ -558,8 +560,12 @@ const PLAYER_MESSAGES: Record = { feedbackPlaywrightMocksExported: "Playwright mock script exported.", feedbackLoadArchiveBeforeSharing: "Load an archive before sharing.", feedbackInvalidShareServerUrl: "Invalid share server URL.", + feedbackSharePreflightRequired: + "Sharing requires a passed pre-encryption privacy scanner result.", feedbackShareUploadProgress: "Uploading share archive... {percent}% ({loadedBytes} / {totalBytes})", + feedbackShareDigestUnavailable: + "Unable to bind the privacy summary to the archive because SHA-256 is unavailable.", feedbackShareMissingUrl: "Share server did not return a share URL.", feedbackShareSucceeded: "Shared archive. URL copied: {shareUrl}", feedbackShareFailed: "Share failed: {error}", @@ -730,7 +736,8 @@ const PLAYER_MESSAGES: Record = { sharePlaceholderServerUrl: "https://share.example.com", sharePlaceholderApiKeyRequired: "当服务启用鉴权时必填", sharePrivacyPreflightTitle: "隐私预检", - sharePrivacyPreflightDescription: "上传前审核脱敏覆盖范围和检测到的敏感信号。", + sharePrivacyPreflightDescription: + "上传前审核脱敏覆盖范围和检测到的敏感信号;仅预加密扫描通过的归档可从播放器分享。", sharePrivacyRedactionProfile: "脱敏配置", sharePrivacyDetectedSignals: "检测信号", sharePrivacySensitivePreview: "敏感预览", @@ -874,7 +881,9 @@ const PLAYER_MESSAGES: Record = { feedbackPlaywrightMocksExported: "已导出 Playwright Mock 脚本。", feedbackLoadArchiveBeforeSharing: "请先加载归档,再执行分享。", feedbackInvalidShareServerUrl: "分享服务 URL 无效。", + feedbackSharePreflightRequired: "分享前必须有通过的预加密隐私扫描结果。", feedbackShareUploadProgress: "正在上传分享归档... {percent}%({loadedBytes} / {totalBytes})", + feedbackShareDigestUnavailable: "当前环境不支持 SHA-256,无法将隐私摘要绑定到归档。", feedbackShareMissingUrl: "分享服务未返回分享 URL。", feedbackShareSucceeded: "分享归档已完成,链接已复制:{shareUrl}", feedbackShareFailed: "分享失败:{error}", diff --git a/apps/player/src/lib/share-privacy.test.ts b/apps/player/src/lib/share-privacy.test.ts new file mode 100644 index 0000000..119134d --- /dev/null +++ b/apps/player/src/lib/share-privacy.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { hasPassedSharePrivacyPreflight } from "./share-privacy.js"; + +describe("hasPassedSharePrivacyPreflight", () => { + it("accepts only an explicitly passed pre-encryption scan", () => { + expect(hasPassedSharePrivacyPreflight({ preEncryption: true, status: "passed" })).toBe(true); + expect(hasPassedSharePrivacyPreflight({ preEncryption: false, status: "passed" })).toBe(false); + expect(hasPassedSharePrivacyPreflight({ preEncryption: true, status: "blocked" })).toBe(false); + expect(hasPassedSharePrivacyPreflight({ preEncryption: true, status: "unknown" })).toBe(false); + expect(hasPassedSharePrivacyPreflight(undefined)).toBe(false); + }); +}); diff --git a/apps/player/src/lib/share-privacy.ts b/apps/player/src/lib/share-privacy.ts new file mode 100644 index 0000000..d7a86c0 --- /dev/null +++ b/apps/player/src/lib/share-privacy.ts @@ -0,0 +1,10 @@ +export type SharePrivacyScannerSummary = { + preEncryption: boolean; + status: string; +}; + +export function hasPassedSharePrivacyPreflight( + scanner: SharePrivacyScannerSummary | null | undefined +): boolean { + return scanner?.preEncryption === true && scanner.status === "passed"; +} diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 00737ca..607a7bb 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -29,7 +29,7 @@ import { } from "./lib/i18n.js"; import { describeRequestName, resolveNetworkInitiator } from "./lib/network-labels.js"; import { clamp, readPointerRatio } from "./lib/math.js"; -import { sha256HexFromText } from "./lib/hash.js"; +import { sha256HexFromBytes, sha256HexFromText } from "./lib/hash.js"; import { formatByteSize, formatNetworkSize, sumNetworkTransferBytes } from "./lib/network-size.js"; import { applyNetworkViewFilters, @@ -69,6 +69,7 @@ import { getShareServerApiKeyForBaseUrl, setShareServerApiKeyForBaseUrl } from "./lib/share-api-key.js"; +import { hasPassedSharePrivacyPreflight } from "./lib/share-privacy.js"; import { normalizeShareServerBaseUrl, resolveShareArchiveRequest } from "./lib/share.js"; import { readScreenshotContext, @@ -316,8 +317,9 @@ type PlayerState = { }; type PublicShareSummary = { - schemaVersion: 1; + schemaVersion: 2; source: "client"; + archiveSha256: string; analyzed: boolean; encrypted: boolean; manifest: { @@ -1891,6 +1893,11 @@ async function shareLoadedArchive(): Promise { return; } + if (!hasPassedSharePrivacyPreflight(player.archive.privacyManifest?.scanner)) { + setFeedback(i18n.messages.feedbackSharePreflightRequired); + return; + } + const normalizedBaseUrl = normalizeShareServerBaseUrl(shareConfig.baseUrl); if (!normalizedBaseUrl) { @@ -1901,23 +1908,31 @@ async function shareLoadedArchive(): Promise { state.shareServerBaseUrl = normalizedBaseUrl; writeStoredText(SHARE_SERVER_BASE_URL_STORAGE_KEY, normalizedBaseUrl); - const headers: Record = { - "content-type": "application/octet-stream", - "x-webblackbox-filename": state.loadedArchiveName ?? "session.webblackbox", - [SHARE_SUMMARY_HEADER]: encodeShareSummaryHeader(buildClientShareSummary(player)) - }; + try { + const archiveBody = toArrayBuffer(bytes); + const archiveSha256 = await sha256HexFromBytes(archiveBody); + if (!archiveSha256) { + throw new Error(i18n.messages.feedbackShareDigestUnavailable); + } - if (shareConfig.apiKey.length > 0) { - headers["x-webblackbox-api-key"] = shareConfig.apiKey; - } + const headers: Record = { + "content-type": "application/octet-stream", + "x-webblackbox-filename": state.loadedArchiveName ?? "session.webblackbox", + [SHARE_SUMMARY_HEADER]: encodeShareSummaryHeader( + buildClientShareSummary(player, archiveSha256) + ) + }; + + if (shareConfig.apiKey.length > 0) { + headers["x-webblackbox-api-key"] = shareConfig.apiKey; + } - try { const totalBytes = bytes.byteLength; let lastProgressUpdate = 0; const payload = (await uploadArchiveWithProgress( `${normalizedBaseUrl}/api/share/upload`, headers, - toArrayBuffer(bytes), + archiveBody, (loadedBytes, uploadTotalBytes) => { const targetTotal = uploadTotalBytes && uploadTotalBytes > 0 ? uploadTotalBytes : totalBytes; @@ -2151,17 +2166,23 @@ function renderSharePrivacyPreflight(): void { } function updateShareUploadConfirmState(): void { - refs.shareUploadConfirm.disabled = !refs.shareUploadPrivacyReviewed.checked; + const scanner = state.player?.archive.privacyManifest?.scanner; + const scannerPassed = hasPassedSharePrivacyPreflight(scanner); + refs.shareUploadConfirm.disabled = !refs.shareUploadPrivacyReviewed.checked || !scannerPassed; } -function buildClientShareSummary(player: WebBlackboxPlayer): PublicShareSummary { +function buildClientShareSummary( + player: WebBlackboxPlayer, + archiveSha256: string +): PublicShareSummary { const manifest = player.archive.manifest; const derived = player.buildDerived(); const privacyReport = player.getPrivacyProtectionReport(); return { - schemaVersion: 1, + schemaVersion: 2, source: "client", + archiveSha256, analyzed: true, encrypted: Boolean(manifest.encryption), manifest: { diff --git a/apps/share-server/README.md b/apps/share-server/README.md index aab34af..fcc6782 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -66,15 +66,19 @@ Headers: - `content-type: application/octet-stream` - `x-webblackbox-filename: ` -- `x-webblackbox-share-summary: ` +- `x-webblackbox-share-summary: ` - `x-webblackbox-share-ttl-ms: ` - `x-webblackbox-api-key: ` Body: - Raw encrypted `.webblackbox` bytes. Public deployments do not accept plaintext uploads by default. -- Outer ZIP entry count, actual per-entry inflater output, metadata bytes, total expanded bytes, and compression ratio are checked against non-relaxable safety ceilings before the upload is retained. Plaintext uploads that opt into server analysis additionally receive Player event-codec, event-count, and index-cardinality checks. The server has no key for encrypted inner files, so clients must apply those inner checks before encryption and upload. The current client preflight summary is not cryptographically bound to the encrypted plaintext; signed or remotely attested preflight remains a separate deployment concern. -- Encrypted uploads must include encryption metadata for every private archive path: `events/*`, `blobs/*`, `index/time.json`, `index/req.json`, `index/inv.json`, and `privacy/manifest.json` when present. The server scans the full bounded contents of those entries for recognizable plaintext formats rather than trusting a fixed prefix sample. This is a rejection heuristic, not cryptographic proof that opaque bytes are ciphertext. Legacy encrypted archives that left private indexes in plaintext must be re-exported with the current exporter before public share upload. +- Outer ZIP entry count, actual per-entry inflater output, metadata bytes, total expanded bytes, compression ratio, manifest shape, encryption map coverage, and obvious plaintext private files are checked against non-relaxable safety ceilings before the upload is retained. Plaintext uploads that opt into server analysis additionally receive Player event-codec, event-count, and index-cardinality checks. +- Encrypted uploads must include encryption metadata for every private archive path: `events/*`, `blobs/*`, `index/time.json`, `index/req.json`, `index/inv.json`, and `privacy/manifest.json` when present. The server scans the full bounded contents of those entries for recognizable plaintext formats. This is a rejection heuristic, not cryptographic proof that opaque bytes are ciphertext. Legacy encrypted archives that left private indexes in plaintext must be re-exported with the current exporter before public share upload. + +When the optional client claim is sent, it must use `schemaVersion: 2`, include the SHA-256 of the exact archive bytes in `archiveSha256`, and agree with server-inspected outer manifest facts. A digest mismatch, a conflicting encryption/manifest claim, or any legacy `x-webblackbox-encryption`, `x-webblackbox-policy-eligible`, or `x-webblackbox-redaction-summary` header is rejected. The digest prevents a summary for one archive from being attached to another; it does **not** authenticate the uploader or prove that the claimed pre-encryption scan ran honestly. + +The server never receives the passphrase, so it cannot inspect encrypted private content. Bound client fields are isolated under `clientClaim` and returned as `source: "client-unverified"`, `analyzed: false`, with `trust.privateContent: "client-claim-unverified"`; verified server analysis uses the top-level `privacy` field instead. A missing client claim is accepted only as an encrypted envelope and is returned as `source: "unavailable"`. Neither encrypted state is privacy attestation. Legacy stored client summaries are downgraded to the same unverified shape without claiming a digest match. Deployments that require attestation must add a trusted signer or remote pre-encryption scanner and verify its signature outside this protocol. Response: @@ -86,10 +90,24 @@ Response: "fileName": "webblackbox-share-abc123.webblackbox", "sizeBytes": 123456, "summary": { - "schemaVersion": 1, - "source": "client", - "analyzed": true, - "encrypted": true + "schemaVersion": 2, + "source": "client-unverified", + "analyzed": false, + "encrypted": true, + "trust": { + "archiveEnvelope": "server-inspected", + "privateContent": "client-claim-unverified", + "archiveDigestMatched": true + }, + "clientClaim": { + "privacy": { + "scanner": { + "preEncryption": true, + "status": "passed", + "findingCount": 0 + } + } + } } } ``` diff --git a/apps/share-server/package.json b/apps/share-server/package.json index f171205..22d676c 100644 --- a/apps/share-server/package.json +++ b/apps/share-server/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@webblackbox/player-sdk": "workspace:*", + "@webblackbox/protocol": "workspace:*", "jszip": "3.10.1" } } diff --git a/apps/share-server/scripts/e2e-share-flow.mjs b/apps/share-server/scripts/e2e-share-flow.mjs index 712ae87..55dabd7 100644 --- a/apps/share-server/scripts/e2e-share-flow.mjs +++ b/apps/share-server/scripts/e2e-share-flow.mjs @@ -54,18 +54,36 @@ async function main() { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, "x-webblackbox-filename": "share-flow.webblackbox", - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, body: archive }); assert(typeof upload.shareId === "string", "Upload did not return shareId", upload); assert(upload.summary?.encrypted === true, "Upload summary is not encrypted", upload); - assert(upload.summary?.privacy, "Upload summary missing privacy preflight", upload); assert( - upload.summary?.privacy?.scanner?.preEncryption === true && - upload.summary?.privacy?.scanner?.status === "passed", - "Upload summary missing passed privacy scanner status", + upload.summary?.source === "client-unverified" && + upload.summary?.trust?.privateContent === "client-claim-unverified" && + upload.summary?.trust?.archiveDigestMatched === true, + "Upload summary does not expose the client-claim trust boundary", + upload + ); + assert( + upload.summary?.privacy === undefined, + "Unverified client privacy fields leaked into the verified summary namespace", + upload + ); + assert( + upload.summary?.clientClaim?.privacy, + "Upload summary missing client privacy claim", + upload + ); + assert( + upload.summary?.clientClaim?.privacy?.scanner?.preEncryption === true && + upload.summary?.clientClaim?.privacy?.scanner?.status === "passed", + "Upload summary missing passed client privacy scanner claim", upload ); @@ -76,8 +94,8 @@ async function main() { }); assert( meta.summary?.encrypted === true && - meta.summary?.privacy?.redaction?.headerRuleCount >= 1 && - meta.summary?.privacy?.detected?.redactedMarkers >= 1, + meta.summary?.clientClaim?.privacy?.redaction?.headerRuleCount >= 1 && + meta.summary?.clientClaim?.privacy?.detected?.redactedMarkers >= 1, "Metadata redaction summary missing", meta ); @@ -199,10 +217,11 @@ async function createFixtureArchive() { return zip.generateAsync({ type: "uint8array" }); } -function buildPassedShareSummary() { +function buildPassedShareSummary(archive) { return { - schemaVersion: 1, + schemaVersion: 2, source: "client", + archiveSha256: createHash("sha256").update(archive).digest("hex"), analyzed: true, encrypted: true, manifest: { diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 0202e87..977a85d 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; -import { mkdir, mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { request as createHttpRequest } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; @@ -197,18 +197,19 @@ describe("share-server", () => { const secret = "customer-alpha.internal/users/reset-token-123"; const encryptedArchive = await createEncryptedEnvelopeArchive(); const summary = { - schemaVersion: 1, + schemaVersion: 2, source: "client", + archiveSha256: sha256Hex(encryptedArchive), analyzed: true, encrypted: true, manifest: { origin: `https://${secret}`, mode: "lite", - chunkCodec: "ndjson", + chunkCodec: "none", recordedAt: "2026-02-13T00:00:00.000Z" }, totals: { - events: 1, + events: 0, errors: 0, requests: 0, actions: 0, @@ -296,8 +297,9 @@ describe("share-server", () => { expect(response.status).toBe(422); }); - it("rejects encrypted public share uploads without a passed client privacy preflight", async () => { + it("does not present an encrypted upload without a client claim as analyzed", async () => { const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", @@ -306,17 +308,241 @@ describe("share-server", () => { "x-webblackbox-api-key": apiKey, "x-webblackbox-filename": "encrypted.webblackbox" }, - body: Buffer.from(await createEncryptedEnvelopeArchive()) + body: Buffer.from(archive) + }); + const payload = (await response.json()) as { + summary: { + schemaVersion: number; + source: string; + analyzed: boolean; + trust: { privateContent: string }; + }; + }; + + expect(response.status).toBe(201); + expect(payload.summary).toMatchObject({ + schemaVersion: 2, + source: "unavailable", + analyzed: false, + trust: { privateContent: "not-analyzed" } + }); + }); + + it("rejects legacy client privacy assurance headers", async () => { + const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-encryption": "encrypted", + "x-webblackbox-policy-eligible": "true", + "x-webblackbox-redaction-summary": "passed" + }, + body: Buffer.from(archive) + }); + + await expect(response.json()).resolves.toEqual({ + error: "Legacy client assurance headers are not accepted. Use an archive-bound share summary." + }); + expect(response.status).toBe(400); + }); + + it("rejects client summaries that are not bound to the uploaded archive", async () => { + const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); + const summary = buildPassedShareSummary(archive) as Record; + summary.archiveSha256 = "0".repeat(64); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(summary)) + }, + body: Buffer.from(archive) + }); + + await expect(response.json()).resolves.toEqual({ + error: "Share summary archive digest does not match the upload." + }); + expect(response.status).toBe(400); + }); + + it("rejects legacy unbound share-summary schemas on new uploads", async () => { + const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); + const summary = buildPassedShareSummary(archive) as Record; + summary.schemaVersion = 1; + delete summary.archiveSha256; + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(summary)) + }, + body: Buffer.from(archive) + }); + + await expect(response.json()).resolves.toEqual({ + error: "Share summary must use schemaVersion 2 with source 'client'." + }); + expect(response.status).toBe(400); + }); + + it("rejects archive-bound client summaries that contradict inspected envelope facts", async () => { + const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); + const summary = buildPassedShareSummary(archive) as Record; + summary.encrypted = false; + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(summary)) + }, + body: Buffer.from(archive) }); await expect(response.json()).resolves.toEqual({ - error: "Encrypted public share uploads require a passed client privacy preflight summary." + error: "Share summary conflicts with server-inspected archive envelope facts." + }); + expect(response.status).toBe(400); + }); + + it("labels bound client privacy fields as unverified claims", async () => { + const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) + }, + body: Buffer.from(archive) + }); + const payload = (await response.json()) as { + summary: { + source: string; + analyzed: boolean; + archiveSha256?: string; + privacy?: unknown; + clientClaim?: { privacy?: { scanner?: { status?: string } } }; + trust: { privateContent: string; archiveDigestMatched?: boolean }; + analysisError?: string; + }; + }; + + expect(response.status).toBe(201); + expect(payload.summary).toMatchObject({ + source: "client-unverified", + analyzed: false, + trust: { + privateContent: "client-claim-unverified", + archiveDigestMatched: true + } + }); + expect(payload.summary.archiveSha256).toBeUndefined(); + expect(payload.summary.privacy).toBeUndefined(); + expect(payload.summary.clientClaim?.privacy?.scanner?.status).toBe("passed"); + expect(payload.summary.analysisError).toMatch(/unverified client claims/i); + }); + + it("uses a client's explicit blocked result only to reject, never to attest acceptance", async () => { + const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive(); + const summary = buildPassedShareSummary(archive) as { + privacy: { scanner: { status: string; findingCount: number } }; + }; + summary.privacy.scanner.status = "blocked"; + summary.privacy.scanner.findingCount = 1; + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(summary)) + }, + body: Buffer.from(archive) }); + const payload = (await response.json()) as { error: string }; + expect(response.status).toBe(422); + expect(payload.error).toBe("Share upload blocked by the client's privacy scanner claim."); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toEqual([]); + }); + + it("downgrades legacy stored client summaries instead of exposing them as analyzed", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const record = JSON.parse(await readFile(recordPath, "utf8")) as { + summary: Record; + }; + record.summary.schemaVersion = 1; + record.summary.source = "client"; + record.summary.analyzed = true; + const storedClientClaim = record.summary.clientClaim as + | { + totals?: Record; + topActionTriggers?: unknown; + privacy?: unknown; + } + | undefined; + record.summary.totals = { + ...(record.summary.totals as Record), + ...storedClientClaim?.totals + }; + record.summary.topActionTriggers = storedClientClaim?.topActionTriggers; + record.summary.privacy = storedClientClaim?.privacy; + delete record.summary.clientClaim; + delete record.summary.trust; + await writeFile(recordPath, JSON.stringify(record)); + + const response = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const payload = (await response.json()) as { + summary: { + schemaVersion: number; + source: string; + analyzed: boolean; + privacy?: unknown; + clientClaim?: { privacy?: unknown }; + trust: { privateContent: string; archiveDigestMatched?: boolean }; + analysisError: string; + }; + }; + + expect(response.status).toBe(200); + expect(payload.summary).toMatchObject({ + schemaVersion: 2, + source: "client-unverified", + analyzed: false, + trust: { privateContent: "client-claim-unverified" } + }); + expect(payload.summary.privacy).toBeUndefined(); + expect(payload.summary.clientClaim?.privacy).toBeDefined(); + expect(payload.summary.trust.archiveDigestMatched).toBeUndefined(); + expect(payload.summary.analysisError).toMatch(/not archive-digest-bound/i); }); it("rejects encrypted public share uploads with incomplete encrypted file metadata", async () => { const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive({ completeEncryptionMap: false }); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", @@ -324,9 +550,11 @@ describe("share-server", () => { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, "x-webblackbox-filename": "encrypted.webblackbox", - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, - body: Buffer.from(await createEncryptedEnvelopeArchive({ completeEncryptionMap: false })) + body: Buffer.from(archive) }); const payload = (await response.json()) as { error: string; @@ -340,6 +568,7 @@ describe("share-server", () => { it("rejects encrypted public share uploads with plaintext private files", async () => { const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive({ privateFileMode: "plaintext" }); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", @@ -347,13 +576,11 @@ describe("share-server", () => { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, "x-webblackbox-filename": "encrypted.webblackbox", - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, - body: Buffer.from( - await createEncryptedEnvelopeArchive({ - privateFileMode: "plaintext" - }) - ) + body: Buffer.from(archive) }); const payload = (await response.json()) as { error: string; @@ -367,6 +594,7 @@ describe("share-server", () => { it("rejects encrypted public share uploads with plaintext private blobs", async () => { const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive({ blobFileMode: "plaintext" }); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", @@ -374,13 +602,11 @@ describe("share-server", () => { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, "x-webblackbox-filename": "encrypted.webblackbox", - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, - body: Buffer.from( - await createEncryptedEnvelopeArchive({ - blobFileMode: "plaintext" - }) - ) + body: Buffer.from(archive) }); const payload = (await response.json()) as { error: string; @@ -394,6 +620,7 @@ describe("share-server", () => { it("rejects encrypted public share uploads with plaintext text blobs", async () => { const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive({ textBlobFileMode: "plaintext" }); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", @@ -401,13 +628,11 @@ describe("share-server", () => { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, "x-webblackbox-filename": "encrypted.webblackbox", - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, - body: Buffer.from( - await createEncryptedEnvelopeArchive({ - textBlobFileMode: "plaintext" - }) - ) + body: Buffer.from(archive) }); const payload = (await response.json()) as { error: string; @@ -421,20 +646,21 @@ describe("share-server", () => { it("scans complete private entries for plaintext beyond the former prefix window", async () => { const server = await startShareServer(); + const archive = await createEncryptedEnvelopeArchive({ + privateFileMode: "plaintext", + textBlobFileMode: "plaintext", + plaintextPrefixBytes: 64 * 1024 + 1 + }); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", headers: { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, - body: Buffer.from( - await createEncryptedEnvelopeArchive({ - privateFileMode: "plaintext", - textBlobFileMode: "plaintext", - plaintextPrefixBytes: 64 * 1024 + 1 - }) - ) + body: Buffer.from(archive) }); const payload = (await response.json()) as { plaintextEncryptedPaths: string[]; @@ -474,7 +700,9 @@ describe("share-server", () => { headers: { "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, body: Buffer.from(archive) }); @@ -653,7 +881,9 @@ describe("share-server", () => { "x-forwarded-proto": "https", "content-type": "application/octet-stream", "x-webblackbox-api-key": apiKey, - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(encryptedArchive)) + ) }, body: Buffer.from(encryptedArchive) }); @@ -876,30 +1106,34 @@ async function uploadEncryptedFixture( server: RunningShareServer, credential = apiKey ): Promise<{ shareId: string }> { + const archive = await createEncryptedEnvelopeArchive(); const response = await fetch(`${server.baseUrl}/api/share/upload`, { method: "POST", headers: { "content-type": "application/octet-stream", "x-webblackbox-api-key": credential, "x-webblackbox-filename": "fixture.webblackbox", - "x-webblackbox-share-summary": encodeURIComponent(JSON.stringify(buildPassedShareSummary())) + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) }, - body: Buffer.from(await createEncryptedEnvelopeArchive()) + body: Buffer.from(archive) }); expect(response.status).toBe(201); return (await response.json()) as { shareId: string }; } -function buildPassedShareSummary(): unknown { +function buildPassedShareSummary(archive: Uint8Array): unknown { return { - schemaVersion: 1, + schemaVersion: 2, source: "client", + archiveSha256: sha256Hex(archive), analyzed: true, encrypted: true, manifest: { mode: "lite", - chunkCodec: "ndjson", + chunkCodec: "none", recordedAt: "2026-02-13T00:00:00.000Z" }, totals: { @@ -931,6 +1165,10 @@ function buildPassedShareSummary(): unknown { }; } +function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + async function createEncryptedEnvelopeArchive( options: { completeEncryptionMap?: boolean; @@ -987,7 +1225,11 @@ async function createEnvelopeArchive( [BLOB_FIXTURE_PATH]: { ivBase64: toBase64(randomBytes(12)) }, [TEXT_BLOB_FIXTURE_PATH]: { ivBase64: toBase64(randomBytes(12)) } } - : {}; + : encrypted + ? { + "index/req.json": { ivBase64: toBase64(randomBytes(12)) } + } + : {}; const manifest = { protocolVersion: 1, createdAt: "2026-02-13T00:00:00.000Z", diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index fea31dd..ea04b40 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -26,6 +26,7 @@ import { assertLoadedArchiveResourceLimits, resolveArchiveResourceLimits } from "@webblackbox/player-sdk"; +import { exportManifestSchema, type ExportManifest } from "@webblackbox/protocol"; import { parseShareApiCredentials, type ShareApiScope } from "./auth-config.js"; @@ -55,6 +56,16 @@ type ShareReadSession = { type ArchiveEnvelopeSummary = { encrypted: boolean; + manifest?: { + mode: ExportManifest["mode"]; + chunkCodec: ExportManifest["chunkCodec"]; + recordedAt: string; + }; + totals?: { + events: number; + blobs: number; + durationMs: number; + }; encryptedPrivatePathsComplete: boolean; missingEncryptedPaths: string[]; encryptedPrivatePathsConfidential: boolean; @@ -62,11 +73,43 @@ type ArchiveEnvelopeSummary = { analysisError?: string; }; +type ShareActionTriggerSummary = { + triggerType: string; + count: number; + errorRate: number; +}; + +type SharePrivacySummary = { + redaction: { + hashSensitiveValues: boolean; + headerRuleCount: number; + cookieRuleCount: number; + bodyPatternCount: number; + blockedSelectorCount: number; + }; + detected: ReturnType["detected"]; + scanner: ReturnType["scanner"]; + categories?: Array<{ + category: string; + events: number; + low: number; + medium: number; + high: number; + redacted: number; + unredacted: number; + }>; +}; + type ShareSummary = { - schemaVersion: 1; - source: "client" | "server" | "unavailable"; + schemaVersion: 2; + source: "client-unverified" | "server" | "unavailable"; analyzed: boolean; encrypted: boolean; + trust: { + archiveEnvelope: "server-inspected"; + privateContent: "server-analyzed" | "client-claim-unverified" | "not-analyzed"; + archiveDigestMatched?: true; + }; analysisError?: string; manifest?: { mode: string; @@ -77,38 +120,31 @@ type ShareSummary = { events: number; blobs?: number; privacyViolations?: number; - errors: number; - requests: number; - actions: number; + errors?: number; + requests?: number; + actions?: number; durationMs: number; }; - topActionTriggers?: Array<{ - triggerType: string; - count: number; - errorRate: number; - }>; - privacy?: { - redaction: { - hashSensitiveValues: boolean; - headerRuleCount: number; - cookieRuleCount: number; - bodyPatternCount: number; - blockedSelectorCount: number; + topActionTriggers?: ShareActionTriggerSummary[]; + privacy?: SharePrivacySummary; + clientClaim?: { + totals?: { + privacyViolations?: number; + errors?: number; + requests?: number; + actions?: number; }; - detected: ReturnType["detected"]; - scanner: ReturnType["scanner"]; - categories?: Array<{ - category: string; - events: number; - low: number; - medium: number; - high: number; - redacted: number; - unredacted: number; - }>; + topActionTriggers?: ShareActionTriggerSummary[]; + privacy?: SharePrivacySummary; }; }; +type ClientShareClaim = Omit & { + source: "client"; + analyzed: true; + archiveSha256: string; +}; + const DEFAULT_PORT = 8787; const DEFAULT_HOST = "127.0.0.1"; const MAX_UPLOAD_BYTES = Math.min( @@ -168,6 +204,11 @@ const UPLOAD_RATE_LIMIT_WINDOW_MS = parseRateLimitWindowMs( 60_000 ); const SHARE_SUMMARY_HEADER = "x-webblackbox-share-summary"; +const LEGACY_CLIENT_ASSURANCE_HEADERS = [ + "x-webblackbox-encryption", + "x-webblackbox-policy-eligible", + "x-webblackbox-redaction-summary" +] as const; const MAX_SHARE_SUMMARY_HEADER_BYTES = 16 * 1024; const AES_GCM_IV_BYTES = 12; const SHARE_READ_SESSION_COOKIE = "webblackbox_share_read"; @@ -395,10 +436,10 @@ async function processUploadWithinInspectionSlot( id, typeof filenameHeader === "string" ? filenameHeader : undefined ); - let clientSummary: ShareSummary | null; + let clientSummary: ClientShareClaim | null; try { - clientSummary = readClientShareSummary(request); + clientSummary = readClientShareSummary(request, checksumSha256); } catch (error) { if (error instanceof ShareSummaryHeaderError) { respondJson(response, 400, { @@ -468,11 +509,25 @@ async function processUploadWithinInspectionSlot( }); return; } + + if (clientSummary) { + try { + assertClientShareClaimMatchesArchive(clientSummary, archiveEnvelope); + } catch (error) { + if (error instanceof ShareSummaryHeaderError) { + respondJson(response, 400, { error: error.message }); + return; + } + throw error; + } + } + const sizeBytes = bytes.byteLength; bytes = new Uint8Array(0); - const summary = clientSummary - ? applyArchiveEnvelopeToClientSummary(clientSummary, archiveEnvelopeSummary) - : archiveEnvelopeSummary; + const summary = + archiveEnvelope.encrypted && clientSummary + ? applyArchiveEnvelopeToClientSummary(clientSummary, archiveEnvelopeSummary) + : archiveEnvelopeSummary; if (archiveEnvelope.encrypted && !archiveEnvelope.encryptedPrivatePathsComplete) { await writeShareAuditEvent(request, { @@ -500,44 +555,32 @@ async function processUploadWithinInspectionSlot( return; } - if (summary.analyzed && summary.privacy?.scanner.status === "blocked") { + if ( + summary.source === "server" && + summary.analyzed && + summary.privacy?.scanner.status === "blocked" + ) { await writeShareAuditEvent(request, { action: "upload", shareId: id, outcome: "blocked" }); respondJson(response, 422, { - error: "Share upload blocked by privacy scanner.", + error: "Share upload blocked by the server privacy scanner.", scanner: summary.privacy.scanner }); return; } - if (archiveEnvelope.encrypted && !clientSummary) { - await writeShareAuditEvent(request, { - action: "upload", - shareId: id, - outcome: "blocked" - }); - respondJson(response, 422, { - error: "Encrypted public share uploads require a passed client privacy preflight summary." - }); - return; - } - - if ( - archiveEnvelope.encrypted && - clientSummary && - !hasPassedClientPrivacyPreflight(clientSummary) - ) { + if (clientSummary?.privacy?.scanner.status === "blocked") { await writeShareAuditEvent(request, { action: "upload", shareId: id, outcome: "blocked" }); respondJson(response, 422, { - error: "Encrypted public share uploads require a passed client privacy preflight summary.", - scanner: clientSummary.privacy?.scanner + error: "Share upload blocked by the client's privacy scanner claim.", + scanner: clientSummary.privacy.scanner }); return; } @@ -895,10 +938,14 @@ async function buildShareSummary( const privacyReport = player.getPrivacyProtectionReport(); return { - schemaVersion: 1, + schemaVersion: 2, source: "server", analyzed: true, encrypted: envelope.encrypted || Boolean(manifest.encryption), + trust: { + archiveEnvelope: "server-inspected", + privateContent: "server-analyzed" + }, manifest: { mode: manifest.mode, chunkCodec: manifest.chunkCodec, @@ -934,11 +981,26 @@ async function buildShareSummary( const message = error instanceof Error ? error.message : String(error); return { - schemaVersion: 1, + schemaVersion: 2, source: "unavailable", analyzed: false, encrypted: envelope.encrypted, - analysisError: redactText(message, 240) + trust: { + archiveEnvelope: "server-inspected", + privateContent: "not-analyzed" + }, + manifest: envelope.manifest, + totals: + envelope.totals === undefined + ? undefined + : { + events: envelope.totals.events, + blobs: envelope.totals.blobs, + durationMs: envelope.totals.durationMs + }, + analysisError: envelope.encrypted + ? "Encrypted private content was not analyzed because the share server has no passphrase." + : redactText(message, 240) }; } } @@ -949,8 +1011,9 @@ async function inspectArchiveEnvelope(bytes: Uint8Array): Promise 0; const encryptedFiles = asRecord(encryption.files); const privatePaths = collectArchivePrivatePaths(zip); @@ -964,9 +1027,20 @@ async function inspectArchiveEnvelope(bytes: Uint8Array): Promise request.headers[name] !== undefined)) { + throw new ShareSummaryHeaderError( + "Legacy client assurance headers are not accepted. Use an archive-bound share summary." + ); + } + const rawHeader = request.headers[SHARE_SUMMARY_HEADER]; if (rawHeader === undefined) { @@ -1272,20 +1355,40 @@ function readClientShareSummary(request: IncomingMessage): ShareSummary | null { throw new ShareSummaryHeaderError("Share summary header is not valid encoded JSON."); } - return normalizeClientShareSummary(parsed); + return normalizeClientShareSummary(parsed, archiveSha256); } -function normalizeClientShareSummary(value: unknown): ShareSummary { +function normalizeClientShareSummary( + value: unknown, + expectedArchiveSha256: string +): ClientShareClaim { const record = asRecord(value); + if (record.schemaVersion !== 2 || record.source !== "client") { + throw new ShareSummaryHeaderError( + "Share summary must use schemaVersion 2 with source 'client'." + ); + } + if (record.analyzed !== true || typeof record.encrypted !== "boolean") { + throw new ShareSummaryHeaderError( + "Share summary must declare a completed client analysis and encryption state." + ); + } + + const archiveSha256 = readArchiveSha256(record.archiveSha256); + if (!sha256HexMatches(archiveSha256, expectedArchiveSha256)) { + throw new ShareSummaryHeaderError("Share summary archive digest does not match the upload."); + } + const manifest = asRecord(record.manifest); const totals = asRecord(record.totals); const privacy = asRecord(record.privacy); return { - schemaVersion: 1, + schemaVersion: 2, source: "client", - analyzed: readBoolean(record.analyzed, true), - encrypted: readBoolean(record.encrypted, true), + archiveSha256, + analyzed: true, + encrypted: record.encrypted, manifest: { mode: readString(manifest.mode, "unknown", 32), chunkCodec: readString(manifest.chunkCodec, "unknown", 32), @@ -1306,21 +1409,62 @@ function normalizeClientShareSummary(value: unknown): ShareSummary { } function applyArchiveEnvelopeToClientSummary( - clientSummary: ShareSummary, + clientSummary: ClientShareClaim, archiveEnvelopeSummary: ShareSummary ): ShareSummary { + if (!clientSummary.totals || !archiveEnvelopeSummary.totals) { + throw new Error("Archive-bound client summary is missing inspected totals."); + } + return { - ...clientSummary, + schemaVersion: 2, + source: "client-unverified", + analyzed: false, encrypted: archiveEnvelopeSummary.encrypted, - analysisError: archiveEnvelopeSummary.encrypted - ? undefined - : archiveEnvelopeSummary.analysisError + trust: { + archiveEnvelope: "server-inspected", + privateContent: "client-claim-unverified", + archiveDigestMatched: true + }, + manifest: archiveEnvelopeSummary.manifest, + totals: archiveEnvelopeSummary.totals, + clientClaim: { + totals: { + privacyViolations: clientSummary.totals.privacyViolations, + errors: clientSummary.totals.errors, + requests: clientSummary.totals.requests, + actions: clientSummary.totals.actions + }, + topActionTriggers: clientSummary.topActionTriggers, + privacy: clientSummary.privacy + }, + analysisError: + "Encrypted private content was not analyzed by the share server; privacy fields are unverified client claims." }; } -function hasPassedClientPrivacyPreflight(summary: ShareSummary): boolean { - const scanner = summary.privacy?.scanner; - return summary.analyzed && scanner?.preEncryption === true && scanner.status === "passed"; +function assertClientShareClaimMatchesArchive( + summary: ClientShareClaim, + envelope: ArchiveEnvelopeSummary +): void { + if (!envelope.manifest || !envelope.totals) { + throw new Error("Archive envelope facts are unavailable after successful inspection."); + } + + const manifestMatches = + summary.manifest?.mode === envelope.manifest.mode && + summary.manifest.chunkCodec === envelope.manifest.chunkCodec && + summary.manifest.recordedAt === envelope.manifest.recordedAt; + const totalsMatch = + summary.totals?.events === envelope.totals.events && + summary.totals.durationMs === envelope.totals.durationMs && + (summary.totals.blobs === undefined || summary.totals.blobs === envelope.totals.blobs); + + if (summary.encrypted !== envelope.encrypted || !manifestMatches || !totalsMatch) { + throw new ShareSummaryHeaderError( + "Share summary conflicts with server-inspected archive envelope facts." + ); + } } function normalizeSharePrivacySummary(value: Record): ShareSummary["privacy"] { @@ -1510,7 +1654,102 @@ function buildPublicShareMetadata(record: ShareRecord): { sizeBytes: record.sizeBytes, checksumSha256: record.checksumSha256, shareUrl: record.shareUrl, - summary: record.summary + summary: normalizePersistedShareSummary(record.summary) + }; +} + +function normalizePersistedShareSummary(value: unknown): ShareSummary { + const record = asRecord(value); + const source = record.source; + const trust = asRecord(record.trust); + const currentTrustIsConsistent = + (source === "server" && + record.analyzed === true && + trust.privateContent === "server-analyzed") || + (source === "client-unverified" && + record.analyzed === false && + trust.privateContent === "client-claim-unverified") || + (source === "unavailable" && + record.analyzed === false && + trust.privateContent === "not-analyzed"); + + if ( + record.schemaVersion === 2 && + trust.archiveEnvelope === "server-inspected" && + currentTrustIsConsistent + ) { + return value as ShareSummary; + } + + const legacy = value as Partial & { + source?: string; + archiveSha256?: unknown; + }; + const legacyWithoutDigest = { ...legacy }; + delete legacyWithoutDigest.archiveSha256; + const encrypted = legacy.encrypted === true; + + if (source === "client" || source === "client-unverified") { + const legacyTotals = legacy.totals; + + return { + schemaVersion: 2, + source: "client-unverified", + analyzed: false, + encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "client-claim-unverified" + }, + manifest: legacy.manifest, + totals: legacyTotals + ? { + events: legacyTotals.events, + blobs: legacyTotals.blobs, + durationMs: legacyTotals.durationMs + } + : undefined, + clientClaim: { + totals: legacyTotals + ? { + privacyViolations: legacyTotals.privacyViolations, + errors: legacyTotals.errors, + requests: legacyTotals.requests, + actions: legacyTotals.actions + } + : undefined, + topActionTriggers: legacy.topActionTriggers, + privacy: legacy.privacy + }, + analysisError: + "Legacy client privacy fields were not archive-digest-bound and remain unverified claims." + }; + } + + if (source === "server" && legacy.analyzed === true) { + return { + ...legacyWithoutDigest, + schemaVersion: 2, + source: "server", + analyzed: true, + encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "server-analyzed" + } + } as ShareSummary; + } + + return { + schemaVersion: 2, + source: "unavailable", + analyzed: false, + encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "not-analyzed" + }, + analysisError: "Stored share analysis is unavailable or uses an unsupported legacy schema." }; } @@ -1853,6 +2092,20 @@ function readString(value: unknown, fallback: string, maxLength: number): string return redactText(value, maxLength); } +function readArchiveSha256(value: unknown): string { + if (typeof value !== "string" || !/^[a-f0-9]{64}$/i.test(value)) { + throw new ShareSummaryHeaderError( + "Share summary archiveSha256 must be a 64-character SHA-256 hex digest." + ); + } + + return value.toLowerCase(); +} + +function sha256HexMatches(left: string, right: string): boolean { + return equalsSecret(left.toLowerCase(), right.toLowerCase()); +} + function readBoolean(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 903379f..a520f75 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -26,7 +26,7 @@ Captured sessions remain local until the user exports or shares an archive. Unde ## Export And Share -Real-user archives must be encrypted before export or share. The public share server stores encrypted archive bytes and redacted public metadata. It does not receive archive passphrases or decryption keys, and it rejects encrypted uploads that leave private archive files, including event chunks, blobs, indexes, or privacy manifests, in plaintext. +Real-user archives must be encrypted before export or share. The public share server stores encrypted archive bytes and redacted public metadata. It does not receive archive passphrases or decryption keys, and it rejects encrypted uploads that leave private archive files, including event chunks, blobs, indexes, or privacy manifests, in plaintext. Player-generated privacy claims include the SHA-256 of the exact uploaded archive, but the server labels their private-content results as unverified client claims because digest binding provides integrity between the claim and bytes, not authenticity or scanner attestation. Public share links expire, can be revoked, and generate redacted audit events. Audit records do not include captured payloads, passphrases, API keys, raw URLs, raw selectors, or archive plaintext. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e1a31a7..5121b01 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -13,7 +13,7 @@ The dev/enterprise profile can enable deeper diagnostics, including CDP, but the 3. Capture adapters sanitize data before it enters the recorder pipeline. 4. The ingest gate rejects or replaces policy-violating artifacts with `privacy.violation` events. 5. Archives include `privacy/manifest.json` with policy, categories, encryption status, and pre-encryption scanner result. -6. Exports and shares recompute policy eligibility instead of trusting imported archive metadata. +6. Exports recompute policy eligibility. The Player evaluates decrypted content before sharing, while the keyless Share server enforces only server-inspected outer facts and labels client private-content results as unverified claims. MAIN-world capture hooks run in the page's JavaScript environment and are therefore treated as untrusted observations, not authenticated evidence. Their bridge accepts only bounded, allowlisted event payloads, ignores page-supplied timestamps, rate-limits input, and exposes no marker or other extension control operation. Privileged decisions remain in the isolated content script and service worker. @@ -21,7 +21,7 @@ Global visual and profiler artifacts fail closed when the page contains a child ## Encryption -Real-user archives require export encryption. Public share uploads require encrypted `.webblackbox` archives and never accept passphrases. Private archive paths include event chunks, blobs, indexes, and `privacy/manifest.json`; older encrypted archives with plaintext private files must be re-exported before public sharing. Client-side share metadata is limited to an allowlisted public summary. +Real-user archives require export encryption. Public share uploads require encrypted `.webblackbox` archives and never accept passphrases. Private archive paths include event chunks, blobs, indexes, and `privacy/manifest.json`; older encrypted archives with plaintext private files must be re-exported before public sharing. Client-side share metadata is limited to an allowlisted public claim bound to the exact archive bytes by SHA-256. Local event chunks and blobs are also protected before persistent storage. The pipeline rejects persistent storage that does not declare authenticated AES-GCM payload protection when `localAtRest` is required. The extension stores a non-extractable, purpose-specific Web Crypto key in an extension-origin IndexedDB keyring so offscreen/service-worker restarts can recover the cache without exporting raw key material. On upgrade, the legacy plaintext database is purged; if the managed key is lost, unverifiable cached payloads are purged rather than read as plaintext. @@ -33,7 +33,7 @@ The player treats archives as untrusted input. It does not load captured externa ## Share Server -The share server supports scoped API keys, upload rate limits, expiry, revocation, redacted metadata, and redacted audit logs. Public deployments should keep plaintext uploads disabled. +The share server supports scoped API keys, upload rate limits, expiry, revocation, redacted metadata, and redacted audit logs. Public deployments should keep plaintext uploads disabled. The server derives encryption and outer manifest facts itself and rejects mismatched client claims. Because it has no archive passphrase, it labels encrypted-content privacy results as archive-bound but unverified client claims; matching a digest is not proof that the client scanner ran honestly. Deployments that require privacy attestation need a trusted signed or remote pre-encryption analysis path. ## Reporting Security Issues diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5524d8..7902a18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,9 @@ importers: '@webblackbox/player-sdk': specifier: workspace:* version: link:../../packages/player-sdk + '@webblackbox/protocol': + specifier: workspace:* + version: link:../../packages/protocol jszip: specifier: 3.10.1 version: 3.10.1 From b30c1ebd1b296c31a0093ddb660c28be3a20d089 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:52:11 +0800 Subject: [PATCH 051/181] fix(share): bind persisted records to safe identities --- apps/share-server/src/index.test.ts | 45 ++++++++++ apps/share-server/src/index.ts | 129 ++++++++++++++++++++++------ 2 files changed, 148 insertions(+), 26 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 977a85d..bc1c1e9 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -672,6 +672,51 @@ describe("share-server", () => { ); }); + it("binds persisted records to safe filenames before metadata or cleanup", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const persisted = JSON.parse(await readFile(recordPath, "utf8")) as Record; + persisted.shareUrl = "https://attacker.example/share/poisoned"; + persisted.fileName = 'poisoned".zip'; + await writeFile(recordPath, JSON.stringify(persisted)); + + const metadataResponse = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const metadata = (await metadataResponse.json()) as { shareUrl: string; fileName: string }; + expect(metadataResponse.status).toBe(200); + expect(metadata.shareUrl).toBe(`${server.baseUrl}/share/${upload.shareId}`); + expect(metadata.fileName).toMatch(/^webblackbox-share-[a-f0-9]+\.zip$/); + + const sentinelPath = resolve(server.dataDir, "sentinel.json"); + await writeFile(sentinelPath, "keep-me"); + await writeFile( + resolve(server.dataDir, "records", "malicious.json"), + JSON.stringify({ + ...persisted, + id: "../sentinel", + createdAt: 1, + expiresAt: 2 + }) + ); + + const listResponse = await fetch(`${server.baseUrl}/api/share/list`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(listResponse.status).toBe(200); + await expect(readFile(sentinelPath, "utf8")).resolves.toBe("keep-me"); + + await writeFile( + resolve(server.dataDir, "records", "oversized.json"), + " ".repeat(256 * 1024 + 1) + ); + const oversizedResponse = await fetch(`${server.baseUrl}/api/share/oversized/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(oversizedResponse.status).toBe(404); + }); + it("expires shares and blocks archive download after ttl", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index ea04b40..3c83c01 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,16 +1,6 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { createReadStream } from "node:fs"; -import { - appendFile, - mkdir, - open, - readFile, - readdir, - rename, - rm, - stat, - writeFile -} from "node:fs/promises"; +import { appendFile, mkdir, open, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -213,6 +203,8 @@ const MAX_SHARE_SUMMARY_HEADER_BYTES = 16 * 1024; const AES_GCM_IV_BYTES = 12; const SHARE_READ_SESSION_COOKIE = "webblackbox_share_read"; const SHARE_READ_SESSION_TTL_MS = 10 * 60 * 1000; +const MAX_SHARE_RECORD_BYTES = 256 * 1024; +const SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const MAX_SHARE_READ_SESSIONS = 4096; const RATE_LIMIT_CLEANUP_INTERVAL = 64; const MAX_TRACKED_RATE_BUCKETS = 4096; @@ -296,7 +288,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): if ( readShareId && method === "GET" && - /^\/share\/[a-zA-Z0-9_-]+$/.test(pathname) && + /^\/share\/[a-zA-Z0-9_-]{1,128}$/.test(pathname) && authorization.source !== "read-session" ) { issueShareReadSessionCookie(response, readShareId); @@ -313,28 +305,28 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): return; } - const metadataMatch = /^\/api\/share\/([a-zA-Z0-9_-]+)\/meta$/.exec(pathname); + const metadataMatch = /^\/api\/share\/([a-zA-Z0-9_-]{1,128})\/meta$/.exec(pathname); if (method === "GET" && metadataMatch?.[1]) { await handleGetMetadata(request, response, metadataMatch[1]); return; } - const archiveMatch = /^\/api\/share\/([a-zA-Z0-9_-]+)\/archive$/.exec(pathname); + const archiveMatch = /^\/api\/share\/([a-zA-Z0-9_-]{1,128})\/archive$/.exec(pathname); if (method === "GET" && archiveMatch?.[1]) { await handleDownloadArchive(request, response, archiveMatch[1]); return; } - const revokeMatch = /^\/api\/share\/([a-zA-Z0-9_-]+)\/revoke$/.exec(pathname); + const revokeMatch = /^\/api\/share\/([a-zA-Z0-9_-]{1,128})\/revoke$/.exec(pathname); if (method === "POST" && revokeMatch?.[1]) { await handleRevokeShare(request, response, revokeMatch[1]); return; } - const sharePageMatch = /^\/share\/([a-zA-Z0-9_-]+)$/.exec(pathname); + const sharePageMatch = /^\/share\/([a-zA-Z0-9_-]{1,128})$/.exec(pathname); if (method === "GET" && sharePageMatch?.[1]) { await handleSharePage(request, response, sharePageMatch[1]); @@ -1606,11 +1598,30 @@ async function ensureStorageLayout(): Promise { } async function readRecord(id: string): Promise { + if (!SHARE_ID_PATTERN.test(id)) { + return null; + } + + let handle: Awaited> | null = null; try { - const raw = await readFile(recordPathForId(id), "utf8"); - return JSON.parse(raw) as ShareRecord; + handle = await open(recordPathForId(id), "r"); + const recordStat = await handle.stat(); + if ( + !recordStat.isFile() || + !Number.isSafeInteger(recordStat.size) || + recordStat.size <= 0 || + recordStat.size > MAX_SHARE_RECORD_BYTES + ) { + return null; + } + + const raw = await readFileHandleExactly(handle, recordStat.size); + const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)) as unknown; + return normalizePersistedShareRecord(parsed, id); } catch { return null; + } finally { + await handle?.close(); } } @@ -1621,7 +1632,7 @@ async function loadAllRecords(): Promise { .filter((name) => name.endsWith(".json")) .map(async (name) => { const id = name.slice(0, -".json".length); - return readRecord(id); + return SHARE_ID_PATTERN.test(id) ? readRecord(id) : null; }) ); @@ -1632,6 +1643,64 @@ async function writeRecord(record: ShareRecord): Promise { await writeFile(recordPathForId(record.id), JSON.stringify(record, null, 2)); } +function normalizePersistedShareRecord(value: unknown, expectedId: string): ShareRecord | null { + const record = asRecord(value); + if (record.id !== expectedId || !SHARE_ID_PATTERN.test(expectedId)) { + return null; + } + + const createdAt = readSafeNonNegativeInteger(record.createdAt); + const persistedExpiresAt = readSafeNonNegativeInteger(record.expiresAt); + const defaultExpiresAt = + createdAt === null ? null : addSafeIntegers(createdAt, SHARE_DEFAULT_TTL_MS); + const expiresAt = persistedExpiresAt ?? defaultExpiresAt; + const revokedAt = + record.revokedAt === undefined ? undefined : readSafeNonNegativeInteger(record.revokedAt); + const sizeBytes = readSafeNonNegativeInteger(record.sizeBytes); + const checksumSha256 = + typeof record.checksumSha256 === "string" && /^[a-f0-9]{64}$/i.test(record.checksumSha256) + ? record.checksumSha256.toLowerCase() + : null; + + if ( + createdAt === null || + expiresAt === null || + expiresAt < createdAt || + revokedAt === null || + (revokedAt !== undefined && revokedAt < createdAt) || + sizeBytes === null || + sizeBytes <= 0 || + sizeBytes > MAX_UPLOAD_BYTES || + checksumSha256 === null + ) { + return null; + } + + return { + id: expectedId, + createdAt, + expiresAt, + revokedAt, + fileName: publicArchiveFileName( + expectedId, + typeof record.fileName === "string" ? record.fileName : undefined + ), + sizeBytes, + checksumSha256, + shareUrl: `${sharePublicOrigin}/share/${expectedId}`, + summary: normalizePersistedShareSummary(record.summary) + }; +} + +function readSafeNonNegativeInteger(value: unknown): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function addSafeIntegers(left: number, right: number): number | null { + const sum = left + right; + return Number.isSafeInteger(sum) ? sum : null; +} + function buildPublicShareMetadata(record: ShareRecord): { id: string; createdAt: number; @@ -1835,13 +1904,21 @@ function hashAuditValue(value: string): string { } function recordPathForId(id: string): string { + assertSafeShareId(id); return join(RECORDS_DIR, `${id}.json`); } function archivePathForId(id: string): string { + assertSafeShareId(id); return join(ARCHIVES_DIR, `${id}.webblackbox`); } +function assertSafeShareId(id: string): void { + if (!SHARE_ID_PATTERN.test(id)) { + throw new Error("Invalid share id."); + } +} + function parsePort(rawValue: string | undefined): number { const parsed = Number(rawValue); @@ -2236,19 +2313,19 @@ function resolveRequiredShareScope(method: string, pathname: string): ShareApiSc return "list"; } - if (method === "GET" && /^\/api\/share\/[a-zA-Z0-9_-]+\/meta$/.test(pathname)) { + if (method === "GET" && /^\/api\/share\/[a-zA-Z0-9_-]{1,128}\/meta$/.test(pathname)) { return "read"; } - if (method === "GET" && /^\/api\/share\/[a-zA-Z0-9_-]+\/archive$/.test(pathname)) { + if (method === "GET" && /^\/api\/share\/[a-zA-Z0-9_-]{1,128}\/archive$/.test(pathname)) { return "read"; } - if (method === "GET" && /^\/share\/[a-zA-Z0-9_-]+$/.test(pathname)) { + if (method === "GET" && /^\/share\/[a-zA-Z0-9_-]{1,128}$/.test(pathname)) { return "read"; } - if (method === "POST" && /^\/api\/share\/[a-zA-Z0-9_-]+\/revoke$/.test(pathname)) { + if (method === "POST" && /^\/api\/share\/[a-zA-Z0-9_-]{1,128}\/revoke$/.test(pathname)) { return "revoke"; } @@ -2314,16 +2391,16 @@ function isAuthorizedToken(token: string, requiredScope: ShareApiScope): boolean } function isQueryApiKeyAllowedRoute(method: string, pathname: string): boolean { - return method === "GET" && /^\/share\/[a-zA-Z0-9_-]+$/.test(pathname); + return method === "GET" && /^\/share\/[a-zA-Z0-9_-]{1,128}$/.test(pathname); } function extractReadShareId(pathname: string): string | null { - const pageMatch = /^\/share\/([a-zA-Z0-9_-]+)$/.exec(pathname); + const pageMatch = /^\/share\/([a-zA-Z0-9_-]{1,128})$/.exec(pathname); if (pageMatch?.[1]) { return pageMatch[1]; } - const apiMatch = /^\/api\/share\/([a-zA-Z0-9_-]+)\/(?:meta|archive)$/.exec(pathname); + const apiMatch = /^\/api\/share\/([a-zA-Z0-9_-]{1,128})\/(?:meta|archive)$/.exec(pathname); return apiMatch?.[1] ?? null; } From 3c5d67570e34811ddb026c8bb7ebec0d8f7d398e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 02:56:42 +0800 Subject: [PATCH 052/181] fix(share): bound record capacity and list pages --- apps/share-server/README.md | 3 +- apps/share-server/src/index.test.ts | 77 ++++++++++++ apps/share-server/src/index.ts | 174 +++++++++++++++++++++++----- 3 files changed, 225 insertions(+), 29 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index fcc6782..2b42fec 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -39,6 +39,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_DEFAULT_TTL_MS`: default share lifetime in ms (default `604800000`, seven days). - `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days). - `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). +- `WEBBLACKBOX_SHARE_MAX_RECORDS`: maximum retained record files admitted by one server (default and hard ceiling `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). - `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: set `true` only behind a trusted proxy; otherwise upload rate limiting uses socket IP. @@ -114,7 +115,7 @@ Response: ### Metadata and archive -- `GET /api/share/list` +- `GET /api/share/list?offset=0&limit=100` (strict offset pagination; maximum page size `200`, with `total` and `nextOffset` in the response) - `GET /api/share/:id/meta` - `GET /api/share/:id/archive` - `GET /share/:id` diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index bc1c1e9..9150831 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -717,6 +717,83 @@ describe("share-server", () => { expect(oversizedResponse.status).toBe(404); }); + it("paginates bounded share listings with strict query validation", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const sourceRecord = JSON.parse( + await readFile(resolve(server.dataDir, "records", `${upload.shareId}.json`), "utf8") + ) as Record; + const createdAt = Number(sourceRecord.createdAt); + const cloneIds = ["a".repeat(32), "b".repeat(32)]; + + for (const [index, id] of cloneIds.entries()) { + await writeFile( + resolve(server.dataDir, "records", `${id}.json`), + JSON.stringify({ + ...sourceRecord, + id, + createdAt: createdAt - index - 1 + }) + ); + } + + const firstResponse = await fetch(`${server.baseUrl}/api/share/list?limit=2&offset=0`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const first = (await firstResponse.json()) as { + items: Array<{ id: string }>; + total: number; + offset: number; + limit: number; + nextOffset: number | null; + }; + expect(first).toMatchObject({ total: 3, offset: 0, limit: 2, nextOffset: 2 }); + expect(first.items).toHaveLength(2); + + const secondResponse = await fetch(`${server.baseUrl}/api/share/list?limit=2&offset=2`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const second = (await secondResponse.json()) as { + items: Array<{ id: string }>; + nextOffset: number | null; + }; + expect(second.items).toHaveLength(1); + expect(second.nextOffset).toBeNull(); + + const invalidResponse = await fetch(`${server.baseUrl}/api/share/list?limit=201`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(invalidResponse.status).toBe(400); + }); + + it("rejects uploads before buffering when retained record capacity is exhausted", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_MAX_RECORDS: "2" + }); + await uploadEncryptedFixture(server); + await uploadEncryptedFixture(server); + const archive = await createEncryptedEnvelopeArchive(); + + const response = await fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) + }, + body: Buffer.from(archive) + }); + + expect(response.status).toBe(507); + await expect(response.json()).resolves.toEqual({ + error: + "Share record capacity is exhausted (2). Revoke or expire retained shares before uploading." + }); + await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toHaveLength(2); + }); + it("expires shares and blocks archive download after ttl", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 3c83c01..66ec95d 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { createReadStream } from "node:fs"; -import { appendFile, mkdir, open, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, open, opendir, rename, rm, stat, writeFile } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -205,6 +205,13 @@ const SHARE_READ_SESSION_COOKIE = "webblackbox_share_read"; const SHARE_READ_SESSION_TTL_MS = 10 * 60 * 1000; const MAX_SHARE_RECORD_BYTES = 256 * 1024; const SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; +const MAX_SHARE_RECORDS = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_RECORDS, 10_000), + 10_000 +); +const MAX_SHARE_RECORD_DIRECTORY_ENTRIES = 20_000; +const DEFAULT_SHARE_LIST_LIMIT = 100; +const MAX_SHARE_LIST_LIMIT = 200; const MAX_SHARE_READ_SESSIONS = 4096; const RATE_LIMIT_CLEANUP_INTERVAL = 64; const MAX_TRACKED_RATE_BUCKETS = 4096; @@ -301,7 +308,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): } if (method === "GET" && pathname === "/api/share/list") { - await handleList(request, response); + await handleList(request, response, requestUrl); return; } @@ -359,6 +366,14 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): } try { + if (!(await hasShareRecordCapacity())) { + closeRequestAfterResponse(request, response); + respondJson(response, 507, { + error: `Share record capacity is exhausted (${MAX_SHARE_RECORDS}). Revoke or expire retained shares before uploading.` + }); + return; + } + await handleUploadWithinInspectionSlot(request, response); } finally { activeUploadInspections -= 1; @@ -628,11 +643,26 @@ async function processUploadWithinInspectionSlot( }); } -async function handleList(request: IncomingMessage, response: ServerResponse): Promise { +async function handleList( + request: IncomingMessage, + response: ServerResponse, + requestUrl: URL +): Promise { + const pagination = parseShareListPagination(requestUrl); + if (!pagination) { + respondJson(response, 400, { + error: `List pagination requires integer offset >= 0 and limit between 1 and ${MAX_SHARE_LIST_LIMIT}.` + }); + return; + } + await pruneExpiredShareRecords(Date.now()); - const records = await loadAllRecords(); + const records = (await loadAllRecords()).sort( + (left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id) + ); + const endOffset = Math.min(records.length, pagination.offset + pagination.limit); const items = records - .sort((left, right) => right.createdAt - left.createdAt) + .slice(pagination.offset, endOffset) .map((record) => buildPublicShareMetadata(record)); await writeShareAuditEvent(request, { @@ -640,7 +670,11 @@ async function handleList(request: IncomingMessage, response: ServerResponse): P outcome: "ok" }); respondJson(response, 200, { - items + items, + total: records.length, + offset: pagination.offset, + limit: pagination.limit, + nextOffset: endOffset < records.length ? endOffset : null }); } @@ -1626,17 +1660,71 @@ async function readRecord(id: string): Promise { } async function loadAllRecords(): Promise { - const fileNames = await readdir(RECORDS_DIR); - const records = await Promise.all( - fileNames - .filter((name) => name.endsWith(".json")) - .map(async (name) => { - const id = name.slice(0, -".json".length); - return SHARE_ID_PATTERN.test(id) ? readRecord(id) : null; - }) - ); + const directory = await opendir(RECORDS_DIR); + const records: ShareRecord[] = []; + let directoryEntries = 0; + let recordFiles = 0; + + for await (const entry of directory) { + directoryEntries += 1; + assertShareRecordDirectoryEntryBudget(directoryEntries); + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + + recordFiles += 1; + if (recordFiles > MAX_SHARE_RECORDS) { + throw new Error(`Share record capacity exceeded (${MAX_SHARE_RECORDS}).`); + } + + const id = entry.name.slice(0, -".json".length); + const record = SHARE_ID_PATTERN.test(id) ? await readRecord(id) : null; + if (record) { + records.push(record); + } + } + + return records; +} + +async function hasShareRecordCapacity(): Promise { + const initialCount = await countShareRecordFiles(); + if (initialCount < MAX_SHARE_RECORDS) { + return true; + } + if (initialCount > MAX_SHARE_RECORDS) { + return false; + } + + await pruneExpiredShareRecords(Date.now()); + return (await countShareRecordFiles()) < MAX_SHARE_RECORDS; +} + +async function countShareRecordFiles(): Promise { + const directory = await opendir(RECORDS_DIR); + let directoryEntries = 0; + let recordFiles = 0; + + for await (const entry of directory) { + directoryEntries += 1; + assertShareRecordDirectoryEntryBudget(directoryEntries); + if (entry.isFile() && entry.name.endsWith(".json")) { + recordFiles += 1; + if (recordFiles > MAX_SHARE_RECORDS) { + return recordFiles; + } + } + } + + return recordFiles; +} - return records.filter((record): record is ShareRecord => Boolean(record)); +function assertShareRecordDirectoryEntryBudget(entries: number): void { + if (entries > MAX_SHARE_RECORD_DIRECTORY_ENTRIES) { + throw new Error( + `Share record directory entry limit exceeded (${MAX_SHARE_RECORD_DIRECTORY_ENTRIES}).` + ); + } } async function writeRecord(record: ShareRecord): Promise { @@ -1849,24 +1937,54 @@ function resolveShareTtlMs(request: IncomingMessage): number { return Math.min(SHARE_MAX_TTL_MS, Math.max(1_000, requestedTtl)); } +function parseShareListPagination(requestUrl: URL): { offset: number; limit: number } | null { + const offset = parseBoundedQueryInteger( + requestUrl.searchParams.get("offset"), + 0, + 0, + MAX_SHARE_RECORDS + ); + const limit = parseBoundedQueryInteger( + requestUrl.searchParams.get("limit"), + DEFAULT_SHARE_LIST_LIMIT, + 1, + MAX_SHARE_LIST_LIMIT + ); + return offset === null || limit === null ? null : { offset, limit }; +} + +function parseBoundedQueryInteger( + raw: string | null, + fallback: number, + minimum: number, + maximum: number +): number | null { + if (raw === null) { + return fallback; + } + if (!/^\d+$/.test(raw)) { + return null; + } + const parsed = Number(raw); + return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum ? parsed : null; +} + async function pruneExpiredShareRecords(now: number): Promise { const records = await loadAllRecords(); const retentionMs = Math.max(0, SHARE_RETAIN_EXPIRED_MS); - await Promise.all( - records.map(async (record) => { - const retentionDeadline = resolveShareExpiresAt(record) + retentionMs; + for (const record of records) { + const retentionDeadline = resolveShareExpiresAt(record) + retentionMs; - if (retentionDeadline > now) { - return; - } + if (retentionDeadline > now) { + continue; + } - await Promise.all([ - rm(recordPathForId(record.id), { force: true }), - rm(archivePathForId(record.id), { force: true }) - ]); - }) - ); + await Promise.all([ + rm(recordPathForId(record.id), { force: true }), + rm(archivePathForId(record.id), { force: true }) + ]); + } } async function writeShareAuditEvent( From 38bbbe1864b4b132616e19a05fa5f97fe9c8eada Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:00:45 +0800 Subject: [PATCH 053/181] fix(share): make storage commits crash consistent --- apps/share-server/README.md | 2 +- apps/share-server/src/index.test.ts | 61 ++++++++++++-- apps/share-server/src/index.ts | 121 ++++++++++++++++++++++++++-- 3 files changed, 167 insertions(+), 17 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 2b42fec..37085fc 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -137,7 +137,7 @@ Each share writes: - `records/.json` (redacted public summary only) - `audit/share-access.jsonl` (action, outcome, share id, timestamp, and client hash only) -Uploads are streamed to mode-`0600` temporary files under `archives/`, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. +New storage directories are mode `0700`. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. Audit logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. Audit append failures are reported through operational logs but do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 9150831..522e4dd 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { createRequire } from "node:module"; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { request as createHttpRequest } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; @@ -794,6 +794,44 @@ describe("share-server", () => { await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toHaveLength(2); }); + it("atomically persists records and reconciles interrupted storage on restart", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const validRecordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const validRecord = JSON.parse(await readFile(validRecordPath, "utf8")) as Record< + string, + unknown + >; + expect((await stat(validRecordPath)).mode & 0o777).toBe(0o600); + + const orphanArchiveId = "c".repeat(32); + const orphanRecordId = "d".repeat(32); + await writeFile( + resolve(server.dataDir, "archives", `${orphanArchiveId}.webblackbox`), + "orphan" + ); + await writeFile( + resolve(server.dataDir, "records", `${orphanRecordId}.json`), + JSON.stringify({ ...validRecord, id: orphanRecordId }) + ); + await writeFile(resolve(server.dataDir, "archives", ".interrupted.upload"), "partial"); + await writeFile(resolve(server.dataDir, "records", ".interrupted.record"), "partial"); + + await stopShareServer(server, false); + const restarted = await startShareServer({}, server.dataDir); + + await expect(readdir(resolve(restarted.dataDir, "archives"))).resolves.toEqual([ + `${upload.shareId}.webblackbox` + ]); + await expect(readdir(resolve(restarted.dataDir, "records"))).resolves.toEqual([ + `${upload.shareId}.json` + ]); + const metadataResponse = await fetch(`${restarted.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(metadataResponse.status).toBe(200); + }); + it("expires shares and blocks archive download after ttl", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000" @@ -1102,10 +1140,11 @@ describe("share-server", () => { }); async function startShareServer( - envOverrides: Record = {} + envOverrides: Record = {}, + existingDataDir?: string ): Promise { const port = await reservePort(); - const dataDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-test-")); + const dataDir = existingDataDir ?? (await mkdtemp(resolve(tmpdir(), "webblackbox-share-test-"))); const child = spawn(process.execPath, [tsxCli, resolve(appRoot, "src/index.ts")], { cwd: appRoot, env: { @@ -1138,7 +1177,7 @@ async function startShareServer( return server; } -async function stopShareServer(server: RunningShareServer): Promise { +async function stopShareServer(server: RunningShareServer, removeData = true): Promise { if (server.child.exitCode === null && !server.child.killed) { server.child.kill("SIGTERM"); await new Promise((resolve) => { @@ -1150,10 +1189,16 @@ async function stopShareServer(server: RunningShareServer): Promise { }); } - await rm(server.dataDir, { - recursive: true, - force: true - }); + if (!removeData) { + runningServers = runningServers.filter((candidate) => candidate !== server); + } + + if (removeData) { + await rm(server.dataDir, { + recursive: true, + force: true + }); + } } async function waitForShareServer(server: RunningShareServer): Promise { diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 66ec95d..eecae84 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { createReadStream } from "node:fs"; -import { appendFile, mkdir, open, opendir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { appendFile, mkdir, open, opendir, rename, rm, stat } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -227,12 +227,13 @@ void startShareServer().catch((error) => { }); async function startShareServer(): Promise { - await ensureStorageLayout(); - await pruneExpiredShareRecords(Date.now()); - const port = parsePort(process.env.PORT); const host = parseBindHost(process.env.WEBBLACKBOX_SHARE_BIND_HOST); sharePublicOrigin = resolvePublicOrigin(process.env.WEBBLACKBOX_SHARE_PUBLIC_ORIGIN, host, port); + await ensureStorageLayout(); + await reconcileStorageLayout(); + await pruneExpiredShareRecords(Date.now()); + const server = createServer((request, response) => { void routeRequest(request, response).catch((error) => { console.warn("[share-server] request failed", error); @@ -621,6 +622,7 @@ async function processUploadWithinInspectionSlot( }; await rename(tempPath, archivePath); + await syncDirectoryBestEffort(ARCHIVES_DIR); await writeRecord(record); upload.committed = true; await writeShareAuditEvent(request, { @@ -1620,17 +1622,81 @@ function roundTo(value: number, digits: number): number { } async function ensureStorageLayout(): Promise { + await mkdir(DATA_ROOT, { + recursive: true, + mode: 0o700 + }); await mkdir(ARCHIVES_DIR, { - recursive: true + recursive: true, + mode: 0o700 }); await mkdir(RECORDS_DIR, { - recursive: true + recursive: true, + mode: 0o700 }); await mkdir(AUDIT_DIR, { - recursive: true + recursive: true, + mode: 0o700 }); } +async function reconcileStorageLayout(): Promise { + const archiveIds = await collectStoredIds(ARCHIVES_DIR, ".webblackbox", ".upload"); + const recordIds = await collectStoredIds(RECORDS_DIR, ".json", ".record"); + + for (const id of recordIds) { + if (archiveIds.has(id) && (await readRecord(id))) { + continue; + } + + await Promise.all([ + rm(recordPathForId(id), { force: true }), + rm(archivePathForId(id), { force: true }) + ]); + archiveIds.delete(id); + } + + for (const id of archiveIds) { + if (!recordIds.has(id)) { + await rm(archivePathForId(id), { force: true }); + } + } +} + +async function collectStoredIds( + directoryPath: string, + committedSuffix: string, + temporarySuffix: string +): Promise> { + const directory = await opendir(directoryPath); + const ids = new Set(); + let directoryEntries = 0; + + for await (const entry of directory) { + directoryEntries += 1; + assertShareRecordDirectoryEntryBudget(directoryEntries); + if (!entry.isFile()) { + continue; + } + + if (entry.name.startsWith(".") && entry.name.endsWith(temporarySuffix)) { + await rm(join(directoryPath, entry.name), { force: true }); + continue; + } + + if (!entry.name.endsWith(committedSuffix)) { + continue; + } + + const id = entry.name.slice(0, -committedSuffix.length); + if (SHARE_ID_PATTERN.test(id)) { + ids.add(id); + } + } + + return ids; +} + async function readRecord(id: string): Promise { if (!SHARE_ID_PATTERN.test(id)) { return null; @@ -1728,7 +1794,45 @@ function assertShareRecordDirectoryEntryBudget(entries: number): void { } async function writeRecord(record: ShareRecord): Promise { - await writeFile(recordPathForId(record.id), JSON.stringify(record, null, 2)); + const payload = Buffer.from(JSON.stringify(record, null, 2), "utf8"); + if (payload.byteLength > MAX_SHARE_RECORD_BYTES) { + throw new Error(`Share record exceeds ${MAX_SHARE_RECORD_BYTES} bytes.`); + } + + const temporaryPath = join( + RECORDS_DIR, + `.${record.id}.${randomUUID().replaceAll("-", "")}.record` + ); + let handle: Awaited> | null = null; + let committed = false; + + try { + handle = await open(temporaryPath, "wx", 0o600); + await writeFileHandleFully(handle, payload); + await handle.sync(); + await handle.close(); + handle = null; + await rename(temporaryPath, recordPathForId(record.id)); + committed = true; + await syncDirectoryBestEffort(RECORDS_DIR); + } finally { + await handle?.close(); + if (!committed) { + await rm(temporaryPath, { force: true }); + } + } +} + +async function syncDirectoryBestEffort(directoryPath: string): Promise { + let handle: Awaited> | null = null; + try { + handle = await open(directoryPath, "r"); + await handle.sync(); + } catch { + // Some supported filesystems do not allow directory handles to be fsynced. + } finally { + await handle?.close(); + } } function normalizePersistedShareRecord(value: unknown, expectedId: string): ShareRecord | null { @@ -2178,6 +2282,7 @@ async function readRequestBodyToTempFile( throw timeoutError ?? new Error("Upload request was aborted."); } + await handle.sync(); const storedStat = await handle.stat(); if (storedStat.size !== totalBytes) { throw new Error("Upload temporary file size changed unexpectedly."); From 4c13b10b35500f4794179666100927ec9bf983c3 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:05:37 +0800 Subject: [PATCH 054/181] fix(share): complete bounded audit lifecycle --- apps/share-server/README.md | 3 +- apps/share-server/src/index.test.ts | 31 +++++ apps/share-server/src/index.ts | 207 +++++++++++++++++++++++++++- 3 files changed, 237 insertions(+), 4 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 37085fc..fa70fb3 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -40,6 +40,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days). - `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). - `WEBBLACKBOX_SHARE_MAX_RECORDS`: maximum retained record files admitted by one server (default and hard ceiling `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. +- `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). - `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: set `true` only behind a trusted proxy; otherwise upload rate limiting uses socket IP. @@ -139,5 +140,5 @@ Each share writes: New storage directories are mode `0700`. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. -Audit logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. +Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. Audit append failures are reported through operational logs but do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 522e4dd..5936872 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -941,6 +941,37 @@ describe("share-server", () => { expect(auditLog).not.toContain("webblackbox-share-"); }); + it("audits authorization failures within a bounded rotating log", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES: "512" + }); + const rejectedKey = "raw-rejected-api-key"; + + for (let index = 0; index < 8; index += 1) { + const response = await fetch(`${server.baseUrl}/api/share/list`, { + headers: { + "x-webblackbox-api-key": rejectedKey + } + }); + expect(response.status).toBe(401); + } + + const auditFiles = await readdir(resolve(server.dataDir, "audit")); + expect(auditFiles).toEqual( + expect.arrayContaining(["share-access.jsonl", "share-access.1.jsonl"]) + ); + const auditLogs = await Promise.all( + auditFiles.map((name) => readFile(resolve(server.dataDir, "audit", name), "utf8")) + ); + expect(auditLogs.join("\n")).toContain('"outcome":"blocked"'); + expect(auditLogs.join("\n")).toContain('"reason":"unauthorized"'); + expect(auditLogs.join("\n")).not.toContain(rejectedKey); + + for (const name of auditFiles) { + expect((await stat(resolve(server.dataDir, "audit", name))).size).toBeLessThanOrEqual(512); + } + }); + it("keeps committed operations successful when the audit sink is unavailable", async () => { const server = await startShareServer(); const auditPath = resolve(server.dataDir, "audit/share-access.jsonl"); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index eecae84..1aec7cb 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -162,6 +162,7 @@ const ARCHIVES_DIR = join(DATA_ROOT, "archives"); const RECORDS_DIR = join(DATA_ROOT, "records"); const AUDIT_DIR = join(DATA_ROOT, "audit"); const SHARE_AUDIT_LOG_PATH = join(AUDIT_DIR, "share-access.jsonl"); +const SHARE_AUDIT_ROTATED_LOG_PATH = join(AUDIT_DIR, "share-access.1.jsonl"); const SHARE_API_KEY = readOptionalSecret(process.env.WEBBLACKBOX_SHARE_API_KEY); const SHARE_API_CREDENTIALS = parseShareApiCredentials( process.env.WEBBLACKBOX_SHARE_API_KEYS, @@ -212,6 +213,10 @@ const MAX_SHARE_RECORDS = Math.min( const MAX_SHARE_RECORD_DIRECTORY_ENTRIES = 20_000; const DEFAULT_SHARE_LIST_LIMIT = 100; const MAX_SHARE_LIST_LIMIT = 200; +const MAX_SHARE_AUDIT_LOG_BYTES = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES, 16 * 1024 * 1024), + 64 * 1024 * 1024 +); const MAX_SHARE_READ_SESSIONS = 4096; const RATE_LIMIT_CLEANUP_INTERVAL = 64; const MAX_TRACKED_RATE_BUCKETS = 4096; @@ -220,6 +225,7 @@ const shareReadSessions = new Map(); let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; let sharePublicOrigin = DEFAULT_BASE_URL; +let auditWriteQueue = Promise.resolve(); void startShareServer().catch((error) => { console.error("[share-server] startup failed", error); @@ -235,9 +241,20 @@ async function startShareServer(): Promise { await pruneExpiredShareRecords(Date.now()); const server = createServer((request, response) => { - void routeRequest(request, response).catch((error) => { + void routeRequest(request, response).catch(async (error) => { console.warn("[share-server] request failed", error); + const auditContext = resolveShareAuditRequest(request); + if (auditContext) { + await writeShareAuditEvent(request, { + ...auditContext, + outcome: "error", + details: { + reason: "request-failed" + } + }); + } + if (response.headersSent || response.writableEnded) { if (!response.writableEnded) { response.destroy(error instanceof Error ? error : undefined); @@ -272,10 +289,21 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): const pathname = requestUrl.pathname; const requiredScope = resolveRequiredShareScope(method, pathname); + const auditContext = resolveShareAuditContext(method, pathname); if (requiredScope) { const authorization = authorizeRequest(request, requestUrl, method, pathname, requiredScope); if (!authorization.authorized) { + if (auditContext) { + await writeShareAuditEvent(request, { + ...auditContext, + outcome: "blocked", + details: { + reason: "unauthorized" + } + }); + } + if (pathname.startsWith("/share/")) { respondHtml(response, 401, "

Unauthorized

Provide a valid share API key.

"); return; @@ -289,6 +317,14 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): const readShareId = requiredScope === "read" ? extractReadShareId(pathname) : null; if (readShareId && authorization.source === "query") { issueShareReadSessionCookie(response, readShareId); + await writeShareAuditEvent(request, { + action: "page", + shareId: readShareId, + outcome: "ok", + details: { + authorization: "query-bootstrap" + } + }); redirectToUrlWithoutQueryKey(response, requestUrl); return; } @@ -351,6 +387,14 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): if (!rateLimited.ok) { closeRequestAfterResponse(request, response); + await writeShareAuditEvent(request, { + action: "upload", + outcome: "blocked", + details: { + reason: "rate-limit", + retryAfterSec: rateLimited.retryAfterSec + } + }); respondJson(response, 429, { error: `Upload rate limit exceeded. Retry in ${rateLimited.retryAfterSec}s.` }); @@ -360,6 +404,13 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): if (!tryAcquireUploadInspectionSlot()) { closeRequestAfterResponse(request, response); response.setHeader("retry-after", "1"); + await writeShareAuditEvent(request, { + action: "upload", + outcome: "blocked", + details: { + reason: "inspection-capacity" + } + }); respondJson(response, 503, { error: "Archive inspection capacity is temporarily exhausted." }); @@ -369,6 +420,14 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): try { if (!(await hasShareRecordCapacity())) { closeRequestAfterResponse(request, response); + await writeShareAuditEvent(request, { + action: "upload", + outcome: "blocked", + details: { + reason: "record-capacity", + limit: MAX_SHARE_RECORDS + } + }); respondJson(response, 507, { error: `Share record capacity is exhausted (${MAX_SHARE_RECORDS}). Revoke or expire retained shares before uploading.` }); @@ -420,12 +479,29 @@ async function processUploadWithinInspectionSlot( } catch (error) { if (error instanceof PayloadTooLargeError) { closeRequestAfterResponse(request, response); + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked", + details: { + reason: "payload-too-large", + limit: error.maxBytes + } + }); respondJson(response, 413, { error: `Upload payload exceeds ${error.maxBytes} bytes.` }); return; } if (error instanceof UploadTimeoutError || request.aborted) { + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "error", + details: { + reason: error instanceof UploadTimeoutError ? `upload-${error.kind}-timeout` : "aborted" + } + }); return; } @@ -433,6 +509,14 @@ async function processUploadWithinInspectionSlot( } if (bytes.byteLength === 0) { + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked", + details: { + reason: "empty-payload" + } + }); respondJson(response, 400, { error: "Upload payload is empty." }); @@ -450,6 +534,14 @@ async function processUploadWithinInspectionSlot( clientSummary = readClientShareSummary(request, checksumSha256); } catch (error) { if (error instanceof ShareSummaryHeaderError) { + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked", + details: { + reason: "invalid-client-claim" + } + }); respondJson(response, 400, { error: error.message }); @@ -523,6 +615,14 @@ async function processUploadWithinInspectionSlot( assertClientShareClaimMatchesArchive(clientSummary, archiveEnvelope); } catch (error) { if (error instanceof ShareSummaryHeaderError) { + await writeShareAuditEvent(request, { + action: "upload", + shareId: id, + outcome: "blocked", + details: { + reason: "client-claim-conflict" + } + }); respondJson(response, 400, { error: error.message }); return; } @@ -652,6 +752,13 @@ async function handleList( ): Promise { const pagination = parseShareListPagination(requestUrl); if (!pagination) { + await writeShareAuditEvent(request, { + action: "list", + outcome: "blocked", + details: { + reason: "invalid-pagination" + } + }); respondJson(response, 400, { error: `List pagination requires integer offset >= 0 and limit between 1 and ${MAX_SHARE_LIST_LIMIT}.` }); @@ -2109,9 +2216,12 @@ async function writeShareAuditEvent( clientHash: hashAuditValue(resolveClientKey(request)), details: input.details }; + const line = `${JSON.stringify(event)}\n`; + const writeOperation = auditWriteQueue.then(() => appendShareAuditLine(line)); + auditWriteQueue = writeOperation.catch(() => undefined); try { - await appendFile(SHARE_AUDIT_LOG_PATH, `${JSON.stringify(event)}\n`, "utf8"); + await writeOperation; } catch (error) { console.warn("[share-server] audit append failed", { action: input.action, @@ -2121,6 +2231,47 @@ async function writeShareAuditEvent( } } +async function appendShareAuditLine(line: string): Promise { + const lineBytes = Buffer.byteLength(line, "utf8"); + if (lineBytes > MAX_SHARE_AUDIT_LOG_BYTES) { + throw new Error("Share audit event exceeds the configured log size ceiling."); + } + + let currentBytes = 0; + try { + const current = await stat(SHARE_AUDIT_LOG_PATH); + currentBytes = current.isFile() ? current.size : 0; + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + + if (currentBytes > 0 && currentBytes + lineBytes > MAX_SHARE_AUDIT_LOG_BYTES) { + await rm(SHARE_AUDIT_ROTATED_LOG_PATH, { force: true }); + try { + await rename(SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH); + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + } + + await appendFile(SHARE_AUDIT_LOG_PATH, line, { + encoding: "utf8", + mode: 0o600 + }); +} + +function isFileNotFoundError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as Error & { code?: unknown }).code === "ENOENT" + ); +} + function hashAuditValue(value: string): string { return createHash("sha256").update(`webblackbox-share-audit:${value}`).digest("hex"); } @@ -2366,7 +2517,7 @@ class PayloadTooLargeError extends Error { } class UploadTimeoutError extends Error { - public constructor(kind: "idle" | "total") { + public constructor(public readonly kind: "idle" | "total") { super(`Upload ${kind} timeout exceeded.`); } } @@ -2527,6 +2678,56 @@ function resolveAllowedOrigin( return originHeader === SHARE_ALLOWED_ORIGIN ? SHARE_ALLOWED_ORIGIN : null; } +function resolveShareAuditRequest( + request: IncomingMessage +): { action: ShareAuditAction; shareId?: string } | null { + try { + const requestUrl = new URL(request.url ?? "/", sharePublicOrigin); + return resolveShareAuditContext(request.method ?? "GET", requestUrl.pathname); + } catch { + return null; + } +} + +function resolveShareAuditContext( + method: string, + pathname: string +): { action: ShareAuditAction; shareId?: string } | null { + if (method === "POST" && pathname === "/api/share/upload") { + return { action: "upload" }; + } + if (method === "GET" && pathname === "/api/share/list") { + return { action: "list" }; + } + + const route = + /^\/(?:api\/share\/([a-zA-Z0-9_-]{1,128})\/(meta|archive|revoke)|share\/([a-zA-Z0-9_-]{1,128}))$/.exec( + pathname + ); + if (!route) { + return null; + } + + const shareId = route[1] ?? route[3]; + const routeKind = route[2] ?? "page"; + if (!shareId) { + return null; + } + if (method === "GET" && routeKind === "meta") { + return { action: "metadata", shareId }; + } + if (method === "GET" && routeKind === "archive") { + return { action: "download", shareId }; + } + if (method === "GET" && routeKind === "page") { + return { action: "page", shareId }; + } + if (method === "POST" && routeKind === "revoke") { + return { action: "revoke", shareId }; + } + return null; +} + function resolveRequiredShareScope(method: string, pathname: string): ShareApiScope | null { if (method === "POST" && pathname === "/api/share/upload") { return "upload"; From 116e75fa5993894a4412cb2318c11cde7ed3ea34 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:08:23 +0800 Subject: [PATCH 055/181] fix(share): constrain forwarded client trust --- apps/share-server/README.md | 5 +- apps/share-server/src/index.test.ts | 43 ++++++++++++++++ apps/share-server/src/index.ts | 77 ++++++++++++++++++++++------- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index fa70fb3..ed111f7 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -29,7 +29,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_API_KEYS`: semicolon-separated scoped keys for rotation and least privilege. Format: `secret:scope,scope;next-secret:scope`. Supported scopes are `upload`, `read`, `list`, `revoke`, and `admin`. `admin` covers all scopes. Unknown or empty scopes and duplicate secrets fail startup instead of falling back to `admin`. A key without `:scope` retains the legacy explicit-admin behavior. Keep an old key and a new key configured during rotation, then remove the old key after clients are updated. - `WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY`: optional browser bootstrap for `GET /share/:id?key=`. Keep this disabled in production unless the key is short-lived; when enabled, the server redirects to a clean URL and uses a short HttpOnly read-session cookie for page links. - `WEBBLACKBOX_SHARE_BIND_HOST`: bind host for the HTTP server (default `127.0.0.1`). -- `WEBBLACKBOX_SHARE_PUBLIC_ORIGIN`: canonical external origin used for share URLs, same-origin CORS, and secure-cookie policy. It is required when binding to a non-loopback host, must be HTTPS unless it names a loopback host, and must not include credentials, a path, query, or fragment. Request `Host` and `X-Forwarded-Proto` headers are never used as public-origin authority. +- `WEBBLACKBOX_SHARE_PUBLIC_ORIGIN`: canonical external origin used for share URLs, same-origin CORS, and secure-cookie policy. It is required when binding to a non-loopback host, must be HTTPS unless it names a loopback host, and must not include credentials, a path, query, or fragment. A non-loopback public origin also requires an API credential, including when a local reverse proxy fronts a loopback bind. Request `Host` and `X-Forwarded-Proto` headers are never used as public-origin authority. - `WEBBLACKBOX_SHARE_ALLOWED_ORIGIN`: CORS allow origin. Defaults to `same-origin`. Use `*` only for trusted environments. - `WEBBLACKBOX_SHARE_MAX_UPLOAD_BYTES`: max accepted upload body size in bytes (default `262144000`, hard-capped by the Player SDK's 256 MiB input ceiling). - `WEBBLACKBOX_SHARE_MAX_CONCURRENT_INSPECTIONS`: maximum upload bodies that may be received and inspected concurrently (default `1`). Additional uploads receive `503` with `Retry-After`. @@ -43,7 +43,8 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). -- `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: set `true` only behind a trusted proxy; otherwise upload rate limiting uses socket IP. +- `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: enables proxy-aware client addresses for rate limiting and redacted audit hashes only; authorization never trusts forwarded headers. Forwarded chains are used only when the socket peer is loopback or listed in `WEBBLACKBOX_TRUSTED_PROXY_IPS`, and the first untrusted hop is selected from right to left. +- `WEBBLACKBOX_TRUSTED_PROXY_IPS`: comma-separated exact proxy IPs allowed to extend the trusted forwarded chain. The edge proxy must append or overwrite `X-Forwarded-For`; invalid IP configuration fails startup. Loopback proxy peers are trusted automatically when forwarded support is enabled. For production, prefer scoped keys over a single admin key: diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 5936872..9dbfa6b 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1138,6 +1138,49 @@ describe("share-server", () => { ).rejects.toThrow(/must use HTTPS for non-loopback hosts/); }); + it("requires credentials for remote public origins and validates trusted proxy IPs", async () => { + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_API_KEY: "", + WEBBLACKBOX_SHARE_API_KEYS: "", + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "https://shares.example.test" + }) + ).rejects.toThrow(/credential is required for a non-loopback public origin/); + + await expect( + startShareServer({ + WEBBLACKBOX_TRUSTED_PROXY_IPS: "not-an-ip" + }) + ).rejects.toThrow(/contains an invalid IP address/); + }); + + it("derives forwarded clients from the first untrusted hop next to trusted proxies", async () => { + const server = await startShareServer({ + WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true" + }); + const response = await fetch(`${server.baseUrl}/api/share/list`, { + headers: { + "x-webblackbox-api-key": apiKey, + "x-forwarded-for": "198.51.100.99, 203.0.113.10" + } + }); + expect(response.status).toBe(200); + + const auditLog = await readFile(resolve(server.dataDir, "audit", "share-access.jsonl"), "utf8"); + const events = auditLog + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { action: string; clientHash: string }); + const expectedHash = createHash("sha256") + .update("webblackbox-share-audit:ip:203.0.113.10") + .digest("hex"); + const spoofedHash = createHash("sha256") + .update("webblackbox-share-audit:ip:198.51.100.99") + .digest("hex"); + expect(events.at(-1)).toMatchObject({ action: "list", clientHash: expectedHash }); + expect(events.at(-1)?.clientHash).not.toBe(spoofedHash); + }); + it("supports opt-in query API key bootstrap without propagating the key", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY: "true" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 1aec7cb..11d3fd6 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { createReadStream } from "node:fs"; import { appendFile, mkdir, open, opendir, rename, rm, stat } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { isIP } from "node:net"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -170,6 +171,9 @@ const SHARE_API_CREDENTIALS = parseShareApiCredentials( ); const SHARE_ALLOWED_ORIGIN = normalizeAllowedOrigin(process.env.WEBBLACKBOX_SHARE_ALLOWED_ORIGIN); const TRUST_X_FORWARDED_FOR = parseBooleanFlag(process.env.WEBBLACKBOX_TRUST_X_FORWARDED_FOR); +const TRUSTED_PROXY_ADDRESSES = parseTrustedProxyAddresses( + process.env.WEBBLACKBOX_TRUSTED_PROXY_IPS +); const ALLOW_QUERY_API_KEY = parseBooleanFlag(process.env.WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY); const ALLOW_PLAINTEXT_SHARE_UPLOADS = parseBooleanFlag( process.env.WEBBLACKBOX_SHARE_ALLOW_PLAINTEXT_UPLOADS @@ -236,6 +240,11 @@ async function startShareServer(): Promise { const port = parsePort(process.env.PORT); const host = parseBindHost(process.env.WEBBLACKBOX_SHARE_BIND_HOST); sharePublicOrigin = resolvePublicOrigin(process.env.WEBBLACKBOX_SHARE_PUBLIC_ORIGIN, host, port); + if (SHARE_API_CREDENTIALS.length === 0 && !isLoopbackHost(new URL(sharePublicOrigin).hostname)) { + throw new Error( + "A scoped WEBBLACKBOX_SHARE_API_KEY or WEBBLACKBOX_SHARE_API_KEYS credential is required for a non-loopback public origin." + ); + } await ensureStorageLayout(); await reconcileStorageLayout(); await pruneExpiredShareRecords(Date.now()); @@ -2910,32 +2919,50 @@ function readCookie(request: IncomingMessage, name: string): string | null { } function isLoopbackRequest(request: IncomingMessage): boolean { - const address = resolveClientAddress(request); + const address = resolvePeerAddress(request); return Boolean(address && isLoopbackAddress(address)); } function resolveClientAddress(request: IncomingMessage): string | null { - if (TRUST_X_FORWARDED_FOR) { - const forwardedFor = request.headers["x-forwarded-for"]; - if (typeof forwardedFor === "string" && forwardedFor.length > 0) { - const first = forwardedFor.split(",")[0]?.trim(); - if (first) { - return first; - } - } + const peerAddress = resolvePeerAddress(request); + if (!TRUST_X_FORWARDED_FOR || !peerAddress || !isTrustedProxyAddress(peerAddress)) { + return peerAddress; + } + + const forwardedFor = request.headers["x-forwarded-for"]; + if (typeof forwardedFor !== "string" || forwardedFor.length === 0) { + return peerAddress; + } + + const forwardedChain = forwardedFor.split(",").map(normalizeIpAddress); + if (forwardedChain.some((address) => address === null)) { + return peerAddress; + } + + const chain = [...(forwardedChain as string[]), peerAddress]; + let index = chain.length - 1; + while (index > 0 && isTrustedProxyAddress(chain[index] ?? "")) { + index -= 1; } + return chain[index] ?? peerAddress; +} + +function resolvePeerAddress(request: IncomingMessage): string | null { + return normalizeIpAddress(request.socket.remoteAddress ?? ""); +} - const socketAddress = request.socket.remoteAddress; - return typeof socketAddress === "string" && socketAddress.length > 0 ? socketAddress : null; +function normalizeIpAddress(value: string): string | null { + const trimmed = value.trim().toLowerCase(); + const ipv4Mapped = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed; + return isIP(ipv4Mapped) > 0 ? ipv4Mapped : null; +} + +function isTrustedProxyAddress(address: string): boolean { + return isLoopbackAddress(address) || TRUSTED_PROXY_ADDRESSES.has(address); } function isLoopbackAddress(address: string): boolean { - return ( - address === "127.0.0.1" || - address === "::1" || - address === "::ffff:127.0.0.1" || - address.startsWith("127.") - ); + return address === "::1" || address.startsWith("127."); } function readAuthTokenFromRequest(request: IncomingMessage): string | null { @@ -3079,3 +3106,19 @@ function parseBooleanFlag(value: string | undefined): boolean { const normalized = value.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; } + +function parseTrustedProxyAddresses(value: string | undefined): ReadonlySet { + if (!value?.trim()) { + return new Set(); + } + + const addresses = new Set(); + for (const rawAddress of value.split(",")) { + const address = normalizeIpAddress(rawAddress); + if (!address) { + throw new Error(`WEBBLACKBOX_TRUSTED_PROXY_IPS contains an invalid IP address.`); + } + addresses.add(address); + } + return addresses; +} From cb0b2e0744f562d489f8c279767c170293f6e45b Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:11:55 +0800 Subject: [PATCH 056/181] ci(docs): enforce generated API reference drift --- .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 1 + docs/api/player-sdk/assets/hierarchy.js | 2 +- docs/api/player-sdk/assets/highlight.css | 2 +- docs/api/player-sdk/assets/icons.js | 37 +- docs/api/player-sdk/assets/icons.svg | 2 +- docs/api/player-sdk/assets/main.js | 2326 +---------------- docs/api/player-sdk/assets/navigation.js | 3 +- docs/api/player-sdk/assets/search.js | 3 +- docs/api/player-sdk/assets/style.css | 21 +- .../classes/ArchiveDecodeTimeoutError.html | 190 ++ .../classes/ArchiveResourceLimitError.html | 204 ++ .../player-sdk/classes/BoundedZipReader.html | 118 + .../player-sdk/classes/WebBlackboxPlayer.html | 569 +++- .../assertArchiveInputResourceLimits.html | 44 + .../assertLoadedArchiveResourceLimits.html | 44 + .../functions/getDefaultPlayerStatus.html | 41 +- .../resolveArchiveResourceLimits.html | 43 + docs/api/player-sdk/hierarchy.html | 29 +- docs/api/player-sdk/index.html | 81 +- docs/api/player-sdk/modules.html | 48 +- docs/api/player-sdk/types/ActionSpan.html | 90 +- .../player-sdk/types/ActionTimelineEntry.html | 115 +- .../types/ArchiveResourceLimits.html | 129 + .../player-sdk/types/BugReportOptions.html | 70 +- docs/api/player-sdk/types/DomDiffResult.html | 85 +- .../types/DomDiffTimelineOptions.html | 65 +- docs/api/player-sdk/types/DomSnapshotRef.html | 95 +- .../player-sdk/types/GitHubIssueTemplate.html | 75 +- .../player-sdk/types/JiraIssueTemplate.html | 60 +- .../types/NetworkWaterfallEntry.html | 160 +- .../types/PerformanceArtifactEntry.html | 100 +- docs/api/player-sdk/types/PlayerArchive.html | 86 +- docs/api/player-sdk/types/PlayerBlob.html | 63 + .../player-sdk/types/PlayerComparison.html | 115 +- .../player-sdk/types/PlayerDerivedView.html | 65 +- .../api/player-sdk/types/PlayerOpenInput.html | 36 +- .../player-sdk/types/PlayerOpenOptions.html | 71 +- docs/api/player-sdk/types/PlayerQuery.html | 90 +- docs/api/player-sdk/types/PlayerRange.html | 65 +- .../player-sdk/types/PlayerSearchResult.html | 70 +- docs/api/player-sdk/types/PlayerStatus.html | 36 +- .../types/PlaywrightMockScriptOptions.html | 36 +- .../types/PlaywrightScriptOptions.html | 80 +- .../types/PrivacyProtectionReport.html | 75 + .../types/RealtimeNetworkEntry.html | 120 +- .../types/ReplayDiagnosticEntry.html | 99 + .../player-sdk/types/RequestResponseDiff.html | 111 + .../types/SensitiveDataPreview.html | 63 + .../player-sdk/types/StorageComparison.html | 80 +- .../types/StorageTimelineEntry.html | 110 +- .../types/TeamIssueTemplateOptions.html | 95 +- .../DEFAULT_ARCHIVE_RESOURCE_LIMITS.html | 34 + package.json | 1 + packages/player-sdk/src/index.ts | 3 +- packages/player-sdk/typedoc.json | 1 + scripts/check-api-docs.mjs | 103 + 57 files changed, 4094 insertions(+), 2469 deletions(-) create mode 100644 docs/api/player-sdk/classes/ArchiveDecodeTimeoutError.html create mode 100644 docs/api/player-sdk/classes/ArchiveResourceLimitError.html create mode 100644 docs/api/player-sdk/classes/BoundedZipReader.html create mode 100644 docs/api/player-sdk/functions/assertArchiveInputResourceLimits.html create mode 100644 docs/api/player-sdk/functions/assertLoadedArchiveResourceLimits.html create mode 100644 docs/api/player-sdk/functions/resolveArchiveResourceLimits.html create mode 100644 docs/api/player-sdk/types/ArchiveResourceLimits.html create mode 100644 docs/api/player-sdk/types/PlayerBlob.html create mode 100644 docs/api/player-sdk/types/PrivacyProtectionReport.html create mode 100644 docs/api/player-sdk/types/ReplayDiagnosticEntry.html create mode 100644 docs/api/player-sdk/types/RequestResponseDiff.html create mode 100644 docs/api/player-sdk/types/SensitiveDataPreview.html create mode 100644 docs/api/player-sdk/variables/DEFAULT_ARCHIVE_RESOURCE_LIMITS.html create mode 100644 scripts/check-api-docs.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc9d318..94c8f4b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: API Documentation Drift Check + run: pnpm docs:api:check + - name: Test run: pnpm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7087756..9d6e41d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,7 @@ jobs: pnpm format:check pnpm lint pnpm typecheck + pnpm docs:api:check pnpm test - name: Build workspace diff --git a/docs/api/player-sdk/assets/hierarchy.js b/docs/api/player-sdk/assets/hierarchy.js index a8b6a85..fb85f0a 100644 --- a/docs/api/player-sdk/assets/hierarchy.js +++ b/docs/api/player-sdk/assets/hierarchy.js @@ -1 +1 @@ -window.hierarchyData = "eJyrVirKzy8pVrKKjtVRKkpNy0lNLsnMzytWsqqurQUAmx4Kpg=="; +window.hierarchyData = "eJyrVirKzy8pVrKKjtVRKkpNy0lNLsnMzytWsqqurQUAmx4Kpg==" \ No newline at end of file diff --git a/docs/api/player-sdk/assets/highlight.css b/docs/api/player-sdk/assets/highlight.css index 03cf474..029b1a5 100644 --- a/docs/api/player-sdk/assets/highlight.css +++ b/docs/api/player-sdk/assets/highlight.css @@ -96,4 +96,4 @@ .hl-8 { color: var(--hl-8); } .hl-9 { color: var(--hl-9); } .hl-10 { color: var(--hl-10); } -pre, code { background: var(--code-background); } +pre, code, math[display='block'] { background: var(--code-background); } diff --git a/docs/api/player-sdk/assets/icons.js b/docs/api/player-sdk/assets/icons.js index 9557996..4fbadc5 100644 --- a/docs/api/player-sdk/assets/icons.js +++ b/docs/api/player-sdk/assets/icons.js @@ -1,21 +1,18 @@ -(function () { - addIcons(); - function addIcons() { - if (document.readyState === "loading") - return document.addEventListener("DOMContentLoaded", addIcons); - const svg = document.body.appendChild( - document.createElementNS("http://www.w3.org/2000/svg", "svg") - ); - svg.innerHTML = `MMNEPVFCICPMFPCPTTAAATR`; - svg.style.display = "none"; - if (location.protocol === "file:") updateUseElements(); - } +(function() { + addIcons(); + function addIcons() { + if (document.readyState === "loading") return document.addEventListener("DOMContentLoaded", addIcons); + const svg = document.body.appendChild(document.createElementNS("http://www.w3.org/2000/svg", "svg")); + svg.innerHTML = `MMNEPVFCICPMFPCPTTAAATR`; + svg.style.display = "none"; + if (location.protocol === "file:") updateUseElements(); + } - function updateUseElements() { - document.querySelectorAll("use").forEach((el) => { - if (el.getAttribute("href").includes("#icon-")) { - el.setAttribute("href", el.getAttribute("href").replace(/.*#/, "#")); - } - }); - } -})(); + function updateUseElements() { + document.querySelectorAll("use").forEach(el => { + if (el.getAttribute("href").includes("#icon-")) { + el.setAttribute("href", el.getAttribute("href").replace(/.*#/, "#")); + } + }); + } +})() \ No newline at end of file diff --git a/docs/api/player-sdk/assets/icons.svg b/docs/api/player-sdk/assets/icons.svg index 10db10b..be7798f 100644 --- a/docs/api/player-sdk/assets/icons.svg +++ b/docs/api/player-sdk/assets/icons.svg @@ -1 +1 @@ -MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file +MMNEPVFCICPMFPCPTTAAATR \ No newline at end of file diff --git a/docs/api/player-sdk/assets/main.js b/docs/api/player-sdk/assets/main.js index dcd466e..b0eda9e 100644 --- a/docs/api/player-sdk/assets/main.js +++ b/docs/api/player-sdk/assets/main.js @@ -1,2325 +1,9 @@ "use strict"; -window.translations = { - copy: "Copy", - copied: "Copied!", - normally_hidden: "This member is normally hidden due to your filter settings.", - hierarchy_expand: "Expand", - hierarchy_collapse: "Collapse", - folder: "Folder", - search_index_not_available: "The search index is not available", - search_no_results_found_for_0: "No results found for {0}", - kind_1: "Project", - kind_2: "Module", - kind_4: "Namespace", - kind_8: "Enumeration", - kind_16: "Enumeration Member", - kind_32: "Variable", - kind_64: "Function", - kind_128: "Class", - kind_256: "Interface", - kind_512: "Constructor", - kind_1024: "Property", - kind_2048: "Method", - kind_4096: "Call Signature", - kind_8192: "Index Signature", - kind_16384: "Constructor Signature", - kind_32768: "Parameter", - kind_65536: "Type Literal", - kind_131072: "Type Parameter", - kind_262144: "Accessor", - kind_524288: "Get Signature", - kind_1048576: "Set Signature", - kind_2097152: "Type Alias", - kind_4194304: "Reference", - kind_8388608: "Document" -}; -("use strict"); -(() => { - var Ke = Object.create; - var he = Object.defineProperty; - var Ge = Object.getOwnPropertyDescriptor; - var Ze = Object.getOwnPropertyNames; - var Xe = Object.getPrototypeOf, - Ye = Object.prototype.hasOwnProperty; - var et = (t, e) => () => (e || t((e = { exports: {} }).exports, e), e.exports); - var tt = (t, e, n, r) => { - if ((e && typeof e == "object") || typeof e == "function") - for (let i of Ze(e)) - !Ye.call(t, i) && - i !== n && - he(t, i, { get: () => e[i], enumerable: !(r = Ge(e, i)) || r.enumerable }); - return t; - }; - var nt = (t, e, n) => ( - (n = t != null ? Ke(Xe(t)) : {}), - tt(e || !t || !t.__esModule ? he(n, "default", { value: t, enumerable: !0 }) : n, t) - ); - var ye = et((me, ge) => { - (function () { - var t = function (e) { - var n = new t.Builder(); - return ( - n.pipeline.add(t.trimmer, t.stopWordFilter, t.stemmer), - n.searchPipeline.add(t.stemmer), - e.call(n, n), - n.build() - ); - }; - t.version = "2.3.9"; - ((t.utils = {}), - (t.utils.warn = (function (e) { - return function (n) { - e.console && console.warn && console.warn(n); - }; - })(this)), - (t.utils.asString = function (e) { - return e == null ? "" : e.toString(); - }), - (t.utils.clone = function (e) { - if (e == null) return e; - for (var n = Object.create(null), r = Object.keys(e), i = 0; i < r.length; i++) { - var s = r[i], - o = e[s]; - if (Array.isArray(o)) { - n[s] = o.slice(); - continue; - } - if (typeof o == "string" || typeof o == "number" || typeof o == "boolean") { - n[s] = o; - continue; - } - throw new TypeError("clone is not deep and does not support nested objects"); - } - return n; - }), - (t.FieldRef = function (e, n, r) { - ((this.docRef = e), (this.fieldName = n), (this._stringValue = r)); - }), - (t.FieldRef.joiner = "/"), - (t.FieldRef.fromString = function (e) { - var n = e.indexOf(t.FieldRef.joiner); - if (n === -1) throw "malformed field ref string"; - var r = e.slice(0, n), - i = e.slice(n + 1); - return new t.FieldRef(i, r, e); - }), - (t.FieldRef.prototype.toString = function () { - return ( - this._stringValue == null && - (this._stringValue = this.fieldName + t.FieldRef.joiner + this.docRef), - this._stringValue - ); - })); - ((t.Set = function (e) { - if (((this.elements = Object.create(null)), e)) { - this.length = e.length; - for (var n = 0; n < this.length; n++) this.elements[e[n]] = !0; - } else this.length = 0; - }), - (t.Set.complete = { - intersect: function (e) { - return e; - }, - union: function () { - return this; - }, - contains: function () { - return !0; - } - }), - (t.Set.empty = { - intersect: function () { - return this; - }, - union: function (e) { - return e; - }, - contains: function () { - return !1; - } - }), - (t.Set.prototype.contains = function (e) { - return !!this.elements[e]; - }), - (t.Set.prototype.intersect = function (e) { - var n, - r, - i, - s = []; - if (e === t.Set.complete) return this; - if (e === t.Set.empty) return e; - (this.length < e.length ? ((n = this), (r = e)) : ((n = e), (r = this)), - (i = Object.keys(n.elements))); - for (var o = 0; o < i.length; o++) { - var a = i[o]; - a in r.elements && s.push(a); - } - return new t.Set(s); - }), - (t.Set.prototype.union = function (e) { - return e === t.Set.complete - ? t.Set.complete - : e === t.Set.empty - ? this - : new t.Set(Object.keys(this.elements).concat(Object.keys(e.elements))); - }), - (t.idf = function (e, n) { - var r = 0; - for (var i in e) i != "_index" && (r += Object.keys(e[i]).length); - var s = (n - r + 0.5) / (r + 0.5); - return Math.log(1 + Math.abs(s)); - }), - (t.Token = function (e, n) { - ((this.str = e || ""), (this.metadata = n || {})); - }), - (t.Token.prototype.toString = function () { - return this.str; - }), - (t.Token.prototype.update = function (e) { - return ((this.str = e(this.str, this.metadata)), this); - }), - (t.Token.prototype.clone = function (e) { - return ( - (e = - e || - function (n) { - return n; - }), - new t.Token(e(this.str, this.metadata), this.metadata) - ); - })); - ((t.tokenizer = function (e, n) { - if (e == null || e == null) return []; - if (Array.isArray(e)) - return e.map(function (f) { - return new t.Token(t.utils.asString(f).toLowerCase(), t.utils.clone(n)); - }); - for (var r = e.toString().toLowerCase(), i = r.length, s = [], o = 0, a = 0; o <= i; o++) { - var c = r.charAt(o), - l = o - a; - if (c.match(t.tokenizer.separator) || o == i) { - if (l > 0) { - var d = t.utils.clone(n) || {}; - ((d.position = [a, l]), (d.index = s.length), s.push(new t.Token(r.slice(a, o), d))); - } - a = o + 1; - } - } - return s; - }), - (t.tokenizer.separator = /[\s\-]+/)); - ((t.Pipeline = function () { - this._stack = []; - }), - (t.Pipeline.registeredFunctions = Object.create(null)), - (t.Pipeline.registerFunction = function (e, n) { - (n in this.registeredFunctions && - t.utils.warn("Overwriting existing registered function: " + n), - (e.label = n), - (t.Pipeline.registeredFunctions[e.label] = e)); - }), - (t.Pipeline.warnIfFunctionNotRegistered = function (e) { - var n = e.label && e.label in this.registeredFunctions; - n || - t.utils.warn( - `Function is not registered with pipeline. This may cause problems when serialising the index. -`, - e - ); - }), - (t.Pipeline.load = function (e) { - var n = new t.Pipeline(); - return ( - e.forEach(function (r) { - var i = t.Pipeline.registeredFunctions[r]; - if (i) n.add(i); - else throw new Error("Cannot load unregistered function: " + r); - }), - n - ); - }), - (t.Pipeline.prototype.add = function () { - var e = Array.prototype.slice.call(arguments); - e.forEach(function (n) { - (t.Pipeline.warnIfFunctionNotRegistered(n), this._stack.push(n)); - }, this); - }), - (t.Pipeline.prototype.after = function (e, n) { - t.Pipeline.warnIfFunctionNotRegistered(n); - var r = this._stack.indexOf(e); - if (r == -1) throw new Error("Cannot find existingFn"); - ((r = r + 1), this._stack.splice(r, 0, n)); - }), - (t.Pipeline.prototype.before = function (e, n) { - t.Pipeline.warnIfFunctionNotRegistered(n); - var r = this._stack.indexOf(e); - if (r == -1) throw new Error("Cannot find existingFn"); - this._stack.splice(r, 0, n); - }), - (t.Pipeline.prototype.remove = function (e) { - var n = this._stack.indexOf(e); - n != -1 && this._stack.splice(n, 1); - }), - (t.Pipeline.prototype.run = function (e) { - for (var n = this._stack.length, r = 0; r < n; r++) { - for (var i = this._stack[r], s = [], o = 0; o < e.length; o++) { - var a = i(e[o], o, e); - if (!(a == null || a === "")) - if (Array.isArray(a)) for (var c = 0; c < a.length; c++) s.push(a[c]); - else s.push(a); - } - e = s; - } - return e; - }), - (t.Pipeline.prototype.runString = function (e, n) { - var r = new t.Token(e, n); - return this.run([r]).map(function (i) { - return i.toString(); - }); - }), - (t.Pipeline.prototype.reset = function () { - this._stack = []; - }), - (t.Pipeline.prototype.toJSON = function () { - return this._stack.map(function (e) { - return (t.Pipeline.warnIfFunctionNotRegistered(e), e.label); - }); - })); - ((t.Vector = function (e) { - ((this._magnitude = 0), (this.elements = e || [])); - }), - (t.Vector.prototype.positionForIndex = function (e) { - if (this.elements.length == 0) return 0; - for ( - var n = 0, - r = this.elements.length / 2, - i = r - n, - s = Math.floor(i / 2), - o = this.elements[s * 2]; - i > 1 && (o < e && (n = s), o > e && (r = s), o != e); - ) - ((i = r - n), (s = n + Math.floor(i / 2)), (o = this.elements[s * 2])); - if (o == e || o > e) return s * 2; - if (o < e) return (s + 1) * 2; - }), - (t.Vector.prototype.insert = function (e, n) { - this.upsert(e, n, function () { - throw "duplicate index"; - }); - }), - (t.Vector.prototype.upsert = function (e, n, r) { - this._magnitude = 0; - var i = this.positionForIndex(e); - this.elements[i] == e - ? (this.elements[i + 1] = r(this.elements[i + 1], n)) - : this.elements.splice(i, 0, e, n); - }), - (t.Vector.prototype.magnitude = function () { - if (this._magnitude) return this._magnitude; - for (var e = 0, n = this.elements.length, r = 1; r < n; r += 2) { - var i = this.elements[r]; - e += i * i; - } - return (this._magnitude = Math.sqrt(e)); - }), - (t.Vector.prototype.dot = function (e) { - for ( - var n = 0, - r = this.elements, - i = e.elements, - s = r.length, - o = i.length, - a = 0, - c = 0, - l = 0, - d = 0; - l < s && d < o; - ) - ((a = r[l]), - (c = i[d]), - a < c - ? (l += 2) - : a > c - ? (d += 2) - : a == c && ((n += r[l + 1] * i[d + 1]), (l += 2), (d += 2))); - return n; - }), - (t.Vector.prototype.similarity = function (e) { - return this.dot(e) / this.magnitude() || 0; - }), - (t.Vector.prototype.toArray = function () { - for ( - var e = new Array(this.elements.length / 2), n = 1, r = 0; - n < this.elements.length; - n += 2, r++ - ) - e[r] = this.elements[n]; - return e; - }), - (t.Vector.prototype.toJSON = function () { - return this.elements; - })); - ((t.stemmer = (function () { - var e = { - ational: "ate", - tional: "tion", - enci: "ence", - anci: "ance", - izer: "ize", - bli: "ble", - alli: "al", - entli: "ent", - eli: "e", - ousli: "ous", - ization: "ize", - ation: "ate", - ator: "ate", - alism: "al", - iveness: "ive", - fulness: "ful", - ousness: "ous", - aliti: "al", - iviti: "ive", - biliti: "ble", - logi: "log" - }, - n = { icate: "ic", ative: "", alize: "al", iciti: "ic", ical: "ic", ful: "", ness: "" }, - r = "[^aeiou]", - i = "[aeiouy]", - s = r + "[^aeiouy]*", - o = i + "[aeiou]*", - a = "^(" + s + ")?" + o + s, - c = "^(" + s + ")?" + o + s + "(" + o + ")?$", - l = "^(" + s + ")?" + o + s + o + s, - d = "^(" + s + ")?" + i, - f = new RegExp(a), - p = new RegExp(l), - v = new RegExp(c), - x = new RegExp(d), - w = /^(.+?)(ss|i)es$/, - m = /^(.+?)([^s])s$/, - g = /^(.+?)eed$/, - T = /^(.+?)(ed|ing)$/, - L = /.$/, - C = /(at|bl|iz)$/, - O = new RegExp("([^aeiouylsz])\\1$"), - j = new RegExp("^" + s + i + "[^aeiouwxy]$"), - N = /^(.+?[^aeiou])y$/, - q = - /^(.+?)(ational|tional|enci|anci|izer|bli|alli|entli|eli|ousli|ization|ation|ator|alism|iveness|fulness|ousness|aliti|iviti|biliti|logi)$/, - W = /^(.+?)(icate|ative|alize|iciti|ical|ful|ness)$/, - B = /^(.+?)(al|ance|ence|er|ic|able|ible|ant|ement|ment|ent|ou|ism|ate|iti|ous|ive|ize)$/, - z = /^(.+?)(s|t)(ion)$/, - _ = /^(.+?)e$/, - U = /ll$/, - J = new RegExp("^" + s + i + "[^aeiouwxy]$"), - V = function (u) { - var y, P, k, h, E, Q, H; - if (u.length < 3) return u; - if ( - ((k = u.substr(0, 1)), - k == "y" && (u = k.toUpperCase() + u.substr(1)), - (h = w), - (E = m), - h.test(u) ? (u = u.replace(h, "$1$2")) : E.test(u) && (u = u.replace(E, "$1$2")), - (h = g), - (E = T), - h.test(u)) - ) { - var b = h.exec(u); - ((h = f), h.test(b[1]) && ((h = L), (u = u.replace(h, "")))); - } else if (E.test(u)) { - var b = E.exec(u); - ((y = b[1]), - (E = x), - E.test(y) && - ((u = y), - (E = C), - (Q = O), - (H = j), - E.test(u) - ? (u = u + "e") - : Q.test(u) - ? ((h = L), (u = u.replace(h, ""))) - : H.test(u) && (u = u + "e"))); - } - if (((h = N), h.test(u))) { - var b = h.exec(u); - ((y = b[1]), (u = y + "i")); - } - if (((h = q), h.test(u))) { - var b = h.exec(u); - ((y = b[1]), (P = b[2]), (h = f), h.test(y) && (u = y + e[P])); - } - if (((h = W), h.test(u))) { - var b = h.exec(u); - ((y = b[1]), (P = b[2]), (h = f), h.test(y) && (u = y + n[P])); - } - if (((h = B), (E = z), h.test(u))) { - var b = h.exec(u); - ((y = b[1]), (h = p), h.test(y) && (u = y)); - } else if (E.test(u)) { - var b = E.exec(u); - ((y = b[1] + b[2]), (E = p), E.test(y) && (u = y)); - } - if (((h = _), h.test(u))) { - var b = h.exec(u); - ((y = b[1]), - (h = p), - (E = v), - (Q = J), - (h.test(y) || (E.test(y) && !Q.test(y))) && (u = y)); - } - return ( - (h = U), - (E = p), - h.test(u) && E.test(u) && ((h = L), (u = u.replace(h, ""))), - k == "y" && (u = k.toLowerCase() + u.substr(1)), - u - ); - }; - return function (A) { - return A.update(V); - }; - })()), - t.Pipeline.registerFunction(t.stemmer, "stemmer")); - ((t.generateStopWordFilter = function (e) { - var n = e.reduce(function (r, i) { - return ((r[i] = i), r); - }, {}); - return function (r) { - if (r && n[r.toString()] !== r.toString()) return r; - }; - }), - (t.stopWordFilter = t.generateStopWordFilter([ - "a", - "able", - "about", - "across", - "after", - "all", - "almost", - "also", - "am", - "among", - "an", - "and", - "any", - "are", - "as", - "at", - "be", - "because", - "been", - "but", - "by", - "can", - "cannot", - "could", - "dear", - "did", - "do", - "does", - "either", - "else", - "ever", - "every", - "for", - "from", - "get", - "got", - "had", - "has", - "have", - "he", - "her", - "hers", - "him", - "his", - "how", - "however", - "i", - "if", - "in", - "into", - "is", - "it", - "its", - "just", - "least", - "let", - "like", - "likely", - "may", - "me", - "might", - "most", - "must", - "my", - "neither", - "no", - "nor", - "not", - "of", - "off", - "often", - "on", - "only", - "or", - "other", - "our", - "own", - "rather", - "said", - "say", - "says", - "she", - "should", - "since", - "so", - "some", - "than", - "that", - "the", - "their", - "them", - "then", - "there", - "these", - "they", - "this", - "tis", - "to", - "too", - "twas", - "us", - "wants", - "was", - "we", - "were", - "what", - "when", - "where", - "which", - "while", - "who", - "whom", - "why", - "will", - "with", - "would", - "yet", - "you", - "your" - ])), - t.Pipeline.registerFunction(t.stopWordFilter, "stopWordFilter")); - ((t.trimmer = function (e) { - return e.update(function (n) { - return n.replace(/^\W+/, "").replace(/\W+$/, ""); - }); - }), - t.Pipeline.registerFunction(t.trimmer, "trimmer")); - ((t.TokenSet = function () { - ((this.final = !1), - (this.edges = {}), - (this.id = t.TokenSet._nextId), - (t.TokenSet._nextId += 1)); - }), - (t.TokenSet._nextId = 1), - (t.TokenSet.fromArray = function (e) { - for (var n = new t.TokenSet.Builder(), r = 0, i = e.length; r < i; r++) n.insert(e[r]); - return (n.finish(), n.root); - }), - (t.TokenSet.fromClause = function (e) { - return "editDistance" in e - ? t.TokenSet.fromFuzzyString(e.term, e.editDistance) - : t.TokenSet.fromString(e.term); - }), - (t.TokenSet.fromFuzzyString = function (e, n) { - for (var r = new t.TokenSet(), i = [{ node: r, editsRemaining: n, str: e }]; i.length; ) { - var s = i.pop(); - if (s.str.length > 0) { - var o = s.str.charAt(0), - a; - (o in s.node.edges - ? (a = s.node.edges[o]) - : ((a = new t.TokenSet()), (s.node.edges[o] = a)), - s.str.length == 1 && (a.final = !0), - i.push({ node: a, editsRemaining: s.editsRemaining, str: s.str.slice(1) })); - } - if (s.editsRemaining != 0) { - if ("*" in s.node.edges) var c = s.node.edges["*"]; - else { - var c = new t.TokenSet(); - s.node.edges["*"] = c; - } - if ( - (s.str.length == 0 && (c.final = !0), - i.push({ node: c, editsRemaining: s.editsRemaining - 1, str: s.str }), - s.str.length > 1 && - i.push({ - node: s.node, - editsRemaining: s.editsRemaining - 1, - str: s.str.slice(1) - }), - s.str.length == 1 && (s.node.final = !0), - s.str.length >= 1) - ) { - if ("*" in s.node.edges) var l = s.node.edges["*"]; - else { - var l = new t.TokenSet(); - s.node.edges["*"] = l; - } - (s.str.length == 1 && (l.final = !0), - i.push({ node: l, editsRemaining: s.editsRemaining - 1, str: s.str.slice(1) })); - } - if (s.str.length > 1) { - var d = s.str.charAt(0), - f = s.str.charAt(1), - p; - (f in s.node.edges - ? (p = s.node.edges[f]) - : ((p = new t.TokenSet()), (s.node.edges[f] = p)), - s.str.length == 1 && (p.final = !0), - i.push({ - node: p, - editsRemaining: s.editsRemaining - 1, - str: d + s.str.slice(2) - })); - } - } - } - return r; - }), - (t.TokenSet.fromString = function (e) { - for (var n = new t.TokenSet(), r = n, i = 0, s = e.length; i < s; i++) { - var o = e[i], - a = i == s - 1; - if (o == "*") ((n.edges[o] = n), (n.final = a)); - else { - var c = new t.TokenSet(); - ((c.final = a), (n.edges[o] = c), (n = c)); - } - } - return r; - }), - (t.TokenSet.prototype.toArray = function () { - for (var e = [], n = [{ prefix: "", node: this }]; n.length; ) { - var r = n.pop(), - i = Object.keys(r.node.edges), - s = i.length; - r.node.final && (r.prefix.charAt(0), e.push(r.prefix)); - for (var o = 0; o < s; o++) { - var a = i[o]; - n.push({ prefix: r.prefix.concat(a), node: r.node.edges[a] }); - } - } - return e; - }), - (t.TokenSet.prototype.toString = function () { - if (this._str) return this._str; - for ( - var e = this.final ? "1" : "0", n = Object.keys(this.edges).sort(), r = n.length, i = 0; - i < r; - i++ - ) { - var s = n[i], - o = this.edges[s]; - e = e + s + o.id; - } - return e; - }), - (t.TokenSet.prototype.intersect = function (e) { - for ( - var n = new t.TokenSet(), r = void 0, i = [{ qNode: e, output: n, node: this }]; - i.length; - ) { - r = i.pop(); - for ( - var s = Object.keys(r.qNode.edges), - o = s.length, - a = Object.keys(r.node.edges), - c = a.length, - l = 0; - l < o; - l++ - ) - for (var d = s[l], f = 0; f < c; f++) { - var p = a[f]; - if (p == d || d == "*") { - var v = r.node.edges[p], - x = r.qNode.edges[d], - w = v.final && x.final, - m = void 0; - (p in r.output.edges - ? ((m = r.output.edges[p]), (m.final = m.final || w)) - : ((m = new t.TokenSet()), (m.final = w), (r.output.edges[p] = m)), - i.push({ qNode: x, output: m, node: v })); - } - } - } - return n; - }), - (t.TokenSet.Builder = function () { - ((this.previousWord = ""), - (this.root = new t.TokenSet()), - (this.uncheckedNodes = []), - (this.minimizedNodes = {})); - }), - (t.TokenSet.Builder.prototype.insert = function (e) { - var n, - r = 0; - if (e < this.previousWord) throw new Error("Out of order word insertion"); - for ( - var i = 0; - i < e.length && i < this.previousWord.length && e[i] == this.previousWord[i]; - i++ - ) - r++; - (this.minimize(r), - this.uncheckedNodes.length == 0 - ? (n = this.root) - : (n = this.uncheckedNodes[this.uncheckedNodes.length - 1].child)); - for (var i = r; i < e.length; i++) { - var s = new t.TokenSet(), - o = e[i]; - ((n.edges[o] = s), this.uncheckedNodes.push({ parent: n, char: o, child: s }), (n = s)); - } - ((n.final = !0), (this.previousWord = e)); - }), - (t.TokenSet.Builder.prototype.finish = function () { - this.minimize(0); - }), - (t.TokenSet.Builder.prototype.minimize = function (e) { - for (var n = this.uncheckedNodes.length - 1; n >= e; n--) { - var r = this.uncheckedNodes[n], - i = r.child.toString(); - (i in this.minimizedNodes - ? (r.parent.edges[r.char] = this.minimizedNodes[i]) - : ((r.child._str = i), (this.minimizedNodes[i] = r.child)), - this.uncheckedNodes.pop()); - } - })); - ((t.Index = function (e) { - ((this.invertedIndex = e.invertedIndex), - (this.fieldVectors = e.fieldVectors), - (this.tokenSet = e.tokenSet), - (this.fields = e.fields), - (this.pipeline = e.pipeline)); - }), - (t.Index.prototype.search = function (e) { - return this.query(function (n) { - var r = new t.QueryParser(e, n); - r.parse(); - }); - }), - (t.Index.prototype.query = function (e) { - for ( - var n = new t.Query(this.fields), - r = Object.create(null), - i = Object.create(null), - s = Object.create(null), - o = Object.create(null), - a = Object.create(null), - c = 0; - c < this.fields.length; - c++ - ) - i[this.fields[c]] = new t.Vector(); - e.call(n, n); - for (var c = 0; c < n.clauses.length; c++) { - var l = n.clauses[c], - d = null, - f = t.Set.empty; - l.usePipeline - ? (d = this.pipeline.runString(l.term, { fields: l.fields })) - : (d = [l.term]); - for (var p = 0; p < d.length; p++) { - var v = d[p]; - l.term = v; - var x = t.TokenSet.fromClause(l), - w = this.tokenSet.intersect(x).toArray(); - if (w.length === 0 && l.presence === t.Query.presence.REQUIRED) { - for (var m = 0; m < l.fields.length; m++) { - var g = l.fields[m]; - o[g] = t.Set.empty; - } - break; - } - for (var T = 0; T < w.length; T++) - for ( - var L = w[T], C = this.invertedIndex[L], O = C._index, m = 0; - m < l.fields.length; - m++ - ) { - var g = l.fields[m], - j = C[g], - N = Object.keys(j), - q = L + "/" + g, - W = new t.Set(N); - if ( - (l.presence == t.Query.presence.REQUIRED && - ((f = f.union(W)), o[g] === void 0 && (o[g] = t.Set.complete)), - l.presence == t.Query.presence.PROHIBITED) - ) { - (a[g] === void 0 && (a[g] = t.Set.empty), (a[g] = a[g].union(W))); - continue; - } - if ( - (i[g].upsert(O, l.boost, function (Ue, Je) { - return Ue + Je; - }), - !s[q]) - ) { - for (var B = 0; B < N.length; B++) { - var z = N[B], - _ = new t.FieldRef(z, g), - U = j[z], - J; - (J = r[_]) === void 0 ? (r[_] = new t.MatchData(L, g, U)) : J.add(L, g, U); - } - s[q] = !0; - } - } - } - if (l.presence === t.Query.presence.REQUIRED) - for (var m = 0; m < l.fields.length; m++) { - var g = l.fields[m]; - o[g] = o[g].intersect(f); - } - } - for (var V = t.Set.complete, A = t.Set.empty, c = 0; c < this.fields.length; c++) { - var g = this.fields[c]; - (o[g] && (V = V.intersect(o[g])), a[g] && (A = A.union(a[g]))); - } - var u = Object.keys(r), - y = [], - P = Object.create(null); - if (n.isNegated()) { - u = Object.keys(this.fieldVectors); - for (var c = 0; c < u.length; c++) { - var _ = u[c], - k = t.FieldRef.fromString(_); - r[_] = new t.MatchData(); - } - } - for (var c = 0; c < u.length; c++) { - var k = t.FieldRef.fromString(u[c]), - h = k.docRef; - if (V.contains(h) && !A.contains(h)) { - var E = this.fieldVectors[k], - Q = i[k.fieldName].similarity(E), - H; - if ((H = P[h]) !== void 0) ((H.score += Q), H.matchData.combine(r[k])); - else { - var b = { ref: h, score: Q, matchData: r[k] }; - ((P[h] = b), y.push(b)); - } - } - } - return y.sort(function (We, ze) { - return ze.score - We.score; - }); - }), - (t.Index.prototype.toJSON = function () { - var e = Object.keys(this.invertedIndex) - .sort() - .map(function (r) { - return [r, this.invertedIndex[r]]; - }, this), - n = Object.keys(this.fieldVectors).map(function (r) { - return [r, this.fieldVectors[r].toJSON()]; - }, this); - return { - version: t.version, - fields: this.fields, - fieldVectors: n, - invertedIndex: e, - pipeline: this.pipeline.toJSON() - }; - }), - (t.Index.load = function (e) { - var n = {}, - r = {}, - i = e.fieldVectors, - s = Object.create(null), - o = e.invertedIndex, - a = new t.TokenSet.Builder(), - c = t.Pipeline.load(e.pipeline); - e.version != t.version && - t.utils.warn( - "Version mismatch when loading serialised index. Current version of lunr '" + - t.version + - "' does not match serialized index '" + - e.version + - "'" - ); - for (var l = 0; l < i.length; l++) { - var d = i[l], - f = d[0], - p = d[1]; - r[f] = new t.Vector(p); - } - for (var l = 0; l < o.length; l++) { - var d = o[l], - v = d[0], - x = d[1]; - (a.insert(v), (s[v] = x)); - } - return ( - a.finish(), - (n.fields = e.fields), - (n.fieldVectors = r), - (n.invertedIndex = s), - (n.tokenSet = a.root), - (n.pipeline = c), - new t.Index(n) - ); - })); - ((t.Builder = function () { - ((this._ref = "id"), - (this._fields = Object.create(null)), - (this._documents = Object.create(null)), - (this.invertedIndex = Object.create(null)), - (this.fieldTermFrequencies = {}), - (this.fieldLengths = {}), - (this.tokenizer = t.tokenizer), - (this.pipeline = new t.Pipeline()), - (this.searchPipeline = new t.Pipeline()), - (this.documentCount = 0), - (this._b = 0.75), - (this._k1 = 1.2), - (this.termIndex = 0), - (this.metadataWhitelist = [])); - }), - (t.Builder.prototype.ref = function (e) { - this._ref = e; - }), - (t.Builder.prototype.field = function (e, n) { - if (/\//.test(e)) - throw new RangeError("Field '" + e + "' contains illegal character '/'"); - this._fields[e] = n || {}; - }), - (t.Builder.prototype.b = function (e) { - e < 0 ? (this._b = 0) : e > 1 ? (this._b = 1) : (this._b = e); - }), - (t.Builder.prototype.k1 = function (e) { - this._k1 = e; - }), - (t.Builder.prototype.add = function (e, n) { - var r = e[this._ref], - i = Object.keys(this._fields); - ((this._documents[r] = n || {}), (this.documentCount += 1)); - for (var s = 0; s < i.length; s++) { - var o = i[s], - a = this._fields[o].extractor, - c = a ? a(e) : e[o], - l = this.tokenizer(c, { fields: [o] }), - d = this.pipeline.run(l), - f = new t.FieldRef(r, o), - p = Object.create(null); - ((this.fieldTermFrequencies[f] = p), - (this.fieldLengths[f] = 0), - (this.fieldLengths[f] += d.length)); - for (var v = 0; v < d.length; v++) { - var x = d[v]; - if ((p[x] == null && (p[x] = 0), (p[x] += 1), this.invertedIndex[x] == null)) { - var w = Object.create(null); - ((w._index = this.termIndex), (this.termIndex += 1)); - for (var m = 0; m < i.length; m++) w[i[m]] = Object.create(null); - this.invertedIndex[x] = w; - } - this.invertedIndex[x][o][r] == null && - (this.invertedIndex[x][o][r] = Object.create(null)); - for (var g = 0; g < this.metadataWhitelist.length; g++) { - var T = this.metadataWhitelist[g], - L = x.metadata[T]; - (this.invertedIndex[x][o][r][T] == null && (this.invertedIndex[x][o][r][T] = []), - this.invertedIndex[x][o][r][T].push(L)); - } - } - } - }), - (t.Builder.prototype.calculateAverageFieldLengths = function () { - for ( - var e = Object.keys(this.fieldLengths), n = e.length, r = {}, i = {}, s = 0; - s < n; - s++ - ) { - var o = t.FieldRef.fromString(e[s]), - a = o.fieldName; - (i[a] || (i[a] = 0), (i[a] += 1), r[a] || (r[a] = 0), (r[a] += this.fieldLengths[o])); - } - for (var c = Object.keys(this._fields), s = 0; s < c.length; s++) { - var l = c[s]; - r[l] = r[l] / i[l]; - } - this.averageFieldLength = r; - }), - (t.Builder.prototype.createFieldVectors = function () { - for ( - var e = {}, - n = Object.keys(this.fieldTermFrequencies), - r = n.length, - i = Object.create(null), - s = 0; - s < r; - s++ - ) { - for ( - var o = t.FieldRef.fromString(n[s]), - a = o.fieldName, - c = this.fieldLengths[o], - l = new t.Vector(), - d = this.fieldTermFrequencies[o], - f = Object.keys(d), - p = f.length, - v = this._fields[a].boost || 1, - x = this._documents[o.docRef].boost || 1, - w = 0; - w < p; - w++ - ) { - var m = f[w], - g = d[m], - T = this.invertedIndex[m]._index, - L, - C, - O; - (i[m] === void 0 - ? ((L = t.idf(this.invertedIndex[m], this.documentCount)), (i[m] = L)) - : (L = i[m]), - (C = - (L * ((this._k1 + 1) * g)) / - (this._k1 * (1 - this._b + this._b * (c / this.averageFieldLength[a])) + g)), - (C *= v), - (C *= x), - (O = Math.round(C * 1e3) / 1e3), - l.insert(T, O)); - } - e[o] = l; - } - this.fieldVectors = e; - }), - (t.Builder.prototype.createTokenSet = function () { - this.tokenSet = t.TokenSet.fromArray(Object.keys(this.invertedIndex).sort()); - }), - (t.Builder.prototype.build = function () { - return ( - this.calculateAverageFieldLengths(), - this.createFieldVectors(), - this.createTokenSet(), - new t.Index({ - invertedIndex: this.invertedIndex, - fieldVectors: this.fieldVectors, - tokenSet: this.tokenSet, - fields: Object.keys(this._fields), - pipeline: this.searchPipeline - }) - ); - }), - (t.Builder.prototype.use = function (e) { - var n = Array.prototype.slice.call(arguments, 1); - (n.unshift(this), e.apply(this, n)); - }), - (t.MatchData = function (e, n, r) { - for (var i = Object.create(null), s = Object.keys(r || {}), o = 0; o < s.length; o++) { - var a = s[o]; - i[a] = r[a].slice(); - } - ((this.metadata = Object.create(null)), - e !== void 0 && ((this.metadata[e] = Object.create(null)), (this.metadata[e][n] = i))); - }), - (t.MatchData.prototype.combine = function (e) { - for (var n = Object.keys(e.metadata), r = 0; r < n.length; r++) { - var i = n[r], - s = Object.keys(e.metadata[i]); - this.metadata[i] == null && (this.metadata[i] = Object.create(null)); - for (var o = 0; o < s.length; o++) { - var a = s[o], - c = Object.keys(e.metadata[i][a]); - this.metadata[i][a] == null && (this.metadata[i][a] = Object.create(null)); - for (var l = 0; l < c.length; l++) { - var d = c[l]; - this.metadata[i][a][d] == null - ? (this.metadata[i][a][d] = e.metadata[i][a][d]) - : (this.metadata[i][a][d] = this.metadata[i][a][d].concat(e.metadata[i][a][d])); - } - } - } - }), - (t.MatchData.prototype.add = function (e, n, r) { - if (!(e in this.metadata)) { - ((this.metadata[e] = Object.create(null)), (this.metadata[e][n] = r)); - return; - } - if (!(n in this.metadata[e])) { - this.metadata[e][n] = r; - return; - } - for (var i = Object.keys(r), s = 0; s < i.length; s++) { - var o = i[s]; - o in this.metadata[e][n] - ? (this.metadata[e][n][o] = this.metadata[e][n][o].concat(r[o])) - : (this.metadata[e][n][o] = r[o]); - } - }), - (t.Query = function (e) { - ((this.clauses = []), (this.allFields = e)); - }), - (t.Query.wildcard = new String("*")), - (t.Query.wildcard.NONE = 0), - (t.Query.wildcard.LEADING = 1), - (t.Query.wildcard.TRAILING = 2), - (t.Query.presence = { OPTIONAL: 1, REQUIRED: 2, PROHIBITED: 3 }), - (t.Query.prototype.clause = function (e) { - return ( - "fields" in e || (e.fields = this.allFields), - "boost" in e || (e.boost = 1), - "usePipeline" in e || (e.usePipeline = !0), - "wildcard" in e || (e.wildcard = t.Query.wildcard.NONE), - e.wildcard & t.Query.wildcard.LEADING && - e.term.charAt(0) != t.Query.wildcard && - (e.term = "*" + e.term), - e.wildcard & t.Query.wildcard.TRAILING && - e.term.slice(-1) != t.Query.wildcard && - (e.term = "" + e.term + "*"), - "presence" in e || (e.presence = t.Query.presence.OPTIONAL), - this.clauses.push(e), - this - ); - }), - (t.Query.prototype.isNegated = function () { - for (var e = 0; e < this.clauses.length; e++) - if (this.clauses[e].presence != t.Query.presence.PROHIBITED) return !1; - return !0; - }), - (t.Query.prototype.term = function (e, n) { - if (Array.isArray(e)) - return ( - e.forEach(function (i) { - this.term(i, t.utils.clone(n)); - }, this), - this - ); - var r = n || {}; - return ((r.term = e.toString()), this.clause(r), this); - }), - (t.QueryParseError = function (e, n, r) { - ((this.name = "QueryParseError"), (this.message = e), (this.start = n), (this.end = r)); - }), - (t.QueryParseError.prototype = new Error()), - (t.QueryLexer = function (e) { - ((this.lexemes = []), - (this.str = e), - (this.length = e.length), - (this.pos = 0), - (this.start = 0), - (this.escapeCharPositions = [])); - }), - (t.QueryLexer.prototype.run = function () { - for (var e = t.QueryLexer.lexText; e; ) e = e(this); - }), - (t.QueryLexer.prototype.sliceString = function () { - for ( - var e = [], n = this.start, r = this.pos, i = 0; - i < this.escapeCharPositions.length; - i++ - ) - ((r = this.escapeCharPositions[i]), e.push(this.str.slice(n, r)), (n = r + 1)); - return ( - e.push(this.str.slice(n, this.pos)), - (this.escapeCharPositions.length = 0), - e.join("") - ); - }), - (t.QueryLexer.prototype.emit = function (e) { - (this.lexemes.push({ - type: e, - str: this.sliceString(), - start: this.start, - end: this.pos - }), - (this.start = this.pos)); - }), - (t.QueryLexer.prototype.escapeCharacter = function () { - (this.escapeCharPositions.push(this.pos - 1), (this.pos += 1)); - }), - (t.QueryLexer.prototype.next = function () { - if (this.pos >= this.length) return t.QueryLexer.EOS; - var e = this.str.charAt(this.pos); - return ((this.pos += 1), e); - }), - (t.QueryLexer.prototype.width = function () { - return this.pos - this.start; - }), - (t.QueryLexer.prototype.ignore = function () { - (this.start == this.pos && (this.pos += 1), (this.start = this.pos)); - }), - (t.QueryLexer.prototype.backup = function () { - this.pos -= 1; - }), - (t.QueryLexer.prototype.acceptDigitRun = function () { - var e, n; - do ((e = this.next()), (n = e.charCodeAt(0))); - while (n > 47 && n < 58); - e != t.QueryLexer.EOS && this.backup(); - }), - (t.QueryLexer.prototype.more = function () { - return this.pos < this.length; - }), - (t.QueryLexer.EOS = "EOS"), - (t.QueryLexer.FIELD = "FIELD"), - (t.QueryLexer.TERM = "TERM"), - (t.QueryLexer.EDIT_DISTANCE = "EDIT_DISTANCE"), - (t.QueryLexer.BOOST = "BOOST"), - (t.QueryLexer.PRESENCE = "PRESENCE"), - (t.QueryLexer.lexField = function (e) { - return (e.backup(), e.emit(t.QueryLexer.FIELD), e.ignore(), t.QueryLexer.lexText); - }), - (t.QueryLexer.lexTerm = function (e) { - if ((e.width() > 1 && (e.backup(), e.emit(t.QueryLexer.TERM)), e.ignore(), e.more())) - return t.QueryLexer.lexText; - }), - (t.QueryLexer.lexEditDistance = function (e) { - return ( - e.ignore(), - e.acceptDigitRun(), - e.emit(t.QueryLexer.EDIT_DISTANCE), - t.QueryLexer.lexText - ); - }), - (t.QueryLexer.lexBoost = function (e) { - return (e.ignore(), e.acceptDigitRun(), e.emit(t.QueryLexer.BOOST), t.QueryLexer.lexText); - }), - (t.QueryLexer.lexEOS = function (e) { - e.width() > 0 && e.emit(t.QueryLexer.TERM); - }), - (t.QueryLexer.termSeparator = t.tokenizer.separator), - (t.QueryLexer.lexText = function (e) { - for (;;) { - var n = e.next(); - if (n == t.QueryLexer.EOS) return t.QueryLexer.lexEOS; - if (n.charCodeAt(0) == 92) { - e.escapeCharacter(); - continue; - } - if (n == ":") return t.QueryLexer.lexField; - if (n == "~") - return ( - e.backup(), - e.width() > 0 && e.emit(t.QueryLexer.TERM), - t.QueryLexer.lexEditDistance - ); - if (n == "^") - return ( - e.backup(), - e.width() > 0 && e.emit(t.QueryLexer.TERM), - t.QueryLexer.lexBoost - ); - if ((n == "+" && e.width() === 1) || (n == "-" && e.width() === 1)) - return (e.emit(t.QueryLexer.PRESENCE), t.QueryLexer.lexText); - if (n.match(t.QueryLexer.termSeparator)) return t.QueryLexer.lexTerm; - } - }), - (t.QueryParser = function (e, n) { - ((this.lexer = new t.QueryLexer(e)), - (this.query = n), - (this.currentClause = {}), - (this.lexemeIdx = 0)); - }), - (t.QueryParser.prototype.parse = function () { - (this.lexer.run(), (this.lexemes = this.lexer.lexemes)); - for (var e = t.QueryParser.parseClause; e; ) e = e(this); - return this.query; - }), - (t.QueryParser.prototype.peekLexeme = function () { - return this.lexemes[this.lexemeIdx]; - }), - (t.QueryParser.prototype.consumeLexeme = function () { - var e = this.peekLexeme(); - return ((this.lexemeIdx += 1), e); - }), - (t.QueryParser.prototype.nextClause = function () { - var e = this.currentClause; - (this.query.clause(e), (this.currentClause = {})); - }), - (t.QueryParser.parseClause = function (e) { - var n = e.peekLexeme(); - if (n != null) - switch (n.type) { - case t.QueryLexer.PRESENCE: - return t.QueryParser.parsePresence; - case t.QueryLexer.FIELD: - return t.QueryParser.parseField; - case t.QueryLexer.TERM: - return t.QueryParser.parseTerm; - default: - var r = "expected either a field or a term, found " + n.type; - throw ( - n.str.length >= 1 && (r += " with value '" + n.str + "'"), - new t.QueryParseError(r, n.start, n.end) - ); - } - }), - (t.QueryParser.parsePresence = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - switch (n.str) { - case "-": - e.currentClause.presence = t.Query.presence.PROHIBITED; - break; - case "+": - e.currentClause.presence = t.Query.presence.REQUIRED; - break; - default: - var r = "unrecognised presence operator'" + n.str + "'"; - throw new t.QueryParseError(r, n.start, n.end); - } - var i = e.peekLexeme(); - if (i == null) { - var r = "expecting term or field, found nothing"; - throw new t.QueryParseError(r, n.start, n.end); - } - switch (i.type) { - case t.QueryLexer.FIELD: - return t.QueryParser.parseField; - case t.QueryLexer.TERM: - return t.QueryParser.parseTerm; - default: - var r = "expecting term or field, found '" + i.type + "'"; - throw new t.QueryParseError(r, i.start, i.end); - } - } - }), - (t.QueryParser.parseField = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - if (e.query.allFields.indexOf(n.str) == -1) { - var r = e.query.allFields - .map(function (o) { - return "'" + o + "'"; - }) - .join(", "), - i = "unrecognised field '" + n.str + "', possible fields: " + r; - throw new t.QueryParseError(i, n.start, n.end); - } - e.currentClause.fields = [n.str]; - var s = e.peekLexeme(); - if (s == null) { - var i = "expecting term, found nothing"; - throw new t.QueryParseError(i, n.start, n.end); - } - switch (s.type) { - case t.QueryLexer.TERM: - return t.QueryParser.parseTerm; - default: - var i = "expecting term, found '" + s.type + "'"; - throw new t.QueryParseError(i, s.start, s.end); - } - } - }), - (t.QueryParser.parseTerm = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - ((e.currentClause.term = n.str.toLowerCase()), - n.str.indexOf("*") != -1 && (e.currentClause.usePipeline = !1)); - var r = e.peekLexeme(); - if (r == null) { - e.nextClause(); - return; - } - switch (r.type) { - case t.QueryLexer.TERM: - return (e.nextClause(), t.QueryParser.parseTerm); - case t.QueryLexer.FIELD: - return (e.nextClause(), t.QueryParser.parseField); - case t.QueryLexer.EDIT_DISTANCE: - return t.QueryParser.parseEditDistance; - case t.QueryLexer.BOOST: - return t.QueryParser.parseBoost; - case t.QueryLexer.PRESENCE: - return (e.nextClause(), t.QueryParser.parsePresence); - default: - var i = "Unexpected lexeme type '" + r.type + "'"; - throw new t.QueryParseError(i, r.start, r.end); - } - } - }), - (t.QueryParser.parseEditDistance = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - var r = parseInt(n.str, 10); - if (isNaN(r)) { - var i = "edit distance must be numeric"; - throw new t.QueryParseError(i, n.start, n.end); - } - e.currentClause.editDistance = r; - var s = e.peekLexeme(); - if (s == null) { - e.nextClause(); - return; - } - switch (s.type) { - case t.QueryLexer.TERM: - return (e.nextClause(), t.QueryParser.parseTerm); - case t.QueryLexer.FIELD: - return (e.nextClause(), t.QueryParser.parseField); - case t.QueryLexer.EDIT_DISTANCE: - return t.QueryParser.parseEditDistance; - case t.QueryLexer.BOOST: - return t.QueryParser.parseBoost; - case t.QueryLexer.PRESENCE: - return (e.nextClause(), t.QueryParser.parsePresence); - default: - var i = "Unexpected lexeme type '" + s.type + "'"; - throw new t.QueryParseError(i, s.start, s.end); - } - } - }), - (t.QueryParser.parseBoost = function (e) { - var n = e.consumeLexeme(); - if (n != null) { - var r = parseInt(n.str, 10); - if (isNaN(r)) { - var i = "boost must be numeric"; - throw new t.QueryParseError(i, n.start, n.end); - } - e.currentClause.boost = r; - var s = e.peekLexeme(); - if (s == null) { - e.nextClause(); - return; - } - switch (s.type) { - case t.QueryLexer.TERM: - return (e.nextClause(), t.QueryParser.parseTerm); - case t.QueryLexer.FIELD: - return (e.nextClause(), t.QueryParser.parseField); - case t.QueryLexer.EDIT_DISTANCE: - return t.QueryParser.parseEditDistance; - case t.QueryLexer.BOOST: - return t.QueryParser.parseBoost; - case t.QueryLexer.PRESENCE: - return (e.nextClause(), t.QueryParser.parsePresence); - default: - var i = "Unexpected lexeme type '" + s.type + "'"; - throw new t.QueryParseError(i, s.start, s.end); - } - } - }), - (function (e, n) { - typeof define == "function" && define.amd - ? define(n) - : typeof me == "object" - ? (ge.exports = n()) - : (e.lunr = n()); - })(this, function () { - return t; - })); - })(); - }); - var M, - G = { - getItem() { - return null; - }, - setItem() {} - }, - K; - try { - ((K = localStorage), (M = K)); - } catch { - ((K = G), (M = G)); - } - var S = { - getItem: (t) => M.getItem(t), - setItem: (t, e) => M.setItem(t, e), - disableWritingLocalStorage() { - M = G; - }, - disable() { - (localStorage.clear(), (M = G)); - }, - enable() { - M = K; - } - }; - window.TypeDoc ||= { - disableWritingLocalStorage() { - S.disableWritingLocalStorage(); - }, - disableLocalStorage: () => { - S.disable(); - }, - enableLocalStorage: () => { - S.enable(); - } - }; - window.translations ||= { - copy: "Copy", - copied: "Copied!", - normally_hidden: "This member is normally hidden due to your filter settings.", - hierarchy_expand: "Expand", - hierarchy_collapse: "Collapse", - search_index_not_available: "The search index is not available", - search_no_results_found_for_0: "No results found for {0}", - folder: "Folder", - kind_1: "Project", - kind_2: "Module", - kind_4: "Namespace", - kind_8: "Enumeration", - kind_16: "Enumeration Member", - kind_32: "Variable", - kind_64: "Function", - kind_128: "Class", - kind_256: "Interface", - kind_512: "Constructor", - kind_1024: "Property", - kind_2048: "Method", - kind_4096: "Call Signature", - kind_8192: "Index Signature", - kind_16384: "Constructor Signature", - kind_32768: "Parameter", - kind_65536: "Type Literal", - kind_131072: "Type Parameter", - kind_262144: "Accessor", - kind_524288: "Get Signature", - kind_1048576: "Set Signature", - kind_2097152: "Type Alias", - kind_4194304: "Reference", - kind_8388608: "Document" - }; - var pe = []; - function X(t, e) { - pe.push({ selector: e, constructor: t }); - } - var Z = class { - alwaysVisibleMember = null; - constructor() { - (this.createComponents(document.body), - this.ensureFocusedElementVisible(), - this.listenForCodeCopies(), - window.addEventListener("hashchange", () => this.ensureFocusedElementVisible()), - document.body.style.display || - (this.ensureFocusedElementVisible(), this.updateIndexVisibility(), this.scrollToHash())); - } - createComponents(e) { - pe.forEach((n) => { - e.querySelectorAll(n.selector).forEach((r) => { - r.dataset.hasInstance || - (new n.constructor({ el: r, app: this }), (r.dataset.hasInstance = String(!0))); - }); - }); - } - filterChanged() { - this.ensureFocusedElementVisible(); - } - showPage() { - document.body.style.display && - (document.body.style.removeProperty("display"), - this.ensureFocusedElementVisible(), - this.updateIndexVisibility(), - this.scrollToHash()); - } - scrollToHash() { - if (location.hash) { - let e = document.getElementById(location.hash.substring(1)); - if (!e) return; - e.scrollIntoView({ behavior: "instant", block: "start" }); - } - } - ensureActivePageVisible() { - let e = document.querySelector(".tsd-navigation .current"), - n = e?.parentElement; - for (; n && !n.classList.contains(".tsd-navigation"); ) - (n instanceof HTMLDetailsElement && (n.open = !0), (n = n.parentElement)); - if (e && !rt(e)) { - let r = e.getBoundingClientRect().top - document.documentElement.clientHeight / 4; - ((document.querySelector(".site-menu").scrollTop = r), - (document.querySelector(".col-sidebar").scrollTop = r)); - } - } - updateIndexVisibility() { - let e = document.querySelector(".tsd-index-content"), - n = e?.open; - (e && (e.open = !0), - document.querySelectorAll(".tsd-index-section").forEach((r) => { - r.style.display = "block"; - let i = Array.from(r.querySelectorAll(".tsd-index-link")).every( - (s) => s.offsetParent == null - ); - r.style.display = i ? "none" : "block"; - }), - e && (e.open = n)); - } - ensureFocusedElementVisible() { - if ( - (this.alwaysVisibleMember && - (this.alwaysVisibleMember.classList.remove("always-visible"), - this.alwaysVisibleMember.firstElementChild.remove(), - (this.alwaysVisibleMember = null)), - !location.hash) - ) - return; - let e = document.getElementById(location.hash.substring(1)); - if (!e) return; - let n = e.parentElement; - for (; n && n.tagName !== "SECTION"; ) n = n.parentElement; - if (!n) return; - let r = n.offsetParent == null, - i = n; - for (; i !== document.body; ) - (i instanceof HTMLDetailsElement && (i.open = !0), (i = i.parentElement)); - if (n.offsetParent == null) { - ((this.alwaysVisibleMember = n), n.classList.add("always-visible")); - let s = document.createElement("p"); - (s.classList.add("warning"), - (s.textContent = window.translations.normally_hidden), - n.prepend(s)); - } - r && e.scrollIntoView(); - } - listenForCodeCopies() { - document.querySelectorAll("pre > button").forEach((e) => { - let n; - e.addEventListener("click", () => { - (e.previousElementSibling instanceof HTMLElement && - navigator.clipboard.writeText(e.previousElementSibling.innerText.trim()), - (e.textContent = window.translations.copied), - e.classList.add("visible"), - clearTimeout(n), - (n = setTimeout(() => { - (e.classList.remove("visible"), - (n = setTimeout(() => { - e.textContent = window.translations.copy; - }, 100))); - }, 1e3))); - }); - }); - } - }; - function rt(t) { - let e = t.getBoundingClientRect(), - n = Math.max(document.documentElement.clientHeight, window.innerHeight); - return !(e.bottom < 0 || e.top - n >= 0); - } - var fe = (t, e = 100) => { - let n; - return () => { - (clearTimeout(n), (n = setTimeout(() => t(), e))); - }; - }; - var Ie = nt(ye(), 1); - async function R(t) { - let e = Uint8Array.from(atob(t), (s) => s.charCodeAt(0)), - r = new Blob([e]).stream().pipeThrough(new DecompressionStream("deflate")), - i = await new Response(r).text(); - return JSON.parse(i); - } - var Y = "closing", - ae = "tsd-overlay"; - function it() { - let t = Math.abs(window.innerWidth - document.documentElement.clientWidth); - ((document.body.style.overflow = "hidden"), (document.body.style.paddingRight = `${t}px`)); - } - function st() { - (document.body.style.removeProperty("overflow"), - document.body.style.removeProperty("padding-right")); - } - function xe(t, e) { - (t.addEventListener("animationend", () => { - t.classList.contains(Y) && - (t.classList.remove(Y), document.getElementById(ae)?.remove(), t.close(), st()); - }), - t.addEventListener("cancel", (n) => { - (n.preventDefault(), ve(t)); - }), - e?.closeOnClick && - document.addEventListener( - "click", - (n) => { - t.open && !t.contains(n.target) && ve(t); - }, - !0 - )); - } - function Ee(t) { - if (t.open) return; - let e = document.createElement("div"); - ((e.id = ae), document.body.appendChild(e), t.showModal(), it()); - } - function ve(t) { - if (!t.open) return; - (document.getElementById(ae)?.classList.add(Y), t.classList.add(Y)); - } - var I = class { - el; - app; - constructor(e) { - ((this.el = e.el), (this.app = e.app)); - } - }; - var be = document.head.appendChild(document.createElement("style")); - be.dataset.for = "filters"; - var le = {}; - function we(t) { - for (let e of t.split(/\s+/)) if (le.hasOwnProperty(e) && !le[e]) return !0; - return !1; - } - var ee = class extends I { - key; - value; - constructor(e) { - (super(e), - (this.key = `filter-${this.el.name}`), - (this.value = this.el.checked), - this.el.addEventListener("change", () => { - this.setLocalStorage(this.el.checked); - }), - this.setLocalStorage(this.fromLocalStorage()), - (be.innerHTML += `html:not(.${this.key}) .tsd-is-${this.el.name} { display: none; } -`), - this.app.updateIndexVisibility()); - } - fromLocalStorage() { - let e = S.getItem(this.key); - return e ? e === "true" : this.el.checked; - } - setLocalStorage(e) { - (S.setItem(this.key, e.toString()), (this.value = e), this.handleValueChange()); - } - handleValueChange() { - ((this.el.checked = this.value), - document.documentElement.classList.toggle(this.key, this.value), - (le[`tsd-is-${this.el.name}`] = this.value), - this.app.filterChanged(), - this.app.updateIndexVisibility()); - } - }; - var Le = 0; - async function Se(t, e) { - if (!window.searchData) return; - let n = await R(window.searchData); - ((t.data = n), (t.index = Ie.Index.load(n.index)), (e.innerHTML = "")); - } - function _e() { - let t = document.getElementById("tsd-search-trigger"), - e = document.getElementById("tsd-search"), - n = document.getElementById("tsd-search-input"), - r = document.getElementById("tsd-search-results"), - i = document.getElementById("tsd-search-script"), - s = document.getElementById("tsd-search-status"); - if (!(t && e && n && r && i && s)) throw new Error("Search controls missing"); - let o = { base: document.documentElement.dataset.base }; - (o.base.endsWith("/") || (o.base += "/"), - i.addEventListener("error", () => { - let a = window.translations.search_index_not_available; - Pe(s, a); - }), - i.addEventListener("load", () => { - Se(o, s); - }), - Se(o, s), - ot({ trigger: t, searchEl: e, results: r, field: n, status: s }, o)); - } - function ot(t, e) { - let { field: n, results: r, searchEl: i, status: s, trigger: o } = t; - xe(i, { closeOnClick: !0 }); - function a() { - (Ee(i), n.setSelectionRange(0, n.value.length)); - } - (o.addEventListener("click", a), - n.addEventListener( - "input", - fe(() => { - at(r, n, s, e); - }, 200) - ), - n.addEventListener("keydown", (l) => { - if (r.childElementCount === 0 || l.ctrlKey || l.metaKey || l.altKey) return; - let d = n.getAttribute("aria-activedescendant"), - f = d ? document.getElementById(d) : null; - if (f) { - let p = !1, - v = !1; - switch (l.key) { - case "Home": - case "End": - case "ArrowLeft": - case "ArrowRight": - v = !0; - break; - case "ArrowDown": - case "ArrowUp": - p = l.shiftKey; - break; - } - (p || v) && ke(n); - } - if (!l.shiftKey) - switch (l.key) { - case "Enter": - f?.querySelector("a")?.click(); - break; - case "ArrowUp": - (Te(r, n, f, -1), l.preventDefault()); - break; - case "ArrowDown": - (Te(r, n, f, 1), l.preventDefault()); - break; - } - })); - function c() { - ke(n); - } - (n.addEventListener("change", c), - n.addEventListener("blur", c), - n.addEventListener("click", c), - document.body.addEventListener("keydown", (l) => { - if (l.altKey || l.metaKey || l.shiftKey) return; - let d = l.ctrlKey && l.key === "k", - f = !l.ctrlKey && !ut() && l.key === "/"; - (d || f) && (l.preventDefault(), a()); - })); - } - function at(t, e, n, r) { - if (!r.index || !r.data) return; - ((t.innerHTML = ""), (n.innerHTML = ""), (Le += 1)); - let i = e.value.trim(), - s; - if (i) { - let a = i - .split(" ") - .map((c) => (c.length ? `*${c}*` : "")) - .join(" "); - s = r.index.search(a).filter(({ ref: c }) => { - let l = r.data.rows[Number(c)].classes; - return !l || !we(l); - }); - } else s = []; - if (s.length === 0 && i) { - let a = window.translations.search_no_results_found_for_0.replace( - "{0}", - ` "${te(i)}" ` - ); - Pe(n, a); - return; - } - for (let a = 0; a < s.length; a++) { - let c = s[a], - l = r.data.rows[Number(c.ref)], - d = 1; - (l.name.toLowerCase().startsWith(i.toLowerCase()) && - (d *= 10 / (1 + Math.abs(l.name.length - i.length))), - (c.score *= d)); - } - s.sort((a, c) => c.score - a.score); - let o = Math.min(10, s.length); - for (let a = 0; a < o; a++) { - let c = r.data.rows[Number(s[a].ref)], - d = ``, - f = Ce(c.name, i); - (globalThis.DEBUG_SEARCH_WEIGHTS && (f += ` (score: ${s[a].score.toFixed(2)})`), - c.parent && - (f = ` - ${Ce(c.parent, i)}.${f}`)); - let p = document.createElement("li"); - ((p.id = `tsd-search:${Le}-${a}`), - (p.role = "option"), - (p.ariaSelected = "false"), - (p.classList.value = c.classes ?? "")); - let v = document.createElement("a"); - ((v.tabIndex = -1), - (v.href = r.base + c.url), - (v.innerHTML = d + `${f}`), - p.append(v), - t.appendChild(p)); - } - } - function Te(t, e, n, r) { - let i; - if ( - (r === 1 - ? (i = n?.nextElementSibling || t.firstElementChild) - : (i = n?.previousElementSibling || t.lastElementChild), - i !== n) - ) { - if (!i || i.role !== "option") { - console.error("Option missing"); - return; - } - ((i.ariaSelected = "true"), - i.scrollIntoView({ behavior: "smooth", block: "nearest" }), - e.setAttribute("aria-activedescendant", i.id), - n?.setAttribute("aria-selected", "false")); - } - } - function ke(t) { - let e = t.getAttribute("aria-activedescendant"); - ((e ? document.getElementById(e) : null)?.setAttribute("aria-selected", "false"), - t.setAttribute("aria-activedescendant", "")); - } - function Ce(t, e) { - if (e === "") return t; - let n = t.toLocaleLowerCase(), - r = e.toLocaleLowerCase(), - i = [], - s = 0, - o = n.indexOf(r); - for (; o != -1; ) - (i.push(te(t.substring(s, o)), `${te(t.substring(o, o + r.length))}`), - (s = o + r.length), - (o = n.indexOf(r, s))); - return (i.push(te(t.substring(s))), i.join("")); - } - var lt = { "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }; - function te(t) { - return t.replace(/[&<>"'"]/g, (e) => lt[e]); - } - function Pe(t, e) { - t.innerHTML = e ? `
${e}
` : ""; - } - var ct = ["button", "checkbox", "file", "hidden", "image", "radio", "range", "reset", "submit"]; - function ut() { - let t = document.activeElement; - return t - ? t.isContentEditable || t.tagName === "TEXTAREA" || t.tagName === "SEARCH" - ? !0 - : t.tagName === "INPUT" && !ct.includes(t.type) - : !1; - } - var D = "mousedown", - Me = "mousemove", - $ = "mouseup", - ne = { x: 0, y: 0 }, - Qe = !1, - ce = !1, - dt = !1, - F = !1, - Oe = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - document.documentElement.classList.add(Oe ? "is-mobile" : "not-mobile"); - Oe && - "ontouchstart" in document.documentElement && - ((dt = !0), (D = "touchstart"), (Me = "touchmove"), ($ = "touchend")); - document.addEventListener(D, (t) => { - ((ce = !0), (F = !1)); - let e = D == "touchstart" ? t.targetTouches[0] : t; - ((ne.y = e.pageY || 0), (ne.x = e.pageX || 0)); - }); - document.addEventListener(Me, (t) => { - if (ce && !F) { - let e = D == "touchstart" ? t.targetTouches[0] : t, - n = ne.x - (e.pageX || 0), - r = ne.y - (e.pageY || 0); - F = Math.sqrt(n * n + r * r) > 10; - } - }); - document.addEventListener($, () => { - ce = !1; - }); - document.addEventListener("click", (t) => { - Qe && (t.preventDefault(), t.stopImmediatePropagation(), (Qe = !1)); - }); - var re = class extends I { - active; - className; - constructor(e) { - (super(e), - (this.className = this.el.dataset.toggle || ""), - this.el.addEventListener($, (n) => this.onPointerUp(n)), - this.el.addEventListener("click", (n) => n.preventDefault()), - document.addEventListener(D, (n) => this.onDocumentPointerDown(n)), - document.addEventListener($, (n) => this.onDocumentPointerUp(n))); - } - setActive(e) { - if (this.active == e) return; - ((this.active = e), - document.documentElement.classList.toggle("has-" + this.className, e), - this.el.classList.toggle("active", e)); - let n = (this.active ? "to-has-" : "from-has-") + this.className; - (document.documentElement.classList.add(n), - setTimeout(() => document.documentElement.classList.remove(n), 500)); - } - onPointerUp(e) { - F || (this.setActive(!0), e.preventDefault()); - } - onDocumentPointerDown(e) { - if (this.active) { - if (e.target.closest(".col-sidebar, .tsd-filter-group")) return; - this.setActive(!1); - } - } - onDocumentPointerUp(e) { - if (!F && this.active && e.target.closest(".col-sidebar")) { - let n = e.target.closest("a"); - if (n) { - let r = window.location.href; - (r.indexOf("#") != -1 && (r = r.substring(0, r.indexOf("#"))), - n.href.substring(0, r.length) == r && setTimeout(() => this.setActive(!1), 250)); - } - } - } - }; - var ue = new Map(), - de = class { - open; - accordions = []; - key; - constructor(e, n) { - ((this.key = e), (this.open = n)); - } - add(e) { - (this.accordions.push(e), - (e.open = this.open), - e.addEventListener("toggle", () => { - this.toggle(e.open); - })); - } - toggle(e) { - for (let n of this.accordions) n.open = e; - S.setItem(this.key, e.toString()); - } - }, - ie = class extends I { - constructor(e) { - super(e); - let n = this.el.querySelector("summary"), - r = n.querySelector("a"); - r && - r.addEventListener("click", () => { - location.assign(r.href); - }); - let i = `tsd-accordion-${n.dataset.key ?? n.textContent.trim().replace(/\s+/g, "-").toLowerCase()}`, - s; - if (ue.has(i)) s = ue.get(i); - else { - let o = S.getItem(i), - a = o ? o === "true" : this.el.open; - ((s = new de(i, a)), ue.set(i, s)); - } - s.add(this.el); - } - }; - function He(t) { - let e = S.getItem("tsd-theme") || "os"; - ((t.value = e), - Ae(e), - t.addEventListener("change", () => { - (S.setItem("tsd-theme", t.value), Ae(t.value)); - })); - } - function Ae(t) { - document.documentElement.dataset.theme = t; - } - var se; - function Ne() { - let t = document.getElementById("tsd-nav-script"); - t && (t.addEventListener("load", Re), Re()); - } - async function Re() { - let t = document.getElementById("tsd-nav-container"); - if (!t || !window.navigationData) return; - let e = await R(window.navigationData); - ((se = document.documentElement.dataset.base), - se.endsWith("/") || (se += "/"), - (t.innerHTML = "")); - for (let n of e) Be(n, t, []); - (window.app.createComponents(t), window.app.showPage(), window.app.ensureActivePageVisible()); - } - function Be(t, e, n) { - let r = e.appendChild(document.createElement("li")); - if (t.children) { - let i = [...n, t.text], - s = r.appendChild(document.createElement("details")); - s.className = t.class ? `${t.class} tsd-accordion` : "tsd-accordion"; - let o = s.appendChild(document.createElement("summary")); - ((o.className = "tsd-accordion-summary"), - (o.dataset.key = i.join("$")), - (o.innerHTML = - ''), - De(t, o)); - let a = s.appendChild(document.createElement("div")); - a.className = "tsd-accordion-details"; - let c = a.appendChild(document.createElement("ul")); - c.className = "tsd-nested-navigation"; - for (let l of t.children) Be(l, c, i); - } else De(t, r, t.class); - } - function De(t, e, n) { - if (t.path) { - let r = e.appendChild(document.createElement("a")); - if ( - ((r.href = se + t.path), - n && (r.className = n), - location.pathname === r.pathname && - !r.href.includes("#") && - (r.classList.add("current"), (r.ariaCurrent = "page")), - t.kind) - ) { - let i = window.translations[`kind_${t.kind}`].replaceAll('"', """); - r.innerHTML = ``; - } - r.appendChild(Fe(t.text, document.createElement("span"))); - } else { - let r = e.appendChild(document.createElement("span")), - i = window.translations.folder.replaceAll('"', """); - ((r.innerHTML = ``), - r.appendChild(Fe(t.text, document.createElement("span")))); - } - } - function Fe(t, e) { - let n = t.split(/(?<=[^A-Z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[_-])(?=[^_-])/); - for (let r = 0; r < n.length; ++r) - (r !== 0 && e.appendChild(document.createElement("wbr")), - e.appendChild(document.createTextNode(n[r]))); - return e; - } - var oe = document.documentElement.dataset.base; - oe.endsWith("/") || (oe += "/"); - function $e() { - document.querySelector(".tsd-full-hierarchy") - ? ht() - : document.querySelector(".tsd-hierarchy") && pt(); - } - function ht() { - document.addEventListener("click", (r) => { - let i = r.target; - for (; i.parentElement && i.parentElement.tagName != "LI"; ) i = i.parentElement; - i.dataset.dropdown && (i.dataset.dropdown = String(i.dataset.dropdown !== "true")); - }); - let t = new Map(), - e = new Set(); - for (let r of document.querySelectorAll(".tsd-full-hierarchy [data-refl]")) { - let i = r.querySelector("ul"); - t.has(r.dataset.refl) ? e.add(r.dataset.refl) : i && t.set(r.dataset.refl, i); - } - for (let r of e) n(r); - function n(r) { - let i = t.get(r).cloneNode(!0); - (i.querySelectorAll("[id]").forEach((s) => { - s.removeAttribute("id"); - }), - i.querySelectorAll("[data-dropdown]").forEach((s) => { - s.dataset.dropdown = "false"; - })); - for (let s of document.querySelectorAll(`[data-refl="${r}"]`)) { - let o = gt(), - a = s.querySelector("ul"); - (s.insertBefore(o, a), - (o.dataset.dropdown = String(!!a)), - a || s.appendChild(i.cloneNode(!0))); - } - } - } - function pt() { - let t = document.getElementById("tsd-hierarchy-script"); - t && (t.addEventListener("load", Ve), Ve()); - } - async function Ve() { - let t = document.querySelector(".tsd-panel.tsd-hierarchy:has(h4 a)"); - if (!t || !window.hierarchyData) return; - let e = +t.dataset.refl, - n = await R(window.hierarchyData), - r = t.querySelector("ul"), - i = document.createElement("ul"); - if ( - (i.classList.add("tsd-hierarchy"), - ft(i, n, e), - r.querySelectorAll("li").length == i.querySelectorAll("li").length) - ) - return; - let s = document.createElement("span"); - (s.classList.add("tsd-hierarchy-toggle"), - (s.textContent = window.translations.hierarchy_expand), - t.querySelector("h4 a")?.insertAdjacentElement("afterend", s), - s.insertAdjacentText("beforebegin", ", "), - s.addEventListener("click", () => { - s.textContent === window.translations.hierarchy_expand - ? (r.insertAdjacentElement("afterend", i), - r.remove(), - (s.textContent = window.translations.hierarchy_collapse)) - : (i.insertAdjacentElement("afterend", r), - i.remove(), - (s.textContent = window.translations.hierarchy_expand)); - })); - } - function ft(t, e, n) { - let r = e.roots.filter((i) => mt(e, i, n)); - for (let i of r) t.appendChild(je(e, i, n)); - } - function je(t, e, n, r = new Set()) { - if (r.has(e)) return; - r.add(e); - let i = t.reflections[e], - s = document.createElement("li"); - if ((s.classList.add("tsd-hierarchy-item"), e === n)) { - let o = s.appendChild(document.createElement("span")); - ((o.textContent = i.name), o.classList.add("tsd-hierarchy-target")); - } else { - for (let a of i.uniqueNameParents || []) { - let c = t.reflections[a], - l = s.appendChild(document.createElement("a")); - ((l.textContent = c.name), - (l.href = oe + c.url), - (l.className = c.class + " tsd-signature-type"), - s.append(document.createTextNode("."))); - } - let o = s.appendChild(document.createElement("a")); - ((o.textContent = t.reflections[e].name), - (o.href = oe + i.url), - (o.className = i.class + " tsd-signature-type")); - } - if (i.children) { - let o = s.appendChild(document.createElement("ul")); - o.classList.add("tsd-hierarchy"); - for (let a of i.children) { - let c = je(t, a, n, r); - c && o.appendChild(c); - } - } - return (r.delete(e), s); - } - function mt(t, e, n) { - if (e === n) return !0; - let r = new Set(), - i = [t.reflections[e]]; - for (; i.length; ) { - let s = i.pop(); - if (!r.has(s)) { - r.add(s); - for (let o of s.children || []) { - if (o === n) return !0; - i.push(t.reflections[o]); - } - } - } - return !1; - } - function gt() { - let t = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - return ( - t.setAttribute("width", "20"), - t.setAttribute("height", "20"), - t.setAttribute("viewBox", "0 0 24 24"), - t.setAttribute("fill", "none"), - (t.innerHTML = ''), - t - ); - } - X(re, "a[data-toggle]"); - X(ie, ".tsd-accordion"); - X(ee, ".tsd-filter-item input[type=checkbox]"); - var qe = document.getElementById("tsd-theme"); - qe && He(qe); - var yt = new Z(); - Object.defineProperty(window, "app", { value: yt }); - _e(); - Ne(); - $e(); - "virtualKeyboard" in navigator && (navigator.virtualKeyboard.overlaysContent = !0); -})(); +window.translations={"copy":"Copy","copied":"Copied!","normally_hidden":"This member is normally hidden due to your filter settings.","hierarchy_expand":"Expand","hierarchy_collapse":"Collapse","folder":"Folder","search_index_not_available":"The search index is not available","search_no_results_found_for_0":"No results found for {0}","kind_1":"Project","kind_2":"Module","kind_4":"Namespace","kind_8":"Enumeration","kind_16":"Enumeration Member","kind_32":"Variable","kind_64":"Function","kind_128":"Class","kind_256":"Interface","kind_512":"Constructor","kind_1024":"Property","kind_2048":"Method","kind_4096":"Call Signature","kind_8192":"Index Signature","kind_16384":"Constructor Signature","kind_32768":"Parameter","kind_65536":"Type Literal","kind_131072":"Type Parameter","kind_262144":"Accessor","kind_524288":"Get Signature","kind_1048576":"Set Signature","kind_2097152":"Type Alias","kind_4194304":"Reference","kind_8388608":"Document"}; +(()=>{var Ke=Object.create;var he=Object.defineProperty;var Ge=Object.getOwnPropertyDescriptor;var Ze=Object.getOwnPropertyNames;var Xe=Object.getPrototypeOf,Ye=Object.prototype.hasOwnProperty;var et=(t,e)=>()=>{try{return e||t((e={exports:{}}).exports,e),e.exports}catch(n){throw e=0,n}};var tt=(t,e,n,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of Ze(e))!Ye.call(t,i)&&i!==n&&he(t,i,{get:()=>e[i],enumerable:!(r=Ge(e,i))||r.enumerable});return t};var nt=(t,e,n)=>(n=t!=null?Ke(Xe(t)):{},tt(e||!t||!t.__esModule?he(n,"default",{value:t,enumerable:!0}):n,t));var ye=et((me,ge)=>{(function(){var t=function(e){var n=new t.Builder;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),n.searchPipeline.add(t.stemmer),e.call(n,n),n.build()};t.version="2.3.9";t.utils={},t.utils.warn=(function(e){return function(n){e.console&&console.warn&&console.warn(n)}})(this),t.utils.asString=function(e){return e==null?"":e.toString()},t.utils.clone=function(e){if(e==null)return e;for(var n=Object.create(null),r=Object.keys(e),i=0;i0){var d=t.utils.clone(n)||{};d.position=[a,l],d.index=s.length,s.push(new t.Token(r.slice(a,o),d))}a=o+1}}return s},t.tokenizer.separator=/[\s\-]+/;t.Pipeline=function(){this._stack=[]},t.Pipeline.registeredFunctions=Object.create(null),t.Pipeline.registerFunction=function(e,n){n in this.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[e.label]=e},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn(`Function is not registered with pipeline. This may cause problems when serialising the index. +`,e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(r){var i=t.Pipeline.registeredFunctions[r];if(i)n.add(i);else throw new Error("Cannot load unregistered function: "+r)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(n){t.Pipeline.warnIfFunctionNotRegistered(n),this._stack.push(n)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var r=this._stack.indexOf(e);if(r==-1)throw new Error("Cannot find existingFn");r=r+1,this._stack.splice(r,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var r=this._stack.indexOf(e);if(r==-1)throw new Error("Cannot find existingFn");this._stack.splice(r,0,n)},t.Pipeline.prototype.remove=function(e){var n=this._stack.indexOf(e);n!=-1&&this._stack.splice(n,1)},t.Pipeline.prototype.run=function(e){for(var n=this._stack.length,r=0;r1&&(oe&&(r=s),o!=e);)i=r-n,s=n+Math.floor(i/2),o=this.elements[s*2];if(o==e||o>e)return s*2;if(oc?d+=2:a==c&&(n+=r[l+1]*i[d+1],l+=2,d+=2);return n},t.Vector.prototype.similarity=function(e){return this.dot(e)/this.magnitude()||0},t.Vector.prototype.toArray=function(){for(var e=new Array(this.elements.length/2),n=1,r=0;n0){var o=s.str.charAt(0),a;o in s.node.edges?a=s.node.edges[o]:(a=new t.TokenSet,s.node.edges[o]=a),s.str.length==1&&(a.final=!0),i.push({node:a,editsRemaining:s.editsRemaining,str:s.str.slice(1)})}if(s.editsRemaining!=0){if("*"in s.node.edges)var c=s.node.edges["*"];else{var c=new t.TokenSet;s.node.edges["*"]=c}if(s.str.length==0&&(c.final=!0),i.push({node:c,editsRemaining:s.editsRemaining-1,str:s.str}),s.str.length>1&&i.push({node:s.node,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)}),s.str.length==1&&(s.node.final=!0),s.str.length>=1){if("*"in s.node.edges)var l=s.node.edges["*"];else{var l=new t.TokenSet;s.node.edges["*"]=l}s.str.length==1&&(l.final=!0),i.push({node:l,editsRemaining:s.editsRemaining-1,str:s.str.slice(1)})}if(s.str.length>1){var d=s.str.charAt(0),f=s.str.charAt(1),p;f in s.node.edges?p=s.node.edges[f]:(p=new t.TokenSet,s.node.edges[f]=p),s.str.length==1&&(p.final=!0),i.push({node:p,editsRemaining:s.editsRemaining-1,str:d+s.str.slice(2)})}}}return r},t.TokenSet.fromString=function(e){for(var n=new t.TokenSet,r=n,i=0,s=e.length;i=e;n--){var r=this.uncheckedNodes[n],i=r.child.toString();i in this.minimizedNodes?r.parent.edges[r.char]=this.minimizedNodes[i]:(r.child._str=i,this.minimizedNodes[i]=r.child),this.uncheckedNodes.pop()}};t.Index=function(e){this.invertedIndex=e.invertedIndex,this.fieldVectors=e.fieldVectors,this.tokenSet=e.tokenSet,this.fields=e.fields,this.pipeline=e.pipeline},t.Index.prototype.search=function(e){return this.query(function(n){var r=new t.QueryParser(e,n);r.parse()})},t.Index.prototype.query=function(e){for(var n=new t.Query(this.fields),r=Object.create(null),i=Object.create(null),s=Object.create(null),o=Object.create(null),a=Object.create(null),c=0;c1?this._b=1:this._b=e},t.Builder.prototype.k1=function(e){this._k1=e},t.Builder.prototype.add=function(e,n){var r=e[this._ref],i=Object.keys(this._fields);this._documents[r]=n||{},this.documentCount+=1;for(var s=0;s=this.length)return t.QueryLexer.EOS;var e=this.str.charAt(this.pos);return this.pos+=1,e},t.QueryLexer.prototype.width=function(){return this.pos-this.start},t.QueryLexer.prototype.ignore=function(){this.start==this.pos&&(this.pos+=1),this.start=this.pos},t.QueryLexer.prototype.backup=function(){this.pos-=1},t.QueryLexer.prototype.acceptDigitRun=function(){var e,n;do e=this.next(),n=e.charCodeAt(0);while(n>47&&n<58);e!=t.QueryLexer.EOS&&this.backup()},t.QueryLexer.prototype.more=function(){return this.pos1&&(e.backup(),e.emit(t.QueryLexer.TERM)),e.ignore(),e.more())return t.QueryLexer.lexText},t.QueryLexer.lexEditDistance=function(e){return e.ignore(),e.acceptDigitRun(),e.emit(t.QueryLexer.EDIT_DISTANCE),t.QueryLexer.lexText},t.QueryLexer.lexBoost=function(e){return e.ignore(),e.acceptDigitRun(),e.emit(t.QueryLexer.BOOST),t.QueryLexer.lexText},t.QueryLexer.lexEOS=function(e){e.width()>0&&e.emit(t.QueryLexer.TERM)},t.QueryLexer.termSeparator=t.tokenizer.separator,t.QueryLexer.lexText=function(e){for(;;){var n=e.next();if(n==t.QueryLexer.EOS)return t.QueryLexer.lexEOS;if(n.charCodeAt(0)==92){e.escapeCharacter();continue}if(n==":")return t.QueryLexer.lexField;if(n=="~")return e.backup(),e.width()>0&&e.emit(t.QueryLexer.TERM),t.QueryLexer.lexEditDistance;if(n=="^")return e.backup(),e.width()>0&&e.emit(t.QueryLexer.TERM),t.QueryLexer.lexBoost;if(n=="+"&&e.width()===1||n=="-"&&e.width()===1)return e.emit(t.QueryLexer.PRESENCE),t.QueryLexer.lexText;if(n.match(t.QueryLexer.termSeparator))return t.QueryLexer.lexTerm}},t.QueryParser=function(e,n){this.lexer=new t.QueryLexer(e),this.query=n,this.currentClause={},this.lexemeIdx=0},t.QueryParser.prototype.parse=function(){this.lexer.run(),this.lexemes=this.lexer.lexemes;for(var e=t.QueryParser.parseClause;e;)e=e(this);return this.query},t.QueryParser.prototype.peekLexeme=function(){return this.lexemes[this.lexemeIdx]},t.QueryParser.prototype.consumeLexeme=function(){var e=this.peekLexeme();return this.lexemeIdx+=1,e},t.QueryParser.prototype.nextClause=function(){var e=this.currentClause;this.query.clause(e),this.currentClause={}},t.QueryParser.parseClause=function(e){var n=e.peekLexeme();if(n!=null)switch(n.type){case t.QueryLexer.PRESENCE:return t.QueryParser.parsePresence;case t.QueryLexer.FIELD:return t.QueryParser.parseField;case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var r="expected either a field or a term, found "+n.type;throw n.str.length>=1&&(r+=" with value '"+n.str+"'"),new t.QueryParseError(r,n.start,n.end)}},t.QueryParser.parsePresence=function(e){var n=e.consumeLexeme();if(n!=null){switch(n.str){case"-":e.currentClause.presence=t.Query.presence.PROHIBITED;break;case"+":e.currentClause.presence=t.Query.presence.REQUIRED;break;default:var r="unrecognised presence operator'"+n.str+"'";throw new t.QueryParseError(r,n.start,n.end)}var i=e.peekLexeme();if(i==null){var r="expecting term or field, found nothing";throw new t.QueryParseError(r,n.start,n.end)}switch(i.type){case t.QueryLexer.FIELD:return t.QueryParser.parseField;case t.QueryLexer.TERM:return t.QueryParser.parseTerm;default:var r="expecting term or field, found '"+i.type+"'";throw new t.QueryParseError(r,i.start,i.end)}}},t.QueryParser.parseField=function(e){var n=e.consumeLexeme();if(n!=null){if(e.query.allFields.indexOf(n.str)==-1){var r=e.query.allFields.map(function(o){return"'"+o+"'"}).join(", "),i="unrecognised field '"+n.str+"', possible fields: "+r;throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.fields=[n.str];var s=e.peekLexeme();if(s==null){var i="expecting term, found nothing";throw new t.QueryParseError(i,n.start,n.end)}if(s.type===t.QueryLexer.TERM)return t.QueryParser.parseTerm;var i="expecting term, found '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}},t.QueryParser.parseTerm=function(e){var n=e.consumeLexeme();if(n!=null){e.currentClause.term=n.str.toLowerCase(),n.str.indexOf("*")!=-1&&(e.currentClause.usePipeline=!1);var r=e.peekLexeme();if(r==null){e.nextClause();return}switch(r.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+r.type+"'";throw new t.QueryParseError(i,r.start,r.end)}}},t.QueryParser.parseEditDistance=function(e){var n=e.consumeLexeme();if(n!=null){var r=parseInt(n.str,10);if(isNaN(r)){var i="edit distance must be numeric";throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.editDistance=r;var s=e.peekLexeme();if(s==null){e.nextClause();return}switch(s.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},t.QueryParser.parseBoost=function(e){var n=e.consumeLexeme();if(n!=null){var r=parseInt(n.str,10);if(isNaN(r)){var i="boost must be numeric";throw new t.QueryParseError(i,n.start,n.end)}e.currentClause.boost=r;var s=e.peekLexeme();if(s==null){e.nextClause();return}switch(s.type){case t.QueryLexer.TERM:return e.nextClause(),t.QueryParser.parseTerm;case t.QueryLexer.FIELD:return e.nextClause(),t.QueryParser.parseField;case t.QueryLexer.EDIT_DISTANCE:return t.QueryParser.parseEditDistance;case t.QueryLexer.BOOST:return t.QueryParser.parseBoost;case t.QueryLexer.PRESENCE:return e.nextClause(),t.QueryParser.parsePresence;default:var i="Unexpected lexeme type '"+s.type+"'";throw new t.QueryParseError(i,s.start,s.end)}}},(function(e,n){typeof define=="function"&&define.amd?define(n):typeof me=="object"?ge.exports=n():e.lunr=n()})(this,function(){return t})})()});var M,G={getItem(){return null},setItem(){}},K;try{K=localStorage,M=K}catch{K=G,M=G}var S={getItem:t=>M.getItem(t),setItem:(t,e)=>M.setItem(t,e),disableWritingLocalStorage(){M=G},disable(){localStorage.clear(),M=G},enable(){M=K}};window.TypeDoc||={disableWritingLocalStorage(){S.disableWritingLocalStorage()},disableLocalStorage:()=>{S.disable()},enableLocalStorage:()=>{S.enable()}};window.translations||={copy:"Copy",copied:"Copied!",normally_hidden:"This member is normally hidden due to your filter settings.",hierarchy_expand:"Expand",hierarchy_collapse:"Collapse",search_index_not_available:"The search index is not available",search_no_results_found_for_0:"No results found for {0}",folder:"Folder",kind_1:"Project",kind_2:"Module",kind_4:"Namespace",kind_8:"Enumeration",kind_16:"Enumeration Member",kind_32:"Variable",kind_64:"Function",kind_128:"Class",kind_256:"Interface",kind_512:"Constructor",kind_1024:"Property",kind_2048:"Method",kind_4096:"Call Signature",kind_8192:"Index Signature",kind_16384:"Constructor Signature",kind_32768:"Parameter",kind_65536:"Type Literal",kind_131072:"Type Parameter",kind_262144:"Accessor",kind_524288:"Get Signature",kind_1048576:"Set Signature",kind_2097152:"Type Alias",kind_4194304:"Reference",kind_8388608:"Document"};var pe=[];function X(t,e){pe.push({selector:e,constructor:t})}var Z=class{alwaysVisibleMember=null;constructor(){this.createComponents(document.body),this.ensureFocusedElementVisible(),this.listenForCodeCopies(),window.addEventListener("hashchange",()=>this.ensureFocusedElementVisible()),document.body.style.display||(this.ensureFocusedElementVisible(),this.updateIndexVisibility(),this.scrollToHash())}createComponents(e){pe.forEach(n=>{e.querySelectorAll(n.selector).forEach(r=>{r.dataset.hasInstance||(new n.constructor({el:r,app:this}),r.dataset.hasInstance=String(!0))})})}filterChanged(){this.ensureFocusedElementVisible()}showPage(){document.body.style.display&&(document.body.style.removeProperty("display"),this.ensureFocusedElementVisible(),this.updateIndexVisibility(),this.scrollToHash())}scrollToHash(){if(location.hash){let e=document.getElementById(location.hash.substring(1));if(!e)return;e.scrollIntoView({behavior:"instant",block:"start"})}}ensureActivePageVisible(){let e=document.querySelector(".tsd-navigation .current"),n=e?.parentElement;for(;n&&!n.classList.contains(".tsd-navigation");)n instanceof HTMLDetailsElement&&(n.open=!0),n=n.parentElement;if(e&&!rt(e)){let r=e.getBoundingClientRect().top-document.documentElement.clientHeight/4;document.querySelector(".site-menu").scrollTop=r,document.querySelector(".col-sidebar").scrollTop=r}}updateIndexVisibility(){let e=document.querySelector(".tsd-index-content"),n=e?.open;e&&(e.open=!0),document.querySelectorAll(".tsd-index-section").forEach(r=>{r.style.display="block";let i=Array.from(r.querySelectorAll(".tsd-index-link")).every(s=>s.offsetParent==null);r.style.display=i?"none":"block"}),e&&(e.open=n)}ensureFocusedElementVisible(){if(this.alwaysVisibleMember&&(this.alwaysVisibleMember.classList.remove("always-visible"),this.alwaysVisibleMember.firstElementChild.remove(),this.alwaysVisibleMember=null),!location.hash)return;let e=document.getElementById(location.hash.substring(1));if(!e)return;let n=e.parentElement;for(;n&&n.tagName!=="SECTION";)n=n.parentElement;if(!n)return;let r=n.offsetParent==null,i=n;for(;i!==document.body;)i instanceof HTMLDetailsElement&&(i.open=!0),i=i.parentElement;if(n.offsetParent==null){this.alwaysVisibleMember=n,n.classList.add("always-visible");let s=document.createElement("p");s.classList.add("warning"),s.textContent=window.translations.normally_hidden,n.prepend(s)}r&&e.scrollIntoView()}listenForCodeCopies(){document.querySelectorAll("pre > button").forEach(e=>{let n;e.addEventListener("click",()=>{e.previousElementSibling instanceof HTMLElement&&navigator.clipboard.writeText(e.previousElementSibling.innerText.trim()),e.textContent=window.translations.copied,e.classList.add("visible"),clearTimeout(n),n=setTimeout(()=>{e.classList.remove("visible"),n=setTimeout(()=>{e.textContent=window.translations.copy},100)},1e3)})})}};function rt(t){let e=t.getBoundingClientRect(),n=Math.max(document.documentElement.clientHeight,window.innerHeight);return!(e.bottom<0||e.top-n>=0)}var fe=(t,e=100)=>{let n;return()=>{clearTimeout(n),n=setTimeout(()=>t(),e)}};var Ie=nt(ye(),1);async function R(t){let e=Uint8Array.from(atob(t),s=>s.charCodeAt(0)),r=new Blob([e]).stream().pipeThrough(new DecompressionStream("deflate")),i=await new Response(r).text();return JSON.parse(i)}var Y="closing",ae="tsd-overlay";function it(){let t=Math.abs(window.innerWidth-document.documentElement.clientWidth);document.body.style.overflow="hidden",document.body.style.paddingRight=`${t}px`}function st(){document.body.style.removeProperty("overflow"),document.body.style.removeProperty("padding-right")}function Ee(t,e){t.addEventListener("animationend",()=>{t.classList.contains(Y)&&(t.classList.remove(Y),document.getElementById(ae)?.remove(),t.close(),st())}),t.addEventListener("cancel",n=>{n.preventDefault(),ve(t)}),e?.closeOnClick&&document.addEventListener("click",n=>{t.open&&!t.contains(n.target)&&ve(t)},!0)}function xe(t){if(t.open)return;let e=document.createElement("div");e.id=ae,document.body.appendChild(e),t.showModal(),it()}function ve(t){if(!t.open)return;document.getElementById(ae)?.classList.add(Y),t.classList.add(Y)}var I=class{el;app;constructor(e){this.el=e.el,this.app=e.app}};var be=document.head.appendChild(document.createElement("style"));be.dataset.for="filters";var le={};function Le(t){for(let e of t.split(/\s+/))if(le.hasOwnProperty(e)&&!le[e])return!0;return!1}var ee=class extends I{key;value;constructor(e){super(e),this.key=`filter-${this.el.name}`,this.value=this.el.checked,this.el.addEventListener("change",()=>{this.setLocalStorage(this.el.checked)}),this.setLocalStorage(this.fromLocalStorage()),be.innerHTML+=`html:not(.${this.key}) .tsd-is-${this.el.name} { display: none; } +`,this.app.updateIndexVisibility()}fromLocalStorage(){let e=S.getItem(this.key);return e?e==="true":this.el.checked}setLocalStorage(e){S.setItem(this.key,e.toString()),this.value=e,this.handleValueChange()}handleValueChange(){this.el.checked=this.value,document.documentElement.classList.toggle(this.key,this.value),le[`tsd-is-${this.el.name}`]=this.value,this.app.filterChanged(),this.app.updateIndexVisibility()}};var we=0;async function Se(t,e){if(!window.searchData)return;let n=await R(window.searchData);t.data=n,t.index=Ie.Index.load(n.index),e.innerHTML=""}function _e(){let t=document.getElementById("tsd-search-trigger"),e=document.getElementById("tsd-search"),n=document.getElementById("tsd-search-input"),r=document.getElementById("tsd-search-results"),i=document.getElementById("tsd-search-script"),s=document.getElementById("tsd-search-status");if(!(t&&e&&n&&r&&i&&s))throw new Error("Search controls missing");let o={base:document.documentElement.dataset.base};o.base.endsWith("/")||(o.base+="/"),i.addEventListener("error",()=>{let a=window.translations.search_index_not_available;Pe(s,a)}),i.addEventListener("load",()=>{Se(o,s)}),Se(o,s),ot({trigger:t,searchEl:e,results:r,field:n,status:s},o)}function ot(t,e){let{field:n,results:r,searchEl:i,status:s,trigger:o}=t;Ee(i,{closeOnClick:!0});function a(){xe(i),n.setSelectionRange(0,n.value.length)}o.addEventListener("click",a),n.addEventListener("input",fe(()=>{at(r,n,s,e)},200)),n.addEventListener("keydown",l=>{if(r.childElementCount===0||l.ctrlKey||l.metaKey||l.altKey)return;let d=n.getAttribute("aria-activedescendant"),f=d?document.getElementById(d):null;if(f){let p=!1,v=!1;switch(l.key){case"Home":case"End":case"ArrowLeft":case"ArrowRight":v=!0;break;case"ArrowDown":case"ArrowUp":p=l.shiftKey;break}(p||v)&&ke(n)}if(!l.shiftKey)switch(l.key){case"Enter":f?.querySelector("a")?.click();break;case"ArrowUp":Te(r,n,f,-1),l.preventDefault();break;case"ArrowDown":Te(r,n,f,1),l.preventDefault();break}});function c(){ke(n)}n.addEventListener("change",c),n.addEventListener("blur",c),n.addEventListener("click",c),document.body.addEventListener("keydown",l=>{if(l.altKey||l.metaKey||l.shiftKey)return;let d=l.ctrlKey&&l.key==="k",f=!l.ctrlKey&&!ut()&&l.key==="/";(d||f)&&(l.preventDefault(),a())})}function at(t,e,n,r){if(!r.index||!r.data)return;t.innerHTML="",n.innerHTML="",we+=1;let i=e.value.trim(),s;if(i){let a=i.split(" ").map(c=>c.length?`*${c}*`:"").join(" ");s=r.index.search(a).filter(({ref:c})=>{let l=r.data.rows[Number(c)].classes;return!l||!Le(l)})}else s=[];if(s.length===0&&i){let a=window.translations.search_no_results_found_for_0.replace("{0}",` "${te(i)}" `);Pe(n,a);return}for(let a=0;ac.score-a.score);let o=Math.min(10,s.length);for(let a=0;a`,f=Ce(c.name,i);globalThis.DEBUG_SEARCH_WEIGHTS&&(f+=` (score: ${s[a].score.toFixed(2)})`),c.parent&&(f=` + ${Ce(c.parent,i)}.${f}`);let p=document.createElement("li");p.id=`tsd-search:${we}-${a}`,p.role="option",p.ariaSelected="false",p.classList.value=c.classes??"";let v=document.createElement("a");v.tabIndex=-1,v.href=r.base+c.url,v.innerHTML=d+`${f}`,p.append(v),t.appendChild(p)}}function Te(t,e,n,r){let i;if(r===1?i=n?.nextElementSibling||t.firstElementChild:i=n?.previousElementSibling||t.lastElementChild,i!==n){if(!i||i.role!=="option"){console.error("Option missing");return}i.ariaSelected="true",i.scrollIntoView({behavior:"smooth",block:"nearest"}),e.setAttribute("aria-activedescendant",i.id),n?.setAttribute("aria-selected","false")}}function ke(t){let e=t.getAttribute("aria-activedescendant");(e?document.getElementById(e):null)?.setAttribute("aria-selected","false"),t.setAttribute("aria-activedescendant","")}function Ce(t,e){if(e==="")return t;let n=t.toLocaleLowerCase(),r=e.toLocaleLowerCase(),i=[],s=0,o=n.indexOf(r);for(;o!=-1;)i.push(te(t.substring(s,o)),`${te(t.substring(o,o+r.length))}`),s=o+r.length,o=n.indexOf(r,s);return i.push(te(t.substring(s))),i.join("")}var lt={"&":"&","<":"<",">":">","'":"'",'"':"""};function te(t){return t.replace(/[&<>"'"]/g,e=>lt[e])}function Pe(t,e){t.innerHTML=e?`
${e}
`:""}var ct=["button","checkbox","file","hidden","image","radio","range","reset","submit"];function ut(){let t=document.activeElement;return t?t.isContentEditable||t.tagName==="TEXTAREA"||t.tagName==="SEARCH"?!0:t.tagName==="INPUT"&&!ct.includes(t.type):!1}var D="mousedown",Me="mousemove",$="mouseup",ne={x:0,y:0},Qe=!1,ce=!1,dt=!1,F=!1,Oe=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);document.documentElement.classList.add(Oe?"is-mobile":"not-mobile");Oe&&"ontouchstart"in document.documentElement&&(dt=!0,D="touchstart",Me="touchmove",$="touchend");document.addEventListener(D,t=>{ce=!0,F=!1;let e=D=="touchstart"?t.targetTouches[0]:t;ne.y=e.pageY||0,ne.x=e.pageX||0});document.addEventListener(Me,t=>{if(ce&&!F){let e=D=="touchstart"?t.targetTouches[0]:t,n=ne.x-(e.pageX||0),r=ne.y-(e.pageY||0);F=Math.sqrt(n*n+r*r)>10}});document.addEventListener($,()=>{ce=!1});document.addEventListener("click",t=>{Qe&&(t.preventDefault(),t.stopImmediatePropagation(),Qe=!1)});var re=class extends I{active;className;constructor(e){super(e),this.className=this.el.dataset.toggle||"",this.el.addEventListener($,n=>this.onPointerUp(n)),this.el.addEventListener("click",n=>n.preventDefault()),document.addEventListener(D,n=>this.onDocumentPointerDown(n)),document.addEventListener($,n=>this.onDocumentPointerUp(n))}setActive(e){if(this.active==e)return;this.active=e,document.documentElement.classList.toggle("has-"+this.className,e),this.el.classList.toggle("active",e);let n=(this.active?"to-has-":"from-has-")+this.className;document.documentElement.classList.add(n),setTimeout(()=>document.documentElement.classList.remove(n),500)}onPointerUp(e){F||(this.setActive(!0),e.preventDefault())}onDocumentPointerDown(e){if(this.active){if(e.target.closest(".col-sidebar, .tsd-filter-group"))return;this.setActive(!1)}}onDocumentPointerUp(e){if(!F&&this.active&&e.target.closest(".col-sidebar")){let n=e.target.closest("a");if(n){let r=window.location.href;r.indexOf("#")!=-1&&(r=r.substring(0,r.indexOf("#"))),n.href.substring(0,r.length)==r&&setTimeout(()=>this.setActive(!1),250)}}}};var ue=new Map,de=class{open;accordions=[];key;constructor(e,n){this.key=e,this.open=n}add(e){this.accordions.push(e),e.open=this.open,e.addEventListener("toggle",()=>{this.toggle(e.open)})}toggle(e){for(let n of this.accordions)n.open=e;S.setItem(this.key,e.toString())}},ie=class extends I{constructor(e){super(e);let n=this.el.querySelector("summary"),r=n.querySelector("a");r&&r.addEventListener("click",()=>{location.assign(r.href)});let i=`tsd-accordion-${n.dataset.key??n.textContent.trim().replace(/\s+/g,"-").toLowerCase()}`,s;if(ue.has(i))s=ue.get(i);else{let o=S.getItem(i),a=o?o==="true":this.el.open;s=new de(i,a),ue.set(i,s)}s.add(this.el)}};function He(t){let e=S.getItem("tsd-theme")||"os";t.value=e,Ae(e),t.addEventListener("change",()=>{S.setItem("tsd-theme",t.value),Ae(t.value)})}function Ae(t){document.documentElement.dataset.theme=t}var se;function Ne(){let t=document.getElementById("tsd-nav-script");t&&(t.addEventListener("load",Re),Re())}async function Re(){let t=document.getElementById("tsd-nav-container");if(!t||!window.navigationData)return;let e=await R(window.navigationData);se=document.documentElement.dataset.base,se.endsWith("/")||(se+="/"),t.innerHTML="";for(let n of e)Ve(n,t,[]);window.app.createComponents(t),window.app.showPage(),window.app.ensureActivePageVisible()}function Ve(t,e,n){let r=e.appendChild(document.createElement("li"));if(t.children){let i=[...n,t.text],s=r.appendChild(document.createElement("details"));s.className=t.class?`${t.class} tsd-accordion`:"tsd-accordion";let o=s.appendChild(document.createElement("summary"));o.className="tsd-accordion-summary",o.dataset.key=i.join("$"),o.innerHTML='',De(t,o);let a=s.appendChild(document.createElement("div"));a.className="tsd-accordion-details";let c=a.appendChild(document.createElement("ul"));c.className="tsd-nested-navigation";for(let l of t.children)Ve(l,c,i)}else De(t,r,t.class)}function De(t,e,n){if(t.path){let r=e.appendChild(document.createElement("a"));if(r.href=se+t.path,n&&(r.className=n),location.pathname===r.pathname&&!r.href.includes("#")&&(r.classList.add("current"),r.ariaCurrent="page"),t.kind){let i=window.translations[`kind_${t.kind}`].replaceAll('"',""");r.innerHTML=``}r.appendChild(Fe(t.text,document.createElement("span")))}else{let r=e.appendChild(document.createElement("span")),i=window.translations.folder.replaceAll('"',""");r.innerHTML=``,r.appendChild(Fe(t.text,document.createElement("span")))}}function Fe(t,e){let n=t.split(/(?<=[^A-Z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[_-])(?=[^_-])/);for(let r=0;r{let i=r.target;for(;i.parentElement&&i.parentElement.tagName!="LI";)i=i.parentElement;i.dataset.dropdown&&(i.dataset.dropdown=String(i.dataset.dropdown!=="true"))});let t=new Map,e=new Set;for(let r of document.querySelectorAll(".tsd-full-hierarchy [data-refl]")){let i=r.querySelector("ul");t.has(r.dataset.refl)?e.add(r.dataset.refl):i&&t.set(r.dataset.refl,i)}for(let r of e)n(r);function n(r){let i=t.get(r).cloneNode(!0);i.querySelectorAll("[id]").forEach(s=>{s.removeAttribute("id")}),i.querySelectorAll("[data-dropdown]").forEach(s=>{s.dataset.dropdown="false"});for(let s of document.querySelectorAll(`[data-refl="${r}"]`)){let o=gt(),a=s.querySelector("ul");s.insertBefore(o,a),o.dataset.dropdown=String(!!a),a||s.appendChild(i.cloneNode(!0))}}}function pt(){let t=document.getElementById("tsd-hierarchy-script");t&&(t.addEventListener("load",Be),Be())}async function Be(){let t=document.querySelector(".tsd-panel.tsd-hierarchy:has(h4 a)");if(!t||!window.hierarchyData)return;let e=+t.dataset.refl,n=await R(window.hierarchyData),r=t.querySelector("ul"),i=document.createElement("ul");if(i.classList.add("tsd-hierarchy"),ft(i,n,e),r.querySelectorAll("li").length==i.querySelectorAll("li").length)return;let s=document.createElement("span");s.classList.add("tsd-hierarchy-toggle"),s.textContent=window.translations.hierarchy_expand,t.querySelector("h4 a")?.insertAdjacentElement("afterend",s),s.insertAdjacentText("beforebegin",", "),s.addEventListener("click",()=>{s.textContent===window.translations.hierarchy_expand?(r.insertAdjacentElement("afterend",i),r.remove(),s.textContent=window.translations.hierarchy_collapse):(i.insertAdjacentElement("afterend",r),i.remove(),s.textContent=window.translations.hierarchy_expand)})}function ft(t,e,n){let r=e.roots.filter(i=>mt(e,i,n));for(let i of r)t.appendChild(je(e,i,n))}function je(t,e,n,r=new Set){if(r.has(e))return;r.add(e);let i=t.reflections[e],s=document.createElement("li");if(s.classList.add("tsd-hierarchy-item"),e===n){let o=s.appendChild(document.createElement("span"));o.textContent=i.name,o.classList.add("tsd-hierarchy-target")}else{for(let a of i.uniqueNameParents||[]){let c=t.reflections[a],l=s.appendChild(document.createElement("a"));l.textContent=c.name,l.href=oe+c.url,l.className=c.class+" tsd-signature-type",s.append(document.createTextNode("."))}let o=s.appendChild(document.createElement("a"));o.textContent=t.reflections[e].name,o.href=oe+i.url,o.className=i.class+" tsd-signature-type"}if(i.children){let o=s.appendChild(document.createElement("ul"));o.classList.add("tsd-hierarchy");for(let a of i.children){let c=je(t,a,n,r);c&&o.appendChild(c)}}return r.delete(e),s}function mt(t,e,n){if(e===n)return!0;let r=new Set,i=[t.reflections[e]];for(;i.length;){let s=i.pop();if(!r.has(s)){r.add(s);for(let o of s.children||[]){if(o===n)return!0;i.push(t.reflections[o])}}}return!1}function gt(){let t=document.createElementNS("http://www.w3.org/2000/svg","svg");return t.setAttribute("width","20"),t.setAttribute("height","20"),t.innerHTML='',t}X(re,"a[data-toggle]");X(ie,".tsd-accordion");X(ee,".tsd-filter-item input[type=checkbox]");var qe=document.getElementById("tsd-theme");qe&&He(qe);var yt=new Z;Object.defineProperty(window,"app",{value:yt});_e();Ne();$e();"virtualKeyboard"in navigator&&(navigator.virtualKeyboard.overlaysContent=!0);})(); /*! Bundled license information: lunr/lunr.js: diff --git a/docs/api/player-sdk/assets/navigation.js b/docs/api/player-sdk/assets/navigation.js index 393aa4a..35643af 100644 --- a/docs/api/player-sdk/assets/navigation.js +++ b/docs/api/player-sdk/assets/navigation.js @@ -1,2 +1 @@ -window.navigationData = - "eJyN1stOGzEUBuB38TpqICqFZhcaBFRqoTNRWSAWJ+bMjBWPbdnHhAjx7qgpl7nY2Ov/n8/x8Vxy+8QIH4nN2bWEHdqSgLxjE2aAGjZntDPopt3sS0OtZBO2EeqezWcH348Pj2bPk4FzZVBdKuMpTL3H+dqVIaFV5Kd1CnliAarGsLWP8pQ/Hu0urOyjPKVEsLwp0HkZGVe3kTIX/N8YSgNqaH0kecZKtCiFwjNF410GKnm7XaIVD3j/V+A2vNlOIU9cWN6Ih8hpvoYp6TfSVtvNDRDaCqQM7jlYSskFgiTR4uvFQTjUSbklaQs1fnpIoU5ypmgrbVtQHBeWRAWcgnasl3dmP3RrwAqnR3fpMM+cQxwcFVLiqa8LNNpS5JUzzHN2vLWibqjkVpgYG6nl678032SuMKqmVlkhtJfOeVxhayQQRpaI9VL+uaALv+5dOaQDlZT6U1j41BwVUuJSt6UC4xpNBVZDrp9mWEtRVW9PZ2Si4VamHf6o9MKUdIPrUwl8s9aP/x/ND41LcA7ddFTpm4ezk45XIy2xAi8p/I+j8mr/aXHTcLNvf/v6fPcCPWQCmw=="; +window.navigationData = "eJyNlm1v2jAQgP9LPqN17d77DQpbmejaJbSVNk3ocC5g4diefaZF0/77RMpawE7sz/f4udh35/jnn4zwkbLzrG/Ykq8xR6ucYTjhNSeb9TINtMzOM9potCdB6NWSapH1shWXZXZ+9vrTh9N3Z397z+bh6HP/djKd9fOLy/HdaJaPiuvb/GI0m4yvxtPiJccaDIe5QHsSWXKY8c1+stAXjoxR5iUNE2Bty2Ya9FB/evZxz2/QKrHGyGlVTjLiStqTLv4wz/u3e2m2X2hot2ostaN4qtiaaLqJghLL5K1FF3Uk3PFDZKrEKa9RuUiZfLSrTAPlZInlD65zhBID2mOiy3YjYIOmICDnjcR+LDYJT+y1RtmUJ6x6DqfbrnVTkXbfDkgz5iAXGHY1oTTLd4dmE7Y0oTRLgWDYMkfrRMtx7RMxZ7/p3EKD9C6250iaY9uHgkscSfJ3GUBi1hy1gM2Qw0IqS5wFvUEobv7t0G6vAq2kxSGvKt/rIWnVGaLhayzvOD6Ei7MHpBl3wx627YJRk+FrYJsbowibSuSolfHbJ4zF7AVKy2l7IQHBjcF1YPMhJub9hvSgzOoeCE0FQgQ7IAjFOwAE8Rp3i1tay2eiJ0HKwAI7RyHEROuHplKmBsmwb4hXwCjobuPSOu1C1RoMt8q7C47jiefQLvSAmHHgFk/N2HKxH8dTdvxg+GJJBTNct2lbsHT7lWKrxAweGssyRajH1jqcYq0FELakaONi/i+cLt38YOWxOoDErF+5gU6nB0Rf06ouJGi7VJSjd5UfRhNc28v+/3S2nGiYSnSHf90HwbR5HQg1D0/qNhJz3ON8IICt5urxaZH/IPSQrhfhAmmIFThB4bfhyys5THpP41//AJz+rhY=" \ No newline at end of file diff --git a/docs/api/player-sdk/assets/search.js b/docs/api/player-sdk/assets/search.js index 3e231db..86c27f3 100644 --- a/docs/api/player-sdk/assets/search.js +++ b/docs/api/player-sdk/assets/search.js @@ -1,2 +1 @@ -window.searchData = - "eJy1XVuT47Zy/i+aV9WazTvnzbfETuzY2XWOH6a2UhwJGvGsJMoktZez5f+eAkhKDaBBNkbMk9cj9AXAh0ajP5D8umrqT+3q8enr6kN12q4ew6DIIAnXq1N5FKvH1e+H8oto3nVld2lX69WlOaweV92Xs2i/wT+92XfHw2q92hzKthXt6nG1+ns9rfO3szj9fDpfOlLt9ddXav7t3FX1iXYZ/T6pHYIwvqo+l2173jdlK1g6H7T2SP16dS4bcepIbx22m/L0wjQ7NvW3SI/lW6dp9Qt//I71qX7XlQ093TdlD7JhOzSc6kXv2YS1H09bji2hmvlYokfqvy+i+UIaVL/wR8o92zdFrHnuPXJYUXrnrIyNXm3lID6Kw6yZa6vX90Z8poGFO9O3ebWNRvx1EW33M40qPDd9w2oOV9MjVx2r2S6NjV5tpd7tWjFr5trKx45jJxFls9m/Fe3lQJvFDfhrRnwUJ9fMWBofVOvZ6dFcddhtN3VDr1Xb6tj2XpvKe5+evs6mOX/fbuSO8e5cngzbtx/481Vu7Nky9DyUm6k5Qu645kZuIr/Wp3rGjmp37Nu91pY4bRmWxGl7r50e52ZEtQz1AHeH1HlLXVO9vIjmR3JhmfaGxnPrat7qEDq/ry8WxE2bQ9PN0PTVI9o0dcOxpxq+whq9jv6ojuJQncSPp87KGogWS6wsWyFriemevh4thHkv2Pj48ceXsxmV55zoepG7PZiOOoR9bvhhWZ+KQ4RtXkBiWd5emlI2/pWOTYTxUeI4F6Z4PZcgcq9jqvNSgrOgWfZnIxfhgUcI443BdCyjxoAb1HzGgI0A1H6ZvrMtX1vfv943jRCndl+zx1yTeI19OrP+QTTVR7H9RyU+kckh+t1rNxl2NPoQZyp96AXaQWAq7cT+uqJ53ZWO06Nl+NrW3yY9nt82m331kU7uh988qh/lqdqJlk7bsbYH1HKqJ6N3rpGrjuLn01Z8njcom1ZD0zssjqdjntHxiHy/3er0UTSd2DINj80XsdyJl6bq6KKPYfXW1M+iic3/Et2nuvnwZ9mJZlceDlT+SLbxqD+Jv6wUzq1STuVE7kb767AsLbLt9o0XsHoU3b726PC1/QK2W6qwPmH72n4x23/YlbNZ+5OVNL+xr46CSNanRr86islc3XcMyGx9eghm8nUvD+iMfcL+XM7uZd2ZtU84wMjb/efgz/JwkPmO5zx8Kg+HrhdbZi78/RCn7bJe7MrqIDzi0bX9EiMg82K/kKBElosIfQLpswH1EkvtQeK0qbdi+0PZlb+I00u390GCEt2WXXkYRRfwaMiVfhLlVlgnnOmNWcrtr3KL+NKe61MrXuFML7iwN6qH39XbL36YHQSf6+2X5ZA7dlG681PZegBnlJT+7HvJhf15V/3LI6Rhf9pecomVRdeupxbUXBl7zr6ZPb8VpQrUgxyVPFNN7uWhnDpnmSjS4SnbRDI1Y30ymfKxf27qrt7UZgrvNo8E7rd+tJMot+XJBMrHqhlz3CbdQcbHXts1ojx6AKwXWAhh26oRarNlm8cSCyBsb1+/mYDXfuryjY9d+1zqtjp1LPWxWZ9lMsE2e22+wCiXXw51uSWzn4nR7qVmEp9X+PF7Iz7aNc1ZR85XsQVW3ak8ExXeiVV3E3iVdXPfetfVTfkipkhDqsm9+5ZT5+y+RTrsuW/NWJ/ct3zsm9PqtuueTx97xE7lNjm5U/lYVX/iWh0a32+1Pou+WMA2jSXut7+383C36cnU22+Grcg9NcMTcdvH6oYg/txmp0k/H7uNKFuPCb42v9+yIy67bc/G5RnrFlMkml3dHMvTRnzbdNWu3HRUbHY1u/t+25Te+VtuLuc94zTDi8lY7euHRaJN2p8g0jztEnF72vRk7Pa1TsTvaeuTMdzXOhFHp61PxlJf661dvZi2Plm08LVOxrdp+zMxzrv/dJybGYO5WMfwgmbGv6+P57KpiEExfubHt4PYde9E21KlX1Lrg5Roe4nZm7zIYdccVy97TweUyHIeqBGoPPq+WK+ZRvv+LmBV7Qg/iENXsuyq5tuh+Z2WJVXhYVk2X8byUHDm2x4ElrE+MnbKvEX10Q6MMsqDCaqP64O0pezzzMvftmPzO2f9tD3X1al7K16afrnyXBjlGk3O2xfHWd4ZRK3f/aKouoJrdpDWqeKIGNtPJsTMWOJjXAksZV3+iUSXw7hsPwMvtm2Z6vx2Onz5RewcJxHTupSoT4cvh15iIftv5YD6OdAMIv4emKj+7vLyVpzrpqMfvDR/5mO6q7qDmfyR2h7GlnRnLAc9nr6j7U0/gse1dyw//9yJI2vAHo7l52po7G2VyuU+9RnApqnOjolztOLPn/oPX+fD0N4dZimfPZ+kdBqff6rSw7q6oPI/FnUw6YCSmaIQPH04lp/7u8Q+8yphVl6lFvGjOm0Ol634qWzeivOhpK5MOr0ZZPdl04yyr/XJvQZ+rTcfeOvAajm5FkyLf0j6rm0v4g9xPB/KTtDmXM3ui5uTWmfip9Nxj7U3bX968fnad8TVaRdm46uvF4fy2X7+edqHq8gyHpRtW72chPWs97QTWGoZPyrV1q4eTvuhpCarh75+nJv6n2LT/acwY9C0I4PYB+EOP/6eVDVxfXzOj6vQq70wY9K/V91Pl2dNyPCJaHFfJHIpnAlClKcOq/LuEtfo0PZum+Rqd1qdWegedl1r3Gl6fnlPWzcR9B9VU07hx/qdj55dJQ7WnTFa38O1Ld0n20t3j36oj++GAupbsTPM6z/ey+IQ2ma5G8M9PmNBGZvkKXiWzHMnZcZ90OTZGAvarAEcG989hpv61IlTR1zopMwOrSfZD2Zv60uzMdcR2dOx4T3WTvVWUA+OUgZl22n2mGeT5FUogzNsitMasaZ/qHa7kdylM2+6kcfTU0TeO6FzJut1uOzaf4h3xkzZnn59zKxtx/iSL3fRfuOPprrFVVtPSNnaHlDLye5Mv1tlc2lU81lzt4Z3WCu3W7H9vez2jP6ptueh7R02G3GsP7KtDq0XsLvZS6Bz7Q6tF7DbXo7H0rqRQZi8NfS0FuZXY3+K5+8O5ebDc/25ZwSuZged31gt+GvBeFZwWuPck4K2py6MGo9Bz5i9Nfe3G6YhxEa2xO7vDJMwaTeIb1NYn8WJa3Noe6fFv7QXx82YHBvfabNV73xiY2lsfafVF9F9d6ifuWZfRPfcN7/T7vOlOmyHZ/+5xpXM9ipzf8/1dzp4jEFfiu1ugvf7Yj434+HNqRf9hETv9+dtz3b/6LXiX0Q3sORLrX3liXYx+xXT1QwahoFadN6M64keXrW95KLeEBeIfCbvfBMvkfj9fqEDgY8/2/rYIrFF/MAJtJ8r22q3W262NorDFa8ZmUF02dEZlP5SdvJWzM2rP6uOvSsNOg5KB3LvUzXxAIqvh69waFH7w4p/hRvDil/Gmxdxktfyxff4UahZHPcymyk+09uDfxMdP3EZhXaD0J0+iM+SWv+p5Kb1D73AXgks1P8rw+87Bs+Xl2YUXMiXKeKA6dVL1e0vzz3Nc1OxkH/usjTTu39WTfn/5JtJVvu6dr7Kt6P84p7dqO7Xe3esNx/u8DCNtR1V7MrLoRveW6ufh3eXU39n4Ru6IXXQfr9e9S+Cevy6+igaecdv9bgK30RvitV6pB4en8ZLLJv6eOyLPtt6c1H/fD80+4fYdPKNco9PfetvgtX6KVjHxZsgzN+/Xz+NwuoH9YdRx+0vShBW6yegBMESBE0wXK2fQkowtARDTTBarZ8iSjCyBCNNMF6tn+J1FL+J0kQTjC3BWBNMVuunhLKYWIKJJpiu1k8pJZhagqkmmK3WTxklmFmCmSaYr9ZPOSWYW4K5Jli4BqewBAsdABIPBYkAGztggEehh8QdEPjRAQQSFkBjz8YQ6CACCQ0g8Qc2jkAHEkh4QLSOozdFqMvaUAIdSyARAjFp2IYT6HgCiRIgoQg2pEDHFEikQLqO0jdBpM8x2LACHVcg0QIkJMGGFujYAokYIGEJNrxAx1coIQMkwEIbYKEOsFBCJgyImQptfIVGgFIRCtZx8CZJ9PEKiRil4yuUiAlDUtjGV6jjK5SQCSl8hTa+Qh1foURMGFOyNrxCHV6hBEyYULI2ukIdXaHES5hSsja4Qh1coYRLSIIrtMEV6uAKC+cU29gKdWxFgWuwIhtakQ6tSEGLBHVkYyvSsRW5sRXZ2IqMDdCNrYjYA3VsRQpbBdVlG1uRjq1IwiUiw3VkgyvSwRU5wRXZ4Ip0cEVOcEU2uCIdXJGES0RuE5ENrkgHVyTxEpHbRGSjK9LRFUvARGSqEtvwinV4xRIwEblPxDa8Yh1esQRMRO4TsQ2vWIdXrBIsMm2JbXjFRo4lERORSzkm0iwdX7HCF7mkYhtfsY6vWEImIveJ2AZYrAMslpiJSWTHNsJiHWGxxExMIiy2ERbrCItV4kUiLLYRFusISyRmYhJhiY2wREdYIjETkwhLbIQlOsISiZmYWs2JDbBEB1giIROTAEtsgCU6wBKVxWeUYRtfiZHHS8TEJL4SIpXX8ZVIxMQkvhIbX4mOryRzxvvExlei4yvJnfE+sfGV6PhKCle8T2x4JTq8UgmYhFwVqQ2vVIdXKgGTkKsiteGV6vBKJWISclWkNr5SHV+pRExCrorUxleq4yuVkEnIVZHaAEt1gKXqpEjG3dQGWGocFiVkEvq4SJwXdYClEjIJGXdTG2CpDrBUQiYh10VqAyzVAZZKzCTkukhthKU6wjKJmZREWGYjLNMRloErTc5sgGU6wDIJmZREZ2YDLNMBlkXOk1RmAyzTAZZJyKTkas5sgGU6wDIJmZSEdmYDLNMBlqlyRLyO8jdBbAyYDbDMqEhIyKQJKUwUJXSAZRIyKQntzAZYpgMsk5BJ6WqIDbBMB1iuAEZXRGyA5TrAcnBtcrkNsFwHWK4ARi6L3AZYrgMsl5DJyGWR2wDLdYDlEjIZCe3cBliuAyyXkMlIdOY2wHIdYLmETEaiM7cBlusAy93lidwGWG6UvXLnosqJypcOsLxwQju3AZbrACsC56IqbIAVOsAKiZmMOn8WNsAKHWCFhExG7jWFDbBCB1ihAEYdqAobX4WOr0Lhi1yPhY2vQsdXofBFrsfCxleh46tQ+CqoWS5sfBU6vorMiezCxleh46tQdVVyQRY2vgqjtFo4kV0Q1VWzvBo4od3/poujvw3y4ER3/5spb1RZg9AJ8P43U94otAaRC+P9T6a4UWoNYhdS+59McaPaGkgE5XSROCDqrYFRcA3ckOt/M+WNmmvgRl3/mylvlF0DBTy6Th0QhdfAqLwGEk05GZL730x5A32qYJ/T5Wqqvm8V+CWacrpiTZb4DfSpsn1OZg5AVfnNMr+q3Od03Zoq9JuVflW9z+nSNVXsN6v9qoCf0/QIVe83C/6qhl84GBICf2bNX5XxCxr/VNXfLPurSn5B448q/JuVf1XML2j8UbV/o/gPqp5f0Pgjyv9g1P9B1fQLGn8EBQAGBwCqrF/Q+CNYADBoAFCV/YLGH0EEgMEEgKruFzT+CDIADDYAVIW/oPFHEAJgMAKgqvwQBGQAI1gBMGgBUKV+klgEghgAgxkAVeyHAKj4T3ADYJADoAr+ENAAJggCMBgCUFV/CGgEEywBGDQBqMo/PQAETwAGUQCq9g8BvQIIrgAMsgBU/R8CegkQfAEYhAEoEgACeg0QpAEYrAEoIgACehEQxAEYzAFEPQjpVUCwB2DQB6AoAReKCQoBDA4BFC3gmEQChQaLAFHhRjHBI4BBJEDcg5Cq4gFBJYDBJYCiBwAokgwINgEMOgHinmqntxGCUQCDUgDFEoCDbydYBTBoBVBMAQC9DAlmAQxqARRbAA7inWAXwKAXIJ6KhATDAAbFAIo1AAd7T7AMYNAMEOcTKCBQaDANoMgDFwoIFBpkAyj+AIAOBATfAAbhAEkPQzoQEJwDGKQDJD0O6UBAEA9gMA+QuCt3QHAPYJAPoAgFx2GI4B/AICBAcQqOwxhBQYDBQYCiFYC+DQEEDQEGDwGKWoCQTigJKgIMLgIUvQAhHQoIOgIMPgIUxwAhHQoITgIMUgIUz+A4khG0BBi8BCiqAUI6lBDUBBjcBCi6gd4MCHICDHYCFOFA3x4i6Akw+AlQlAOEdCAjKAowOApQtAOEjltEBAgNngIU9QAhHQcIqgIMrgLSHoR0HCDoCjD4Ckh7ENJxgKAswOAsIO1BSK8jgrYAg7cARUUAfRcCCOoCDO4CFB8B9L0EIPgLMAgMUJwESYQCwWCAQWFAz2HQVxuAYDHAoDEg6y/B0cuIYDLAoDJAsRNAX3EAgs0Ag84AxVAAfc0BCEYDDEoDFEsB9FUHIFgNMGgNUEwF0NcdgGA2wKA2QLEVQF95AILdAIPegLzHIQ1kguEAg+IARVsAffUBCJoDDJ4DFHUB9PUHIKgOMLgOUPQF0FcggKA7wOA7QFEYQF+DAILyAIPzgLy/k0kjkaA9wOA9QFEZENNIJKgPMLgP6MkP+k4EEPQHGPwHKEoDYscFTeqGpoFERWsAfTkCCBoEDB4EFLUB9AUJIKgQMLgQUPwG0NcNgOBDwCBEQHEcQF85AIITAYMUAUV0AH3tAAhiBAxmBBTZAfTVAyDIETDYEVCEB9DXD4AgSMBgSKDobwjTSCRIEjBYElDEB9DXEIAgSsBgSkCRH0BfRQCCLAGDLYGeLqGvIwBBmIDBmISKAQH6SkJIUCbj39TjFfjz3PLpCPk483a1/rr63+HRizAYH/H4ugph9fj17/UqLOR//749cqH+OhpQv0mLt09/3tQl8U1dGnuokR+tx4qgQH4FnoparClKbpri0EPT+ACo6D8qgsYsQ77lPI3oBSKok1iTTAtZqsaXPCA9UYj0ZBFPz+01ZEgTIDzIwomSlWdnjsr+VW5YW4K18fp3fVSuHl8RdFNYIFTI4i5PH36XAB60HA8ar4f6q1HwTGLPUqYy6uFepDPG6Mh5QJt+Lhcrx73PvRzWHm5FKhOMnQJ8VNru4fEseAFAe/8XnhvsV8JD4fCOrZuWDI1XwUPe9TVF2JcU4yRl6UHfcbxpShE4mOAdHmxvhldEYa9i7BUvbhuPyROrVVYlkFoeIBBmG/nuPxxNMGQTXii3vqSAggmGBTCnAn1+HW0ICKxR3IsmvDkhPluNdlMUPlMe5q4fqEfeoc0hjHq5aPhvwosq5NcY0Eji7QfYKm9fbkddBtRlHmLUl0Ks9YoXWpgNveatffzpkZvCHKMPeGtEadKzETRUERMit2+8o3FCSyvlbfjqRS3agsI9YvoidVhjHaGVFDFxij4sg8YY73jg0S09CwUcZIe5zwbE58P/F8WQ2yReZvR1jyYhHNZ9xgTt8NYcnI/gaJkxAXb7wBvaGhDGssGtPn1Yr+SFKpbi28sT8A6PXSx4Lu7K6qBnXgnyL+WF8fHlsng3wFhJeKNOvI4B9w7vgsw9Xn/PBlYGWBmvl8YbM7A2HGELHmIn3/KAdeMkveDtgxPvaMCa8ToseMF3+iUGWLkWvXjhy/3+BqwYA6vg7WbEm8LwysbJbM5bgNf3r2E92LOM7dm2fx/DWb2PYXw1Ie6wdtZmu2e9Jwmn73jmc+7Md+6jEMZozsUo8b40rBPnfzk3gDjenIX14jCSc8OI+9VlWDUOAzk3DJhvasMKcZTKuVHKfqEZVon3iJy3R8yFKMBDGvOG1DwGZgiSRTTuhjz/9G9GocQXL8iQNx/G55+QMhzQQp5j9udZ0KgFeBFGXIWdeOm/cXDThGHHLAWNpcdeA9aFEBfzQhj6DgXGBIYus7A3vV1paWPCi4P9h14RyhBSi7F8FvCQgb+LhmCBvQp5/Ry/boDBgPegOBg8Y84A/lYd8gyvSmaR1vhyKEr9cdgIeCC7fooTqcEbBfCC+kF8tEYLa+Ep6V/njXdrPODDeDOPasfyVO1Eqx+yUHhghkD8KSncOTxGEdej8eM8WBFeguF4wmAea4+i29fa3OGkgXkqO1ZHKzLg0mHCSxX6z8CjVYziOvOYY1ZdUrQ4srQXKsZlF4TDPxK+cmEEGqSerUN94UzzEp3EWFpUaw0DGOYRLwiYaZnFsOAcghmI0YcRcAUSJ0zMkly927VCV4MDHW/p1eeNgakUwZKZafWvuMYhBTuScT0Rfc1SQw8KAczD6bls2/O+KVutV2iieCv2XH451OXWLnZmKOYyU9tBl/r6gPikKUORiZmDEnm9hcsc+cg8GZ73xoilKI4zz4L9yY0g/vCBMGZ2U+naoA/gou7h1RIw+6f0DfyaOQ0YrzFvH+31SeRXp/NFX4bIO09dBDuBq+Ie2oZ3wKNBQ9PpoWf49AiqiqGQ56Gnfz08wengTZ4X210FAgR6Pl7N2g3FDwX4+MQsHJulG1IvxjHzNsPtEyZ4+9AGkeng9SNx+GihJYPMlYq+eodV4c7FzEDZ1F29qbX6R4pcYm4lFvYBX66AjAczC/k4SAyJ0pAejcSNfI5o+MeYZTILx0ZVxYroKQoqGVulETcztLUX6Zjjjf9IeYtG/9oLBiGuC6S8GNOIv/RzES5KMYvlQ9VIXuqw2CfkEt8jqc3m6RCCwmHIIiaQepU2i4SPusx8bdC1F+VW6IwdLmIzCftBmUFK4XXLW7ajHquGgiIy81bNoErnIhH2I+4stuf61AoJCrPAhsvIzBswWF1b/UvPk1AnM+4S6tVRs4i2GuZFK7XJEFUPHPJC5lJS+xVZ9sAZPTC7qbQZenCVCHiBrN3UjX6Y0y4cMXU0QpxkwV5DFtpbYl6nxs/c4M1Fo3F5k2aiKMdJY8CLBSMFoYV4tHr7mv56VYy7U8D0DX0WEW/oOFwxCxbjdwdxeME9Zd7rUPUA674IUhQOhYpo+C/z9tTtS+o4K8NQj5gQlYrIeyIoOjA3WDuxxRfskrFEx8wCem3mxohv1SVMtPVcCn0iK3DeyLyjYZAzVsqTIZ1MKq2V7NRRx22KVieTi7x+5Q0vclxqZcZ5vYSF4nE2rMh8TCCDMW9kTkYnu4kJAvJ4gRMyZq5iAkVjL3iIkxNqpQKYWo6ZrvRfecYdwnMwJmEQjeGNq7fuSr2MjgM486TSNdXLi2iISz34jmI4RGBmvXFQahaKcWId8TZyqYKgaPBkAi9Eqk8hakrwRsVSYQRYzJEnQ7xm0tqfxPPz8BGI8/DpRLxEcfc495ver1fn6txTw49P7//++/8AXCoYbA=="; +window.searchData = "eJy1XV2T2zay/S/y65SjBkVS9Nsm8d7k3mQ3185uqq4rlaIlzAzXEqmlKH9sKv/9FgBypgEcUk1JebJrhP4gcNAA+jTI3xdt8+m4ePXu98WHqt4uXqllkVOq7hZ1udeLV4u/tJvH6qN+o4/Nqd3oH6p91R0Xd4tTu1u8WnRfDvr4FWzz8rHb7xZ3i82uPB71cfFqsfjjbrBCS7V6MrEvP39fH07d1186LVf9Yl9+rozY+16MGbpbHMpW192o/+OevK679ss3zanuZnmijdimF7uhJ/+oN83+0OrjUW/n94/16sRU3Lavfm66cnedh51R8ed5+KPuym3ZlbYv53u378VtP/4JfTe4d2G/De7d1rNv+rGomvpN2VXNLL82z8JtL3yj2fBR190F89KI3XZefl9v9ec3etO027nRaqs/t0+CN/TGds4bfa9bXW9mx9Ctdt3UcgU3wtLjqf7wrd4020uCw8ZIb530nzD7LvbLTr7b++U0/lztdXPqfpzhkhPsnOD+cm+S52X/29d//cs/fvj5t7+8+ea77//5+rc3r9/+/R9vvnn92w/f//j9z2+ffPtYtlX5fqePX52RmN4OqPXkhuN12zbtk81eBewR21K+9bD/zNP7opeRd7Hz/tmDlJ77edPUx649bbrZz/fCF73cHa9D2r7lXGeY3I082ZkWc90YhG7kQ7npTuVurhNPUpd7kfkjsvuop/fh96d601VNffxqqvnkvGAmza9t16uwW/OzZs+JzDP9Q1Nu9Vb6yGdl5saeb3kgnow9ccvbxJ4RvaLYA7y/OPaM+TEj9ky643VIF618Qm/EK9+0LwwIXzenequ3/1cd3uhyq+P+CRtMDru006FSUV9H/j4bV8vV83M9lnHnYquu5XXWWl1uheb6ptfa2+nyGE+qMZND6wus+kmSn3blF92+7cruFG7b+E+TIME6/37QtQ2nUO3Trxdq/vvBRtBR3f3v8ph2KI/Hw2PLB2FK5wuvPR6G2NuxrUtZPwjNDk2vtTiVFBsz3cvsBpn5PuDxfDP6+PaXGem4pm7edmWLIfes7IVpeOwbTj2F82zC2ut6K7Gl6/EQgS3hnvrfk26/QIP2F3lPjSPuWZEIa86jsWXR6D1nZWh0sZWd/qh3Z808tbr8afRnDCz+MK7NxTZa/e+TPnbfY1TxsXENq3O4mu4576Ay1nGTBxOBleb+/qjPmnlqNcfOyGqmy3bz+EYfTztsljeQzxmbaRoZmUjjC9v67PB4ro7YPW6aFs/V2OrQ9lqb1vs5T3qZzYiyseeit4eyDlNITz/Ix6vcxKMV6DEH3okxYu6MjY1ZRH5s6ijVHNix7fau3aW2dL0VWNL19lo7DudRFi805AA+cXI5a6lrq4cH3b6GEyu01zc+N6/OW+1DJ8zIBzb7pmey8Od71JzXJPZswwus4XlkTou7qtaWQoKWvRa3mFmxQtEU8z29HC3A/CzYzPHj5y+HMCqfc6JzIld7MB11gH1p+BFZn4pDwLYsIIksb0+WmatjhmHM+CAxlWCZ8eSjTN7owwtIPLn9s5ELeDAjhMn6YDqWoT6QBrU5fSBGAGt/m2cXW35qff1837Ra18fHRtznnsQl9sMV5Y0+7Mov31blgzk5Vxu0psA2160q4yrPrCvY3xHLm6a+r7aGP5ab92Ru4MPk6jbhh3B9u8QXsMKdd2RyjZs3KuXpqL95LKvwMDA1KkZm08vcwIc+erzRx0NTH/W31f19OP8nvOml215620vfwC8bW37Ux2P5EGVYJhyyYvtnsRt48hxpZkP3WfRa9MbRKhq2yK2oxYzsmf43eE6s0IBg8sliT8fyjbp7bMRmn1pfbdeYExp1Ta+2eER0xKjRp9ZX2+3n69fNFlbcTQ2x+eF9sz1TajfLF9fmAmfcX27qjVH2tvqP/lbvcM3fqDtG8lj9R2+N5A17xzb9ztJbfyv3s8fq0UrWveTNRusih9xfbuzRvjoeq/rBRU7LtIk96kVbK1oNopd4hPOz3+q2+qi3/6z0J5hiZL/P2j32eRFMBYRKXziBYy8wlbzk/o7tmExdndDwU9v5NnF/9iUC0Hr/25yS9rq610ec/OXaXrCWU08yeDdROmErQc8bNE2rvukVFgeORWZ0IFqut1vVH3Xb6a3Q8ND8JpY7/dBWHaYOA6vPTa+weGirj+Xmy49iMPUCF2MqmhlO309t02k70d/oQxMTw7jVDE6o3rRfDp2OWKEJxS+40MhTjng/iuiti2ZzvOBCN/Fiq02zeV3BZG7iw3FT1jUrehK48CxyqQch9t7q+lh1pmir7MqfWv0xXuhQEznq7AryY9ltHqNtxqhit+zsn4Tww0LXx/q63B92Mxx4bn+R7bCX/6a7T0374Zey0+19uduhjBRsc905b1zlmZMe9ld85pqwO3XqmmUVnjAnDJ85Y86yDc98E7bPnPousP1zXN9x1v5kvce8vq/2GiTcpnq/2uvJbNvcPoCc0nQXnGGVZnmAeaUJ++eYpVnWR7mlCQcE7NL8Mfil3O1MVn7mOHwqd7uumqixnjsW8/3Q9fa2XtyX1S7aT0w48NT+Fj1gUqXzQoIVuV1EcLuzOQuQk7jVGqRre03MrMY/6Pqhe5yDBCtqbnjuBtEbeORlfWbMUy/nc6O56md8ZjnD8z038+YpczkPsyx1eTvk8tzld+VxBnB48vLRSd7YH5PFvMyfo5O8xczCFVZTE+pcsdU5+zFDUtpA3cthOjducm215KjOs/WS0OEp25C9nLR+hrqU2z+0Tddsmpg2GTPPBK63vo83UeOWJzdQc6yGMWfc5HiQmWPP0KrlfgbAnMCNELatWo0yLuPmucQNEPYYX1SZgNfj1DWVOXYRFzhmdZoMlNtsDmYzITb71PwGvVx+2TXlFu5+JnrbSZ3Z+FzgB04lnXXk8CR2g1lXlwdQhzQx654FLrIe5da6pi0f9FRpK2py7bo1qvPsugUdnrlunbE+uW7NsR8O67jd8fGcYw+sVOMmJ1eqOVbtn6RW+8bXW20O2iULxKa5xPX2H+N9+Ljpya33vBGOIvfUCE/E7TlWN6A8ddzsdGnqHLutLo8zBvip+fWWR+LyuO2zcfmM9Yhv0+190+7LeqP/0nbVfbnpUGwea3b1LawpvefvYo05PzNOC7yYjNVz/YjozEn7E6TqTLsgbk+bnozdc62D+D1tfTKGz7UO4ui09clYOtf6Mc5eTFufTFrMtQ7j27T9MzFu9vPjOHemD87FOoEXuPLGvHCubCvQKcHP8vi20/fdW/caupG7poHWF0bi6CTO3jdlDo+NcfXwONMBK3I7D2wPVDOe/WZPLTTqnvcGVu2KYEsqRXZt823f/ErLhqqYYdk0v43lPuEst90L3Mb6wNhZ8xHVhx0YZKwHE1Sf1Adjy9qXmTe/bYfmV456vT00lXnf4kP/pkuZC4Nc68nN9mXkLD8aRKPf50VRex8hKk+BOm0c0UP7yQ2xMJbMMW4FbmXd/Amia8S4aX8GXmLbZqvz93r35Qd9P3ISCa0biabefdk5iRvZf2M6dJ4DbS8y34MQ1V+fHlyFGH5FUfjzjKqvqtuFmz+o7cXQEj9M5OAYjsE7YrC96RfFSO2ZV8J2ei/qMPv6177xbKtoL/fJ7QA2bXUYGbiRVvLxs//IdU6/MW/M5xljOWn8/Lt/Zli3BSr/iKiDSQeszBSFMNOHffnZ3XidM64GZuWT1E38qOrN7rTV35Wtu5oxx5te9rFs20H2Up/G58CPzeaDbB5ELSfnQmjxZ0PfHY8n/bPeH3Zlp7G5sWbXxc1JrWfi56jjM+betP3pyTfX/khcnXbhbHyd68WufB+/pWvahyeR23hQHo/VQ62jiuVpJ7jUbfyobNs4ezjth5WazB7O9ePQNv/Sm+5/dBiDph3pxT7o8fAz35OqAddTzvnxJHSxF2FM+q+q++703hMKfAItrotEYwrPBCHk6cTdTanRvu3VNuFsH7V6ZqLPsDs2x0dNn5/e09ZDBP131ZZT+Il+l6PnvtK7qGYM63vx1BY/U+zl+BN92+zf9gnUNzq8Ru//eC2LA7Sd5W4C9+SMBTI2yVPILIXnTmRm/KApszEktEUdODS+ug83Td3pugMFnchs33qS/RA+rf9a/4knnX6Pv8xa3Ww1er0RMmjaTrPHMpuQV0EGz7Apo9bAnDY3tQdyF++8caMZt6fAvndC55ld74jLY+sPeLPplO3pl5yetT3Sv/AVpN5v8t60VVxNdEMq1vaCtZx8nOk3gG5OrW1+1txzwyusldut3v5Udo+C57NtD33bK2y2et98FFvtW9/A7ubRAF1qt299A7vH035fRhUZwORzw3nWMEf69a55D1kG84Mc//v4KlKg5sV+6uIRc2ZsnwxeaxKamH6BCbbBPs7wi37/9a7cfHjffHZtn8z1Cr+KWsh7KLhBOa3x3P3J2NOxmRu8fOKM2efm8+2qTNEq2EOKn/cMvzJpl3+poTnoWmqzb3ulxX97L30/Y3JofKXNo31fsxhLQ+srrT7ozosWZ8w+6O69a36l3fenarft37giNW5ltk8y1z/5uTdWnO+M/h0ahycN7aDheu8m32lw3rXjIG6uBJ4rSJ/jl/8WyxkeubR+9yx4vS/hW+rEselBdy6hv/Vkr/covBU2w6HaiX5iorfoIVvL8XpW5La9Y+VuFcOfPYFvCBT7w1/seCOvvMsQF8C67TX0w3dTfAclwXOmv5O8qTegaG8OpA7P4iUTv94vdgif48+22R+Z2E384IfWea4YRN9utDa2bkJf0jO96G17p1f6Q9mZSrRnr36pOvGep9exszqYe5+qiUtfcz28wKGb2u9n/AVu9DP+Nt486NpchdHf8OuHZ3HsZDZTNQSzPfir7uTb4kHovhe60gf92ezmviulh8YXTuCxHH+31eznf6qqmdsH708Pt9uNOpVTZJ3Qq4eqezy9d9Tqs4ob+TdOBQm9+1fVln+Sb2GByFzXDk/yx0H+5p49l5dc7t2+2Xy4wkP2eVezour78rTrXKvgk4nP33TFDVEa59e7hXu546vfFx91a+pqF68W6mXysljcDXTfq3dD4dim2e9donXbbE72v7/2zf6pzTc2TWPX+qvl4u7d8i5VL5fL5Ndf794NwvYH+4dBx/NfrCAt7t4REqRIkDxBtbh7p5CgigSVJ5gs7t4lSDCJBBNPcLW4e7dCgqtIcOUJpou7dykSTCPB1BPMFnfvMiSYRYKZJ5gv7t7lSDCPBHNPcL24e7dGgutIcO0JFou7dwUSLCLBwgeAwQNB7FAMHgrQY+GD8QMA5COIDC4IYohiEJGPIjLYIIgjioFEPpLI4IMgligGE/loIoMRgniiGFDkI4oMTii7WyUv00A2xhT5oCIDFcqRbAwr8nFFBi0EkUUxtMjHFhnEUIEMx+giH17KAEZBeKkYXsqHlzKAURBeKoaXCgKUjVA4RIEY5cNLGcAoCC8Vw0v58FIGMArCS8XwUj68VDqGEBWjS/noUtkYQlSMLuWjSxnAKAhrFcNL+fBSBjAKhkoVw0v58FLFqNcxvJQPr8TCC4baJIZX4sMrsfCCkyKJ4ZX48EosvGDITWJ4JcEaaBdBOCsSsAz68EoMYBI4K5IYXokPr8QgJoGzIonxlfj4SgxkErx4xwBLfIAlBjLJ6i7JXyYrXzbGV+LjKzGISSA4kxhfiY+vxEAmgeBMYoAlPsBWBjIJBNgqBtjKB9jKQCaBAFvFAFv5AFsZyCQQYKsYYCsfYKtkrLNXMb5WwT7LbrQgOFdgq+Xja2UQs4LgXMX4Wvn4WhnErCA4VzG+Vj6+VgYyKwjOVQywlQ+w1XpsiVvF+Fr5+FoZxKzwzjTG18rHV2oQs8K70xhfqY+v1CBmld0lxcslkS8c4yv18ZUaxKwgstMYX6mPr9RAZgWRncYAS32ApRZgENlpDLA02Mzb3fwSjFQKtvM+vlKDmJSQbAyv1IdXagCTKiQboyv10ZUawKTJ3Sp7uS5SXziGV+rDKzWASVfIcIyu1EdXZvCSpshwFqMr89GVGbykGRSO0ZX56MoMXlKIrixGV+ajK0vGxjiLwZX54MpWY92VxdjKfGxlFltr+MQxuLLgsDgKrgwcF31wZaPgymJwZT64MguuAjodgyvzwZUZvGQw1mcxujIfXfk4uvIYXbmPrnwcXXmMrtxHV27wksFVJo/Rlfvoyg1gMrjK5DG8ch9euUFMBleZPMZX7uMrN4jJ4EqRx/jKfXzl2dikyGN85UE+wkAmg6tMDlISPsDy9diMymN85T6+8mJ0RuUxvnIfX2uDmAxu3NYxvtY+vtYGMRkMQOsYX2sfX2uLL5yIifG19vG1tviCy9s6xtfax9faICaHE3Id42vt42ttEJMT6u11jK+1j6+1gUyuoHAMsLUPsLXNeCUAI+sYX+sg52UgkyN8rUHWy8fX2iAmh8Bex/ha+/gqDGJyiK8ixlfh46swiMkhvooYX4WPr8IgJof4KmJ8FT6+CoOYHCf7YnwVPr4Kg5g1xFcR46vw8VUYxKxh2C1ifBU+vgqDmDUMu0WMr8LHV2Egs4Zht4gBVvgAK2xWFYbdIkZYESRWDWbWEGEFyK2GyVUDmjWEmPvNF2d/6+UNbtYQZe63UD5IsS4NdNY477cESdZlkGVdGvSsIdbcb6F8kGhdGgAVI9llkGpdBrnWpcFQgRPMS5BtXQbp1qWBUYFzzEuQcV0GKdelQVKB08xLkHVdBmnXpQFTgTPNS5B4XQaZ16XN6+Nk8xIkX5cB/my+vsD4Q+n9KL9v8FRg/MEMf4A/m7UvMP5Qkj/M8tvEfYHxh/L8YaLf5u7xikgo1R/m+m36Hq5rhJL9YbafxtdUQgn/MONvs/hwaSSU8w+T/i7rv8TTD+X9w8S/y/wvRwgegL8g+082oQ8PRQTS/xTk/8mm9OG5iAABQAEDQDapj49GBDgACkgAsnl9Wo5QVAB+ARFANrdPSxw/ABdAARlAjg1Y4gACCAEKGAFylMASRxDAClBAC5DN9NMShxDADFBADZByIMQxBLADFNAD5PiBJQ4igCKggCOgxFGcOIoAmoACnoASR3PieQSoAgq4ArLpfxohSgFdQAFfQJYCoBGyFFAGFHAGZGkAnIIiwBpQQBuQZQJojHAFQAyoA7JsAM5VEiAPKGAPyDICRDAWAgKBAgaBLClAI7wtIBEoYBHIEgNkmDl6uaQ8UABgGDAJZMkBMkQXUADIBArYBFo5GOKJAAgFChgFWjkY4okASAUKWAWyTAFhOpcAs0ABtUCWLcDrKeAWKCAXyPIFhBlhAvwCBQQDWc6AMCtMgGOggGQgyxsQZoYJ8AwUEA3kmAYFcQy4BgrIBrL8AWGilgDfQAHhQI5xwBMRUA4UcA6U0vhEBKwDBbQDpWpiGgDmgQLqgdJkYiIC9oEC+oEso0AK5h4JMBAUUBCUOhjiJQ3QEBTwEJQ6GMK8GAEuggIyglIHQzyTASFBASNBlmQgTEETICUoYCXIMg2UwK0dICYoYCbIkg0j8wBwExSQE2T5BsJMNgF+ggKCgiznMDIPAEVBAUdBWTI+DwBNQQFPQZZ7GJsHgKuggKygLJ2YB4CvoICwoCybmAeAtKCAtaAsn4AxYC4ooC4ocyjE0RSwFxTQF5RNoBAQGBQwGJRPoBBwGBSQGJQ7FOL9OeAxKCAyyHIThEsbCHAZFJAZlLtCOLw/B3wGBYQGWY6CcIkDAU6DAlKDLE9BuMyBAK9BAbFBlqwgXOpAgNyggN0gS1gQLlkgQHBQwHCQZS0Ily0QYDkooDnIMheESxcIMB0UUB1k2QvC5QsE2A4K6A6yDAbhMgQCjAcFlAdZFoNwKQIB1oMC2oMsk0ErjETAfFBAfdDalWViJAL2gwL6gyyjQbgsgQADQgEFQpbVIFyaQIAFoYAGIUttUIqRCKgQCrgQsvwGpRiJgA+hgBAhy3EQzpkASoQCToQszQErhwiQIhSwImSJDho5aAJihAJmhCzZQSnGMSBHKGBHyBIeuMQW0CMU8CNkKY+R5wcQDBgSKlx1MJ5FgCShgCUhS3xQimcRIEooYEqocBDEswiQJRSwJVQ4COJZBAgTChgTKhwE8SwCpAkFrImyLAjGsAKsiQpYE2VZEDiGCpAmKiBN1FKNY1gB1kQFrIlaOggusQJQPxzQJsrSIJTBvLUCvIkKeBO1dFXEuPoZECcqIE7U0hWq4wpowJyogDlRlgkhXOygAHWiAupEWSqEcNGCAtyJCrgTtRyPhApwJyrgTpS7GYHrDxQgT1RAnih3O2IEBoA9UQF7otwNiREYAPpEBfSJcrckcBWEAvyJCvgT5W5K4EoIBQgUFRAoyt2WwNUQClAoKqBQFI3nDRWgUFRAoSh3awKfkhQgUVRAoihHouBzmgIkigpIFOVIFFzToQCJosIrFJYVoRzfg0C3KMJrFJYXoXzkLgQAYnSVwgIxx9EA3qYIgOiYFHhOU+hCRXijwhEpOY4m6FJFeKvC8iJ4SUD3KsKLFY5GQfXBCl2tCO9WOBIF16kodL0ivF/hSBRcq6LQFYvwjkV/yQJHAnTPIiBRlCNRcM2KAiSKCkgU5UgUXLeiAImiAhJFORIF164oQKKogERRjkTB9SsKkCgqIFGUJUUI17AowKKogEVRjkXBdSwKsCgqYFGUZUUI17IoQKOogEZRjkbB9SwK8Cgq4FFUMlqtpQCLogIWRTkWBVfEKMCiqIBFUY5FwSUxCrAoKmBRlGNRcE2MAiyKClgU5VgUXBSjAIuiAhZFORYFV8UowKKogEVRlhYhXBajAI+iAh5FOR4F18UowKOogEdRjkfBhTEK8Cgq4FGU41FwZYwCPIoKeBTV39jAQAZEigqIFOWIFFwbowCRogIiRVlihHBxjAJMigqYFOWYFFwdowCVogIqRTkqBZfHKEClqIBKUZYZUbhARAEqRQVUirLMiMIFIgpQKSqgUpRlRhQusVCASlEBlaIsM6JwiYUCVIoKqBRlmRGFSywUoFJUQKUoy4woXGKhAJWiAipFWWpE4RILBbgUFXApKnOXbDESAZmiAjJFWW5E4RILBcgUFZApypIjCpdYKMCmqIBNUZYdUbjEQgE6RQV0irLsiMIlFgrQKSqgU5RlRxQusVCATlEBnaIsO6JwiYQCdIoK6BRl2RGF76UrQKeogE5Rlh1RuMZBATpFBXSKsvSIIoxEwKeogE9Rlh9RhJEICJXhb/bVGB912+nt9+4VGe/eLcwr+7aLu98Xv/WvzUiXw+s5fl+k6eLV73/cLbLE/Ztn5t8/nl+bYf86GLK/GcvuNYC+WlPd9KTX1C/JFR0PZc1VrQrm4WqmoiPXtFbPmoo5zza8xku7zzGz3sufNWZKqvFU7rgSxYZALWVK2PubWafna6YpEaoa3ibL9KwzrqeYo8e8zX1r31LXnDr7zUvvUVdMsXAsnd5Wu1fT27eMR3opZWhLL9br9SYbFWFPHo+67Xq9VX04dePKFYOiEgLHqt815VZvzzqvEj6CUv3DZzxYx2bEwblysiYpJlHpPoXCtXFgkaxbjRLzCWb7hcX+5dHPKnM2AwsSKjzVW739T3VodbnVPkC5g2uZuuG1XM3wCQCOS9Z9hleSKeSvxWXKCg6alQzmUYeZky6DhiwMbsrTUW8ey8qLzHw017LB9F/Fzr3iQTCRwQu+2JDHVRYVzA5xhs6RdxJy5RwpqSxGohf7cZU8fGcyMHuvLOS6cq5LODhNfV9tdb3xVoOM9WIuW8g3TX3s2pN5oZa/PrHJkPehJOv/lfYg+yQK18zDlHAR7T87wrQkS76MCAEzfLuBe8M7X/hk3sK59ycHC+YkC+Zb9/q03/qF4rdhpfgtXiqIxQOSrclbbd6O7UengqtZyvpuW7XuLdueP1Rwh2ShoH/fatt/LYSPBQ8Cwo1o8PZWFNhzHkWFWxkWTlrzGSi+MBZ8YZSF9uij2nxMFZ9qQgT2+nxVKfMsWztREm5ZdG0wvTUvLN/p+sEPUcRDFAkXW11v2i+HEHr8YZeyFU3XW/elKvakTE3ah6csH55YFo7hZ775uPBZshbr/FTu7Cup/f7jYUEIQbtzjgJfytYyt+7cLXKhc+yz9vw5eSQVrhpW1V4fj+WDv2XJ2Lq4FiLP6PKVsNHNZUHUKun0Zz+g8F0FKSHaPgZLxIo9Uir0xuiIhi5jHZ3J5rlVFA9ZwsOu8Oj/9GU59mQMS2mPJUr6Vd5Uvrv/uNOyOUrMsuSHJjYB0iE0JcKJ0L8Hn29B+aq9ku0hrBr3SVG+gvFZngwh5Kk3MuGAP797mG3siO90MtnD3pfVLjhLLLmLSrbgDF9E5OsW37IKdw/gfcb8+fghJ5M55r+omu+C+bIqTNAEr5zmrvEzinAQJ1+TzHXzA3smmxMTLznmmvlOJZOF4um3AHPl/BQkjD/jL0Dmivm2LZNtDsAnSfjpmWtciTW6z+NwPXwCrqSQ7/od+cG+0Hj4chR/YB59cln0QR8a4OjnI59KR74bP09zjKZSjILPoPDe5I+dih8bf3qC+8rDSCoNI+Pf/+Eu825Yibth9LMi3GseYYT7AvzlG+4vn6Mr6RwNPxjDFfKgn8r2iWNffOFqebBOpcF65LtIXK+XdBZPg+iDK3yceAwQkhJnVgDieRYlPEA/lj7q2WQSpv+jTIpig0vpsG8TrhtGW1Pvvuz0vT9nOIljKvznKLNLhJ9V5aeLtcy1qt7sTlv9WLZutvgK+VG0kCrs9IP7ejqjmPhzLmUTeGDpnAqujA/FUjYh2DfuObZ4NFzKouHkrsIUwLKNn+xB7QcBPKxxkjBdDViTwcNos6eY4ITNO20te9Th4+n8+TxSLhkOK7JgZ+AP4iY/GpmbOlJVR5dJCBjWhJ9Dcxk6rLZQj5eVlcWynf4YdNiKRUQhNdF/MJhHaj4LndSqP9cp4Ul7X9bVvT56etespwrZ+rcvP/cksh8n+AgKKeR9+XnzeKo/uATvNiZkOEUkw5dR2ewPfYbJZu08ToxlOqT6LLUdZRd42Jil6VQPDoInZvsn2SQwWmH2gyeGpJqsqFXX6nvdGrrDHw++uMgW0UFrqzdN65+QGaTFyLPccYwT5pZYU6eDxDDPmVDRZySUcHHZl5/3uivNRsuOcuQjm2iyGLAvP3dNV+5GJwdP7YqdtCoHTyOdPNk5S+MkqNkmUxZD97p7bLxQnLOHXQ87L2F+bB9mh9deBYBwNKp9vHdYevFJCOLqeKzqB7fTsnD2C3HYFCuEvdVsfbcUXyyEh9sw4088fUFJv+RQmgw7kXRY9+X6tb/HSViIWgnnbWNOcaV/6uSrtDA3aZt7exr2sAPt2j+hubMp0hmc5aOKKH6II+G+sG7M9A/J2JwDWEg4Nff3R+0n2vm8lIG3OWwisPE5INzeuC9X8xnJj81CLDQH7Ug53x2+ZRMmNw7l8Xh4bMuj92QJX4tlkeZQfjFFSIDQ40VNJEyM9NrQuZ3XMJHw3A7yQhFEzRtO2ESQDcThMeg54mV75qVMIi02+wdK7nhRTiHbyjhdUXqSFzkqIRniVLlSkuoYgC3hTIGQFXQK+xqmcGDXbDIVQvBafWZG2cXEQzDTNutxjTbA7HNwCJdNp6//bDzTxEKhsIzBaWpNjZSniUVuYTlmn2e2X5WPqyJWfKMmW03GEte8ZiaRz4KQVYDFc/woP2NWcFIB6uWILoQjYwJU4z+6uTzIliihg23VhFkjyniKcikMdS5VjQ68hXeEEU6K85lvznyQMIt0aJt/6U33QQcPzBd3YcmGca3ZND5/4FX+JrIhiGYq8bSdEs7VeJayQRxI39WwlSwG4rsYamiXw+FLyL6aYlXvbM5mRyILywEFEa+MfqWZ2K9oyeBASYdtdVYMDywLE63ellF1GCdfSDhTWr3TwerNTw/CatNW75uPsOqeg0c8Ej5jEw0Fjwi5dCD+7af2eFHketjoC0n6nqwxxddxzTWbc2vZcsTURQU1XsJF3H9WX1zJxFzL+iknJFN7laCWiaM5l4WYXtmjLTA3wn4HsqkrXH88hUG+ii+TwmrTXl1QusPmlnC/PeiJqAt+QlnOGtMxfjBnMFnPAjHX6GeCmJfCYrxepa+GAUSYiDf1uLuP+vw9Dk40Sj10yvwFjjMPs9TEPnHiQbjiDiOAwwnbRRXSufqsL2IwvYS6OLg/KzSXTXyF/Lwo3OMOCsciAM8LX6IxCAE86yJkfu0mGbFUfOpKJ5rdcGOaimedhIWXTl2oiGNYuCQey/1h5/c8L14gYUr5uCnr2r8oxKuJzCciZGqa1kMW72phwcNx02pdm9ocLwKxyC2k8J4VgSJOXo4uJCrdUdOn8ziHKsyQnKvm4MUsJKRnognNs14kLC4bSqL8HJB3d2IoQR02HiTceQyqA8DzBU8JE+8g9OfeVUPZGmLTz1F9PIurQ2FINtAUwjs3VnFQpUl800bCjJBVhMviPcpIiLoorcHvd6yHgX26tCRc9pzauIKcb3iFy3FfjTSSo+P5HBKWyAf1TfFhUHGlwty52WyV+wDI3u5DmPg+nvb7Mjyl882QcB0Ojhp8bVsN0zRVw8R9ut8qG5TOPCsvkYHZJq+qQdaNIWT4dR5hYt2Ma7wt59FzKZuy8Eoax4bwUlVXdTt/pvILN7QesgPL5fAfYV9ZsrfsNo/BQs/HWlhYYXX5VCVbKoSFYV1bPTzoFiyrPPE4pEUGzAkPlr3ykKXlqdKBtRRe+zKqUB0VXyOFt3uNKp8UZ5FOWCodLA6cBVwPNyloSGYIdX7S79/vys2H981nl8b2wwrHciIY41/vFofq4KpCX7379Y8//h+LYvIa"; \ No newline at end of file diff --git a/docs/api/player-sdk/assets/style.css b/docs/api/player-sdk/assets/style.css index 44328e9..ec257d5 100644 --- a/docs/api/player-sdk/assets/style.css +++ b/docs/api/player-sdk/assets/style.css @@ -504,8 +504,15 @@ body { background: var(--color-background); font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", - Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + "Noto Sans", + Helvetica, + Arial, + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji"; font-size: 16px; color: var(--color-text); margin: 0; @@ -531,6 +538,14 @@ scroll-margin-block: calc(var(--dim-header-height) + 0.5rem); } + math[display="block"] { + position: relative; + padding: 10px; + border: 1px solid var(--color-accent); + border-radius: 0.8em; + margin-bottom: 8px; + } + code, pre { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; @@ -1047,7 +1062,7 @@ cursor: pointer; } - .tsd-accordion-summary a { + .tsd-accordion-summary > a { width: calc(100% - 1.5rem); } .tsd-accordion-summary > * { diff --git a/docs/api/player-sdk/classes/ArchiveDecodeTimeoutError.html b/docs/api/player-sdk/classes/ArchiveDecodeTimeoutError.html new file mode 100644 index 0000000..e586122 --- /dev/null +++ b/docs/api/player-sdk/classes/ArchiveDecodeTimeoutError.html @@ -0,0 +1,190 @@ +ArchiveDecodeTimeoutError | @webblackbox/player-sdk API
+
@webblackbox/player-sdk API + +
    +
    +
    Preparing search index...
    +
    +
    +
    + +

    Class ArchiveDecodeTimeoutError

    +
    +

    Raised when a codec stream does not complete within the configured open budget.

    +
    +
    +

    Hierarchy

    +
      +
    • Error +
        +
      • ArchiveDecodeTimeoutError
    +
    +
    +
    +
    Index
    +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    +
    + +
    stackTraceLimit: number
    +

    The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

    +

    The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

    +

    If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

    +
    +
    + +
    name: "ArchiveDecodeTimeoutError"
    +
    + +
    timeoutMs: number
    +
    + +
    cause?: unknown
    +
    + +
    message: string
    +
    + +
    stack?: string
    +
    + +
    +
    + +
      +
    • + +
      +

      Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

      +
      const myObject = {};
      Error.captureStackTrace(myObject);
      myObject.stack; // Similar to `new Error().stack` +
      + +

      The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

      +

      The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

      +

      The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

      +
      function a() {
      b();
      }

      function b() {
      c();
      }

      function c() {
      // Create an error without stack trace to avoid calculating the stack trace twice.
      const { stackTraceLimit } = Error;
      Error.stackTraceLimit = 0;
      const error = new Error();
      Error.stackTraceLimit = stackTraceLimit;

      // Capture the stack trace above function b
      Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
      throw error;
      }

      a(); +
      + +
      +
      +

      Parameters

      +
        +
      • targetObject: object
      • +
      • OptionalconstructorOpt: Function
      +

      Returns void

    +
    + +
      +
    • + +
      +
      +

      Parameters

      +
        +
      • err: Error
      • +
      • stackTraces: CallSite[]
      +

      Returns any

      +
    +
    + +
    +
    diff --git a/docs/api/player-sdk/classes/ArchiveResourceLimitError.html b/docs/api/player-sdk/classes/ArchiveResourceLimitError.html new file mode 100644 index 0000000..cda48f5 --- /dev/null +++ b/docs/api/player-sdk/classes/ArchiveResourceLimitError.html @@ -0,0 +1,204 @@ +ArchiveResourceLimitError | @webblackbox/player-sdk API
    +
    @webblackbox/player-sdk API + +
      +
      +
      Preparing search index...
      +
      +
      +
      + +

      Class ArchiveResourceLimitError

      +
      +

      Raised when an archive would exceed a configured resource limit.

      +
      +
      +

      Hierarchy

      +
        +
      • Error +
          +
        • ArchiveResourceLimitError
      +
      +
      +
      +
      Index
      +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      +
      + +
      stackTraceLimit: number
      +

      The Error.stackTraceLimit property specifies the number of stack frames +collected by a stack trace (whether generated by new Error().stack or +Error.captureStackTrace(obj)).

      +

      The default value is 10 but may be set to any valid JavaScript number. Changes +will affect any stack trace captured after the value has been changed.

      +

      If set to a non-number value, or set to a negative number, stack traces will +not capture any frames.

      +
      +
      + +
      name: "ArchiveResourceLimitError"
      +
      + +
      resource: keyof ArchiveResourceLimits
      +
      + +
      limit: number
      +
      + +
      actual: number
      +
      + +
      cause?: unknown
      +
      + +
      message: string
      +
      + +
      stack?: string
      +
      + +
      +
      + +
        +
      • + +
        +

        Creates a .stack property on targetObject, which when accessed returns +a string representing the location in the code at which +Error.captureStackTrace() was called.

        +
        const myObject = {};
        Error.captureStackTrace(myObject);
        myObject.stack; // Similar to `new Error().stack` +
        + +

        The first line of the trace will be prefixed with +${myObject.name}: ${myObject.message}.

        +

        The optional constructorOpt argument accepts a function. If given, all frames +above constructorOpt, including constructorOpt, will be omitted from the +generated stack trace.

        +

        The constructorOpt argument is useful for hiding implementation +details of error generation from the user. For instance:

        +
        function a() {
        b();
        }

        function b() {
        c();
        }

        function c() {
        // Create an error without stack trace to avoid calculating the stack trace twice.
        const { stackTraceLimit } = Error;
        Error.stackTraceLimit = 0;
        const error = new Error();
        Error.stackTraceLimit = stackTraceLimit;

        // Capture the stack trace above function b
        Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace
        throw error;
        }

        a(); +
        + +
        +
        +

        Parameters

        +
          +
        • targetObject: object
        • +
        • OptionalconstructorOpt: Function
        +

        Returns void

      +
      + +
        +
      • + +
        +
        +

        Parameters

        +
          +
        • err: Error
        • +
        • stackTraces: CallSite[]
        +

        Returns any

        +
      +
      + +
      +
      diff --git a/docs/api/player-sdk/classes/BoundedZipReader.html b/docs/api/player-sdk/classes/BoundedZipReader.html new file mode 100644 index 0000000..dff8007 --- /dev/null +++ b/docs/api/player-sdk/classes/BoundedZipReader.html @@ -0,0 +1,118 @@ +BoundedZipReader | @webblackbox/player-sdk API
      +
      @webblackbox/player-sdk API + +
        +
        +
        Preparing search index...
        +
        +
        +
        + +

        Class BoundedZipReader

        +
        +

        Reads JSZip entries with limits based on actual inflater output instead of declared ZIP sizes.

        +
        +
        +
        +
        +
        Index
        +
        +
        + +
        +
        + +
        +
        + +
        +
        + +
        +
        + +
        +
        + +
        +
        + +
        +
        + +
        +
        + +
        +
        diff --git a/docs/api/player-sdk/classes/WebBlackboxPlayer.html b/docs/api/player-sdk/classes/WebBlackboxPlayer.html index 38b77bc..e38645c 100644 --- a/docs/api/player-sdk/classes/WebBlackboxPlayer.html +++ b/docs/api/player-sdk/classes/WebBlackboxPlayer.html @@ -1,15 +1,48 @@ -WebBlackboxPlayer | @webblackbox/player-sdk API
        @webblackbox/player-sdk API
          Preparing search index...

          Class WebBlackboxPlayer

          Main SDK entry for loading, querying, and exporting insights from .webblackbox archives.

          -
          Index

          Properties

          status +WebBlackboxPlayer | @webblackbox/player-sdk API
          +
          @webblackbox/player-sdk API + +
            +
            +
            Preparing search index...
            +
            +
            +
            + +

            Class WebBlackboxPlayer

            +
            +

            Main SDK entry for loading, querying, and exporting insights from .webblackbox archives.

            +
            +
            +
            +
            +
            Index
            +

            Properties

            status: PlayerStatus = "loaded"

            Current player status (always loaded for opened instances).

            -
            archive: PlayerArchive

            Parsed archive metadata and indexes.

            -

            Accessors

            • get events(): WebBlackboxEvent[]

              Returns all events in the current loaded range.

              -

              Returns WebBlackboxEvent[]

            Methods

            • Queries events using range/type/level/text/request filters.

              -

              Parameters

              Returns WebBlackboxEvent[]

            • Resolves a stored blob by hash or blob path alias.

              -

              Parameters

              • hash: string

              Returns Promise<{ mime: string; bytes: Uint8Array } | null>

            • Builds per-action timeline rows with related requests/errors/screenshots.

              -

              Parameters

              • options: {
                    range?: PlayerRange;
                    limit?: number;
                    screenshotLookaheadMs?: number;
                    requestLimit?: number;
                    errorLimit?: number;
                    derived?: PlayerDerivedView;
                } = {}

              Returns ActionTimelineEntry[]

            • Returns all events that reference a specific request id.

              -

              Parameters

              • reqId: string

              Returns WebBlackboxEvent[]

            • Compares two snapshots from this player by event id.

              -

              Parameters

              • previousEventId: string
              • currentEventId: string

              Returns Promise<DomDiffResult | null>

            • Generates a curl replay command for a recorded network request.

              -

              Parameters

              • reqId: string

              Returns string | null

            • Generates a fetch replay snippet for a recorded network request.

              -

              Parameters

              • reqId: string

              Returns string | null

            +
            +
            + +
            +
            + +
            status: PlayerStatus = "loaded"
            +

            Current player status (always loaded for opened instances).

            +
            +
            + +
            archive: PlayerArchive
            +

            Parsed archive metadata and indexes.

            +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
              +
            • + +
              +

              Builds per-action timeline rows with related requests/errors/screenshots.

              +
              +
              +

              Parameters

              +
                +
              • options: {
                    range?: PlayerRange;
                    limit?: number;
                    screenshotLookaheadMs?: number;
                    requestLimit?: number;
                    errorLimit?: number;
                    derived?: PlayerDerivedView;
                } = {}
              +

              Returns ActionTimelineEntry[]

            +
            + +
            +
            + +
            +
            + +
              +
            • + +
              +

              Returns all events that reference a specific request id.

              +
              +
              +

              Parameters

              +
                +
              • reqId: string
              +

              Returns WebBlackboxEvent[]

            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
              +
            • + +
              +

              Compares two snapshots from this player by event id.

              +
              +
              +

              Parameters

              +
                +
              • previousEventId: string
              • +
              • currentEventId: string
              +

              Returns Promise<DomDiffResult | null>

            +
            + +
            +
            + +
            +
            + +
            +
            + +
              +
            • + +
              +

              Generates a curl replay command for a recorded network request.

              +
              +
              +

              Parameters

              +
                +
              • reqId: string
              +

              Returns string | null

            +
            + +
              +
            • + +
              +

              Generates a fetch replay snippet for a recorded network request.

              +
              +
              +

              Parameters

              +
                +
              • reqId: string
              +

              Returns string | null

            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            +
            diff --git a/docs/api/player-sdk/functions/assertArchiveInputResourceLimits.html b/docs/api/player-sdk/functions/assertArchiveInputResourceLimits.html new file mode 100644 index 0000000..8327ec1 --- /dev/null +++ b/docs/api/player-sdk/functions/assertArchiveInputResourceLimits.html @@ -0,0 +1,44 @@ +assertArchiveInputResourceLimits | @webblackbox/player-sdk API
            +
            @webblackbox/player-sdk API + +
              +
              +
              Preparing search index...
              +
              +
              +
              + +

              Function assertArchiveInputResourceLimits

              +
              +
              +
              + +
              +
              diff --git a/docs/api/player-sdk/functions/assertLoadedArchiveResourceLimits.html b/docs/api/player-sdk/functions/assertLoadedArchiveResourceLimits.html new file mode 100644 index 0000000..f04325b --- /dev/null +++ b/docs/api/player-sdk/functions/assertLoadedArchiveResourceLimits.html @@ -0,0 +1,44 @@ +assertLoadedArchiveResourceLimits | @webblackbox/player-sdk API
              +
              @webblackbox/player-sdk API + +
                +
                +
                Preparing search index...
                +
                +
                +
                + +

                Function assertLoadedArchiveResourceLimits

                +
                +
                +
                + +
                +
                diff --git a/docs/api/player-sdk/functions/getDefaultPlayerStatus.html b/docs/api/player-sdk/functions/getDefaultPlayerStatus.html index ead3ee2..602a15e 100644 --- a/docs/api/player-sdk/functions/getDefaultPlayerStatus.html +++ b/docs/api/player-sdk/functions/getDefaultPlayerStatus.html @@ -1,2 +1,39 @@ -getDefaultPlayerStatus | @webblackbox/player-sdk API
                @webblackbox/player-sdk API
                  Preparing search index...

                  Function getDefaultPlayerStatus

                  +getDefaultPlayerStatus | @webblackbox/player-sdk API
                  +
                  @webblackbox/player-sdk API + +
                    +
                    +
                    Preparing search index...
                    +
                    +
                    +
                    + +

                    Function getDefaultPlayerStatus

                    +
                    +
                    +
                    + +
                    +
                    diff --git a/docs/api/player-sdk/functions/resolveArchiveResourceLimits.html b/docs/api/player-sdk/functions/resolveArchiveResourceLimits.html new file mode 100644 index 0000000..bcb1e74 --- /dev/null +++ b/docs/api/player-sdk/functions/resolveArchiveResourceLimits.html @@ -0,0 +1,43 @@ +resolveArchiveResourceLimits | @webblackbox/player-sdk API
                    +
                    @webblackbox/player-sdk API + +
                      +
                      +
                      Preparing search index...
                      +
                      +
                      +
                      + +

                      Function resolveArchiveResourceLimits

                      +
                      +
                      +
                      + +
                      +
                      diff --git a/docs/api/player-sdk/hierarchy.html b/docs/api/player-sdk/hierarchy.html index 74c7ad0..5b6f460 100644 --- a/docs/api/player-sdk/hierarchy.html +++ b/docs/api/player-sdk/hierarchy.html @@ -1 +1,28 @@ -@webblackbox/player-sdk API
                      @webblackbox/player-sdk API
                        Preparing search index...

                        @webblackbox/player-sdk API

                        Hierarchy Summary

                        +@webblackbox/player-sdk API
                        +
                        @webblackbox/player-sdk API + +
                          +
                          +
                          Preparing search index...
                          +
                          +
                          +
                          +

                          @webblackbox/player-sdk API

                          +

                          Hierarchy Summary

                          +
                          + +
                          +
                          diff --git a/docs/api/player-sdk/index.html b/docs/api/player-sdk/index.html index 580ed7e..54102fc 100644 --- a/docs/api/player-sdk/index.html +++ b/docs/api/player-sdk/index.html @@ -1,4 +1,26 @@ -@webblackbox/player-sdk API
                          @webblackbox/player-sdk API
                            Preparing search index...

                            @webblackbox/player-sdk API

                            @webblackbox/player-sdk

                            +@webblackbox/player-sdk API
                            +
                            @webblackbox/player-sdk API + +
                              +
                              +
                              Preparing search index...
                              +
                              +
                              +
                              +

                              @webblackbox/player-sdk API

                              +

                              + WebBlackbox +

                              +

                              @webblackbox/player-sdk

                              +

                              + Session playback, querying, analysis, and code generation SDK. +

                              +

                              + npm version + License + WebBlackbox +

                              +

                              The session playback and analysis SDK for WebBlackbox. Opens .webblackbox archives and provides rich querying, analysis, and code generation capabilities.

                                @@ -18,9 +40,15 @@ -
                                import { WebBlackboxPlayer } from "@webblackbox/player-sdk";

                                // From ArrayBuffer, Uint8Array, or Blob
                                const player = await WebBlackboxPlayer.open(archiveBytes);

                                // With encryption passphrase
                                const player = await WebBlackboxPlayer.open(archiveBytes, {
                                passphrase: "my-secret"
                                });

                                // Preload only a monotonic time window (loads intersecting chunks only)
                                const scopedPlayer = await WebBlackboxPlayer.open(archiveBytes, {
                                range: { monoStart: 12000, monoEnd: 45000 }
                                });

                                console.log(player.status); // "loaded"
                                console.log(player.archive.manifest); // ExportManifest
                                console.log(player.events.length); // Total event count +
                                import { WebBlackboxPlayer } from "@webblackbox/player-sdk";

                                // From ArrayBuffer, Uint8Array, or Blob
                                const player = await WebBlackboxPlayer.open(archiveBytes);

                                // With encryption passphrase
                                const player = await WebBlackboxPlayer.open(archiveBytes, {
                                passphrase: "my-secret"
                                });

                                // Preload only a monotonic time window (loads intersecting chunks only)
                                const scopedPlayer = await WebBlackboxPlayer.open(archiveBytes, {
                                range: { monoStart: 12000, monoEnd: 45000 }
                                });

                                // Resource limits have safe ceilings and may only be tightened per open.
                                const constrainedPlayer = await WebBlackboxPlayer.open(archiveBytes, {
                                resourceLimits: {
                                maxInputBytes: 32 * 1024 * 1024,
                                maxEntryCount: 2_000,
                                maxEventCount: 250_000
                                }
                                });

                                console.log(player.status); // "loaded"
                                console.log(player.archive.manifest); // ExportManifest
                                console.log(player.events.length); // Total event count
                                +

                                The built-in ceilings are 256 MiB input, 10,000 physical ZIP entries, 128 MiB per expanded +entry, 256 MiB total ZIP expansion, 8 MiB per/24 MiB total JSON metadata, 10,000x compression +ratio, 1,000,000 events, 500,000 index records, 2,000,000 index event references, 32 MiB per +decoded event chunk, 64 MiB total decoded event bytes, and 5 seconds per ZIP/codec stream. +Actual inflater output is counted; declared ZIP sizes are only an early-rejection hint. Values +supplied through resourceLimits may only be lower than these ceilings.

                                // Get all events
                                const allEvents = player.query();

                                // Filter by type
                                const networkEvents = player.query({
                                types: ["network.request", "network.response"]
                                });

                                // Filter by level
                                const errors = player.query({
                                levels: ["error"]
                                });

                                // Filter by time range (monotonic timestamps)
                                const firstMinute = player.query({
                                range: { monoStart: 0, monoEnd: 60000 }
                                });

                                // Text search within events
                                const matches = player.query({
                                text: "TypeError"
                                });

                                // Filter by request ID
                                const requestEvents = player.query({
                                requestId: "R-12345"
                                });

                                // Combine filters with pagination
                                const page = player.query({
                                types: ["error.exception"],
                                levels: ["error"],
                                range: { monoStart: 0, monoEnd: 120000 },
                                limit: 50,
                                offset: 0
                                });
                                @@ -88,7 +116,48 @@
                                -
                                type PlayerStatus = "idle" | "loaded";

                                type PlayerOpenInput = ArrayBuffer | Uint8Array | Blob;

                                type PlayerOpenOptions = {
                                passphrase?: string;
                                range?: PlayerRange;
                                };

                                type PlayerQuery = {
                                range?: PlayerRange;
                                types?: WebBlackboxEventType[];
                                levels?: EventLevel[];
                                text?: string;
                                requestId?: string;
                                limit?: number;
                                offset?: number;
                                };

                                type PlayerRange = {
                                monoStart?: number;
                                monoEnd?: number;
                                };

                                type PlayerSearchResult = {
                                eventId: string;
                                score: number;
                                event: WebBlackboxEvent;
                                };

                                type PlayerArchive = {
                                manifest: ExportManifest;
                                timeIndex: ChunkTimeIndexEntry[];
                                requestIndex: RequestIndexEntry[];
                                invertedIndex: InvertedIndexEntry[];
                                integrity: HashesManifest;
                                }; -
                                - -
                              +
                              type PlayerStatus = "idle" | "loaded";

                              type PlayerOpenInput = ArrayBuffer | Uint8Array | Blob;

                              type PlayerOpenOptions = {
                              passphrase?: string;
                              range?: PlayerRange;
                              resourceLimits?: Partial<ArchiveResourceLimits>;
                              };

                              type ArchiveResourceLimits = {
                              maxInputBytes: number;
                              maxEntryCount: number;
                              maxEntryUncompressedBytes: number;
                              maxTotalUncompressedBytes: number;
                              maxMetadataEntryBytes: number;
                              maxTotalMetadataBytes: number;
                              maxCompressionRatio: number;
                              maxEventCount: number;
                              maxIndexRecords: number;
                              maxIndexEventReferences: number;
                              maxChunkDecodedBytes: number;
                              maxTotalDecodedBytes: number;
                              decodeTimeoutMs: number;
                              };

                              type PlayerQuery = {
                              range?: PlayerRange;
                              types?: WebBlackboxEventType[];
                              levels?: EventLevel[];
                              text?: string;
                              requestId?: string;
                              limit?: number;
                              offset?: number;
                              };

                              type PlayerRange = {
                              monoStart?: number;
                              monoEnd?: number;
                              };

                              type PlayerSearchResult = {
                              eventId: string;
                              score: number;
                              event: WebBlackboxEvent;
                              };

                              type PlayerArchive = {
                              manifest: ExportManifest;
                              timeIndex: ChunkTimeIndexEntry[];
                              requestIndex: RequestIndexEntry[];
                              invertedIndex: InvertedIndexEntry[];
                              integrity: HashesManifest;
                              }; +
                              + + +

                              MIT

                              +
                              +
                              +
                              diff --git a/docs/api/player-sdk/modules.html b/docs/api/player-sdk/modules.html index 0798aa8..88e47af 100644 --- a/docs/api/player-sdk/modules.html +++ b/docs/api/player-sdk/modules.html @@ -1 +1,47 @@ -@webblackbox/player-sdk API
                              @webblackbox/player-sdk API
                                Preparing search index...
                                +@webblackbox/player-sdk API
                                +
                                @webblackbox/player-sdk API + +
                                  +
                                  +
                                  Preparing search index...
                                  +
                                  + +
                                  +
                                  diff --git a/docs/api/player-sdk/types/ActionSpan.html b/docs/api/player-sdk/types/ActionSpan.html index dfa3017..0d27698 100644 --- a/docs/api/player-sdk/types/ActionSpan.html +++ b/docs/api/player-sdk/types/ActionSpan.html @@ -1,9 +1,93 @@ -ActionSpan | @webblackbox/player-sdk API
                                  @webblackbox/player-sdk API
                                    Preparing search index...

                                    Type Alias ActionSpan

                                    Aggregated user action span.

                                    -
                                    type ActionSpan = {
                                        actId: string;
                                        startMono: number;
                                        endMono: number;
                                        eventIds: string[];
                                        triggerEventId: string;
                                        requestCount: number;
                                        errorCount: number;
                                    }
                                    Index

                                    Properties

                                    actId +ActionSpan | @webblackbox/player-sdk API
                                    +
                                    @webblackbox/player-sdk API + +
                                      +
                                      +
                                      Preparing search index...
                                      +
                                      +
                                      +
                                      + +

                                      Type Alias ActionSpan

                                      +
                                      +

                                      Aggregated user action span.

                                      +
                                      +
                                      type ActionSpan = {
                                          actId: string;
                                          startMono: number;
                                          endMono: number;
                                          eventIds: string[];
                                          triggerEventId: string;
                                          requestCount: number;
                                          errorCount: number;
                                      }
                                      +
                                      +
                                      +
                                      +
                                      Index
                                      +

                                      Properties

                                      actId: string
                                      startMono: number
                                      endMono: number
                                      eventIds: string[]
                                      triggerEventId: string
                                      requestCount: number
                                      errorCount: number
                                      +
                                      +
                                      + +
                                      +
                                      + +
                                      actId: string
                                      +
                                      + +
                                      startMono: number
                                      +
                                      + +
                                      endMono: number
                                      +
                                      + +
                                      eventIds: string[]
                                      +
                                      + +
                                      triggerEventId: string
                                      +
                                      + +
                                      requestCount: number
                                      +
                                      + +
                                      errorCount: number
                                      +
                                      + +
                                      +
                                      diff --git a/docs/api/player-sdk/types/ActionTimelineEntry.html b/docs/api/player-sdk/types/ActionTimelineEntry.html index c89ea77..114f43d 100644 --- a/docs/api/player-sdk/types/ActionTimelineEntry.html +++ b/docs/api/player-sdk/types/ActionTimelineEntry.html @@ -1,5 +1,29 @@ -ActionTimelineEntry | @webblackbox/player-sdk API
                                      @webblackbox/player-sdk API
                                        Preparing search index...

                                        Type Alias ActionTimelineEntry

                                        Action timeline row with network/error/screenshot context.

                                        -
                                        type ActionTimelineEntry = {
                                            actId: string;
                                            triggerEventId: string;
                                            triggerType: string | null;
                                            startMono: number;
                                            endMono: number;
                                            durationMs: number;
                                            eventCount: number;
                                            requestCount: number;
                                            errorCount: number;
                                            requests: {
                                                reqId: string;
                                                method: string;
                                                url: string;
                                                status: number | null;
                                                failed: boolean;
                                                durationMs: number;
                                            }[];
                                            errors: {
                                                eventId: string;
                                                type: string;
                                                mono: number;
                                                message: string
                                                | null;
                                            }[];
                                            screenshot: | {
                                                eventId: string;
                                                mono: number;
                                                shotId: string
                                                | null;
                                                reason: string | null;
                                                format: string | null;
                                                size: number | null;
                                            }
                                            | null;
                                        }
                                        Index

                                        Properties

                                        actId +ActionTimelineEntry | @webblackbox/player-sdk API
                                        +
                                        @webblackbox/player-sdk API + +
                                          +
                                          +
                                          Preparing search index...
                                          +
                                          +
                                          +
                                          + +

                                          Type Alias ActionTimelineEntry

                                          +
                                          +

                                          Action timeline row with network/error/screenshot context.

                                          +
                                          +
                                          type ActionTimelineEntry = {
                                              actId: string;
                                              triggerEventId: string;
                                              triggerType: string | null;
                                              startMono: number;
                                              endMono: number;
                                              durationMs: number;
                                              eventCount: number;
                                              requestCount: number;
                                              errorCount: number;
                                              requests: {
                                                  reqId: string;
                                                  method: string;
                                                  url: string;
                                                  status: number | null;
                                                  failed: boolean;
                                                  durationMs: number;
                                              }[];
                                              errors: {
                                                  eventId: string;
                                                  type: string;
                                                  mono: number;
                                                  message: string
                                                  | null;
                                              }[];
                                              screenshot: | {
                                                  eventId: string;
                                                  mono: number;
                                                  shotId: string
                                                  | null;
                                                  reason: string | null;
                                                  format: string | null;
                                                  size: number | null;
                                              }
                                              | null;
                                          }
                                          +
                                          +
                                          +
                                          +
                                          Index
                                          +
                                          +
                                          + +

                                          Properties

                                          actId: string
                                          triggerEventId: string
                                          triggerType: string | null
                                          startMono: number
                                          endMono: number
                                          durationMs: number
                                          eventCount: number
                                          requestCount: number
                                          errorCount: number
                                          requests: {
                                              reqId: string;
                                              method: string;
                                              url: string;
                                              status: number | null;
                                              failed: boolean;
                                              durationMs: number;
                                          }[]
                                          errors: { eventId: string; type: string; mono: number; message: string | null }[]
                                          screenshot:
                                              | {
                                                  eventId: string;
                                                  mono: number;
                                                  shotId: string
                                                  | null;
                                                  reason: string | null;
                                                  format: string | null;
                                                  size: number | null;
                                              }
                                              | null
                                          +
                                          +
                                          + +
                                          +
                                          + +
                                          actId: string
                                          +
                                          + +
                                          triggerEventId: string
                                          +
                                          + +
                                          triggerType: string | null
                                          +
                                          + +
                                          startMono: number
                                          +
                                          + +
                                          endMono: number
                                          +
                                          + +
                                          durationMs: number
                                          +
                                          + +
                                          eventCount: number
                                          +
                                          + +
                                          requestCount: number
                                          +
                                          + +
                                          errorCount: number
                                          +
                                          + +
                                          requests: {
                                              reqId: string;
                                              method: string;
                                              url: string;
                                              status: number | null;
                                              failed: boolean;
                                              durationMs: number;
                                          }[]
                                          +
                                          + +
                                          errors: { eventId: string; type: string; mono: number; message: string | null }[]
                                          +
                                          + +
                                          screenshot:
                                              | {
                                                  eventId: string;
                                                  mono: number;
                                                  shotId: string
                                                  | null;
                                                  reason: string | null;
                                                  format: string | null;
                                                  size: number | null;
                                              }
                                              | null
                                          +
                                          + +
                                          +
                                          diff --git a/docs/api/player-sdk/types/ArchiveResourceLimits.html b/docs/api/player-sdk/types/ArchiveResourceLimits.html new file mode 100644 index 0000000..0baa5d9 --- /dev/null +++ b/docs/api/player-sdk/types/ArchiveResourceLimits.html @@ -0,0 +1,129 @@ +ArchiveResourceLimits | @webblackbox/player-sdk API
                                          +
                                          @webblackbox/player-sdk API + +
                                            +
                                            +
                                            Preparing search index...
                                            +
                                            +
                                            +
                                            + +

                                            Type Alias ArchiveResourceLimits

                                            +
                                            +

                                            Resource limits applied before and while an archive is opened. Overrides may only tighten them.

                                            +
                                            +
                                            type ArchiveResourceLimits = {
                                                maxInputBytes: number;
                                                maxEntryCount: number;
                                                maxEntryUncompressedBytes: number;
                                                maxTotalUncompressedBytes: number;
                                                maxMetadataEntryBytes: number;
                                                maxTotalMetadataBytes: number;
                                                maxCompressionRatio: number;
                                                maxEventCount: number;
                                                maxIndexRecords: number;
                                                maxIndexEventReferences: number;
                                                maxChunkDecodedBytes: number;
                                                maxTotalDecodedBytes: number;
                                                decodeTimeoutMs: number;
                                            }
                                            +
                                            +
                                            +
                                            +
                                            Index
                                            +
                                            +
                                            + +
                                            +
                                            + +
                                            maxInputBytes: number
                                            +
                                            + +
                                            maxEntryCount: number
                                            +
                                            + +
                                            maxEntryUncompressedBytes: number
                                            +
                                            + +
                                            maxTotalUncompressedBytes: number
                                            +
                                            + +
                                            maxMetadataEntryBytes: number
                                            +
                                            + +
                                            maxTotalMetadataBytes: number
                                            +
                                            + +
                                            maxCompressionRatio: number
                                            +
                                            + +
                                            maxEventCount: number
                                            +
                                            + +
                                            maxIndexRecords: number
                                            +
                                            + +
                                            maxIndexEventReferences: number
                                            +
                                            + +
                                            maxChunkDecodedBytes: number
                                            +
                                            + +
                                            maxTotalDecodedBytes: number
                                            +
                                            + +
                                            decodeTimeoutMs: number
                                            +
                                            +
                                            diff --git a/docs/api/player-sdk/types/BugReportOptions.html b/docs/api/player-sdk/types/BugReportOptions.html index b286254..a2581fe 100644 --- a/docs/api/player-sdk/types/BugReportOptions.html +++ b/docs/api/player-sdk/types/BugReportOptions.html @@ -1,5 +1,69 @@ -BugReportOptions | @webblackbox/player-sdk API
                                            @webblackbox/player-sdk API
                                              Preparing search index...

                                              Type Alias BugReportOptions

                                              Bug report generation options.

                                              -
                                              type BugReportOptions = {
                                                  title?: string;
                                                  range?: PlayerRange;
                                                  maxItems?: number;
                                              }
                                              Index

                                              Properties

                                              title? +BugReportOptions | @webblackbox/player-sdk API
                                              +
                                              @webblackbox/player-sdk API + +
                                                +
                                                +
                                                Preparing search index...
                                                +
                                                +
                                                +
                                                + +

                                                Type Alias BugReportOptions

                                                +
                                                +

                                                Bug report generation options.

                                                +
                                                +
                                                type BugReportOptions = {
                                                    title?: string;
                                                    range?: PlayerRange;
                                                    maxItems?: number;
                                                }
                                                +
                                                +
                                                +
                                                +
                                                Index
                                                +
                                                +
                                                + +

                                                Properties

                                                title?: string
                                                range?: PlayerRange
                                                maxItems?: number
                                                +
                                                +
                                                + +
                                                +
                                                + +
                                                title?: string
                                                +
                                                + +
                                                range?: PlayerRange
                                                +
                                                + +
                                                maxItems?: number
                                                +
                                                + +
                                                +
                                                diff --git a/docs/api/player-sdk/types/DomDiffResult.html b/docs/api/player-sdk/types/DomDiffResult.html index 0ff7d0b..580c17b 100644 --- a/docs/api/player-sdk/types/DomDiffResult.html +++ b/docs/api/player-sdk/types/DomDiffResult.html @@ -1,8 +1,87 @@ -DomDiffResult | @webblackbox/player-sdk API
                                                @webblackbox/player-sdk API
                                                  Preparing search index...

                                                  Type Alias DomDiffResult

                                                  Result of diffing two DOM snapshots.

                                                  -
                                                  type DomDiffResult = {
                                                      previous: DomSnapshotRef;
                                                      current: DomSnapshotRef;
                                                      addedPaths: string[];
                                                      removedPaths: string[];
                                                      changedPaths: string[];
                                                      summary: { added: number; removed: number; changed: number };
                                                  }
                                                  Index

                                                  Properties

                                                  previous +DomDiffResult | @webblackbox/player-sdk API
                                                  +
                                                  @webblackbox/player-sdk API + +
                                                    +
                                                    +
                                                    Preparing search index...
                                                    +
                                                    +
                                                    +
                                                    + +

                                                    Type Alias DomDiffResult

                                                    +
                                                    +

                                                    Result of diffing two DOM snapshots.

                                                    +
                                                    +
                                                    type DomDiffResult = {
                                                        previous: DomSnapshotRef;
                                                        current: DomSnapshotRef;
                                                        addedPaths: string[];
                                                        removedPaths: string[];
                                                        changedPaths: string[];
                                                        summary: { added: number; removed: number; changed: number };
                                                    }
                                                    +
                                                    +
                                                    +
                                                    +
                                                    Index
                                                    +

                                                    Properties

                                                    previous: DomSnapshotRef
                                                    addedPaths: string[]
                                                    removedPaths: string[]
                                                    changedPaths: string[]
                                                    summary: { added: number; removed: number; changed: number }
                                                    +
                                                    +
                                                    + +
                                                    +
                                                    + +
                                                    previous: DomSnapshotRef
                                                    +
                                                    + +
                                                    +
                                                    + +
                                                    addedPaths: string[]
                                                    +
                                                    + +
                                                    removedPaths: string[]
                                                    +
                                                    + +
                                                    changedPaths: string[]
                                                    +
                                                    + +
                                                    summary: { added: number; removed: number; changed: number }
                                                    +
                                                    + +
                                                    +
                                                    diff --git a/docs/api/player-sdk/types/DomDiffTimelineOptions.html b/docs/api/player-sdk/types/DomDiffTimelineOptions.html index 7d82f4d..2dd2d28 100644 --- a/docs/api/player-sdk/types/DomDiffTimelineOptions.html +++ b/docs/api/player-sdk/types/DomDiffTimelineOptions.html @@ -1,4 +1,63 @@ -DomDiffTimelineOptions | @webblackbox/player-sdk API
                                                    @webblackbox/player-sdk API
                                                      Preparing search index...

                                                      Type Alias DomDiffTimelineOptions

                                                      DOM diff timeline query options.

                                                      -
                                                      type DomDiffTimelineOptions = {
                                                          range?: PlayerRange;
                                                          limit?: number;
                                                      }
                                                      Index

                                                      Properties

                                                      range? +DomDiffTimelineOptions | @webblackbox/player-sdk API
                                                      +
                                                      @webblackbox/player-sdk API + +
                                                        +
                                                        +
                                                        Preparing search index...
                                                        +
                                                        +
                                                        +
                                                        + +

                                                        Type Alias DomDiffTimelineOptions

                                                        +
                                                        +

                                                        DOM diff timeline query options.

                                                        +
                                                        +
                                                        type DomDiffTimelineOptions = {
                                                            range?: PlayerRange;
                                                            limit?: number;
                                                        }
                                                        +
                                                        +
                                                        +
                                                        +
                                                        Index
                                                        +
                                                        +
                                                        + +

                                                        Properties

                                                        range?: PlayerRange
                                                        limit?: number
                                                        +
                                                        +
                                                        + +
                                                        +
                                                        + +
                                                        range?: PlayerRange
                                                        +
                                                        + +
                                                        limit?: number
                                                        +
                                                        + +
                                                        +
                                                        diff --git a/docs/api/player-sdk/types/DomSnapshotRef.html b/docs/api/player-sdk/types/DomSnapshotRef.html index bd06b05..b786217 100644 --- a/docs/api/player-sdk/types/DomSnapshotRef.html +++ b/docs/api/player-sdk/types/DomSnapshotRef.html @@ -1,5 +1,29 @@ -DomSnapshotRef | @webblackbox/player-sdk API
                                                        @webblackbox/player-sdk API
                                                          Preparing search index...

                                                          Type Alias DomSnapshotRef

                                                          DOM snapshot reference entry from the timeline.

                                                          -
                                                          type DomSnapshotRef = {
                                                              eventId: string;
                                                              mono: number;
                                                              t: number;
                                                              snapshotId?: string;
                                                              contentHash?: string;
                                                              source?: string;
                                                              nodeCount?: number;
                                                              reason?: string;
                                                          }
                                                          Index

                                                          Properties

                                                          eventId +DomSnapshotRef | @webblackbox/player-sdk API
                                                          +
                                                          @webblackbox/player-sdk API + +
                                                            +
                                                            +
                                                            Preparing search index...
                                                            +
                                                            +
                                                            +
                                                            + +

                                                            Type Alias DomSnapshotRef

                                                            +
                                                            +

                                                            DOM snapshot reference entry from the timeline.

                                                            +
                                                            +
                                                            type DomSnapshotRef = {
                                                                eventId: string;
                                                                mono: number;
                                                                t: number;
                                                                snapshotId?: string;
                                                                contentHash?: string;
                                                                source?: string;
                                                                nodeCount?: number;
                                                                reason?: string;
                                                            }
                                                            +
                                                            +
                                                            +
                                                            +
                                                            Index
                                                            +
                                                            +
                                                            + +

                                                            Properties

                                                            eventId: string
                                                            mono: number
                                                            t: number
                                                            snapshotId?: string
                                                            contentHash?: string
                                                            source?: string
                                                            nodeCount?: number
                                                            reason?: string
                                                            +
                                                            +
                                                            + +
                                                            +
                                                            + +
                                                            eventId: string
                                                            +
                                                            + +
                                                            mono: number
                                                            +
                                                            + +
                                                            t: number
                                                            +
                                                            + +
                                                            snapshotId?: string
                                                            +
                                                            + +
                                                            contentHash?: string
                                                            +
                                                            + +
                                                            source?: string
                                                            +
                                                            + +
                                                            nodeCount?: number
                                                            +
                                                            + +
                                                            reason?: string
                                                            +
                                                            + +
                                                            +
                                                            diff --git a/docs/api/player-sdk/types/GitHubIssueTemplate.html b/docs/api/player-sdk/types/GitHubIssueTemplate.html index d6f124a..145265c 100644 --- a/docs/api/player-sdk/types/GitHubIssueTemplate.html +++ b/docs/api/player-sdk/types/GitHubIssueTemplate.html @@ -1,6 +1,75 @@ -GitHubIssueTemplate | @webblackbox/player-sdk API
                                                            @webblackbox/player-sdk API
                                                              Preparing search index...

                                                              Type Alias GitHubIssueTemplate

                                                              GitHub issue payload generated from a session.

                                                              -
                                                              type GitHubIssueTemplate = {
                                                                  title: string;
                                                                  body: string;
                                                                  labels: string[];
                                                                  assignees: string[];
                                                              }
                                                              Index

                                                              Properties

                                                              title +GitHubIssueTemplate | @webblackbox/player-sdk API
                                                              +
                                                              @webblackbox/player-sdk API + +
                                                                +
                                                                +
                                                                Preparing search index...
                                                                +
                                                                +
                                                                +
                                                                + +

                                                                Type Alias GitHubIssueTemplate

                                                                +
                                                                +

                                                                GitHub issue payload generated from a session.

                                                                +
                                                                +
                                                                type GitHubIssueTemplate = {
                                                                    title: string;
                                                                    body: string;
                                                                    labels: string[];
                                                                    assignees: string[];
                                                                }
                                                                +
                                                                +
                                                                +
                                                                +
                                                                Index
                                                                +
                                                                +
                                                                + +

                                                                Properties

                                                                title: string
                                                                body: string
                                                                labels: string[]
                                                                assignees: string[]
                                                                +
                                                                +
                                                                + +
                                                                +
                                                                + +
                                                                title: string
                                                                +
                                                                + +
                                                                body: string
                                                                +
                                                                + +
                                                                labels: string[]
                                                                +
                                                                + +
                                                                assignees: string[]
                                                                +
                                                                + +
                                                                +
                                                                diff --git a/docs/api/player-sdk/types/JiraIssueTemplate.html b/docs/api/player-sdk/types/JiraIssueTemplate.html index 22fbfd2..cfd6809 100644 --- a/docs/api/player-sdk/types/JiraIssueTemplate.html +++ b/docs/api/player-sdk/types/JiraIssueTemplate.html @@ -1,3 +1,57 @@ -JiraIssueTemplate | @webblackbox/player-sdk API
                                                                @webblackbox/player-sdk API
                                                                  Preparing search index...

                                                                  Type Alias JiraIssueTemplate

                                                                  Jira issue payload generated from a session.

                                                                  -
                                                                  type JiraIssueTemplate = {
                                                                      fields: {
                                                                          summary: string;
                                                                          description: string;
                                                                          issuetype: { name: string };
                                                                          labels: string[];
                                                                          project?: { key: string };
                                                                          priority?: { name: string };
                                                                      };
                                                                  }
                                                                  Index

                                                                  Properties

                                                                  Properties

                                                                  fields: {
                                                                      summary: string;
                                                                      description: string;
                                                                      issuetype: { name: string };
                                                                      labels: string[];
                                                                      project?: { key: string };
                                                                      priority?: { name: string };
                                                                  }
                                                                  +JiraIssueTemplate | @webblackbox/player-sdk API
                                                                  +
                                                                  @webblackbox/player-sdk API + +
                                                                    +
                                                                    +
                                                                    Preparing search index...
                                                                    +
                                                                    +
                                                                    +
                                                                    + +

                                                                    Type Alias JiraIssueTemplate

                                                                    +
                                                                    +

                                                                    Jira issue payload generated from a session.

                                                                    +
                                                                    +
                                                                    type JiraIssueTemplate = {
                                                                        fields: {
                                                                            summary: string;
                                                                            description: string;
                                                                            issuetype: { name: string };
                                                                            labels: string[];
                                                                            project?: { key: string };
                                                                            priority?: { name: string };
                                                                        };
                                                                    }
                                                                    +
                                                                    +
                                                                    +
                                                                    +
                                                                    Index
                                                                    +
                                                                    +
                                                                    + +
                                                                    +
                                                                    + +
                                                                    +
                                                                    + +
                                                                    fields: {
                                                                        summary: string;
                                                                        description: string;
                                                                        issuetype: { name: string };
                                                                        labels: string[];
                                                                        project?: { key: string };
                                                                        priority?: { name: string };
                                                                    }
                                                                    +
                                                                    + +
                                                                    +
                                                                    diff --git a/docs/api/player-sdk/types/NetworkWaterfallEntry.html b/docs/api/player-sdk/types/NetworkWaterfallEntry.html index d59eda2..28e09c3 100644 --- a/docs/api/player-sdk/types/NetworkWaterfallEntry.html +++ b/docs/api/player-sdk/types/NetworkWaterfallEntry.html @@ -1,5 +1,29 @@ -NetworkWaterfallEntry | @webblackbox/player-sdk API
                                                                    @webblackbox/player-sdk API
                                                                      Preparing search index...

                                                                      Type Alias NetworkWaterfallEntry

                                                                      Normalized request waterfall entry.

                                                                      -
                                                                      type NetworkWaterfallEntry = {
                                                                          reqId: string;
                                                                          url: string;
                                                                          method: string;
                                                                          status?: number;
                                                                          statusText?: string;
                                                                          mimeType?: string;
                                                                          startMono: number;
                                                                          endMono: number;
                                                                          durationMs: number;
                                                                          startWallTime: number;
                                                                          endWallTime: number;
                                                                          failed: boolean;
                                                                          errorText?: string;
                                                                          actionId?: string;
                                                                          encodedDataLength?: number;
                                                                          requestHeaders: Record<string, string>;
                                                                          responseHeaders: Record<string, string>;
                                                                          requestBodyText?: string;
                                                                          responseBodyHash?: string;
                                                                          responseBodySize?: number;
                                                                          eventIds: string[];
                                                                      }
                                                                      Index

                                                                      Properties

                                                                      reqId +NetworkWaterfallEntry | @webblackbox/player-sdk API
                                                                      +
                                                                      @webblackbox/player-sdk API + +
                                                                        +
                                                                        +
                                                                        Preparing search index...
                                                                        +
                                                                        +
                                                                        +
                                                                        + +

                                                                        Type Alias NetworkWaterfallEntry

                                                                        +
                                                                        +

                                                                        Normalized request waterfall entry.

                                                                        +
                                                                        +
                                                                        type NetworkWaterfallEntry = {
                                                                            reqId: string;
                                                                            url: string;
                                                                            method: string;
                                                                            status?: number;
                                                                            statusText?: string;
                                                                            mimeType?: string;
                                                                            startMono: number;
                                                                            endMono: number;
                                                                            durationMs: number;
                                                                            startWallTime: number;
                                                                            endWallTime: number;
                                                                            failed: boolean;
                                                                            errorText?: string;
                                                                            actionId?: string;
                                                                            encodedDataLength?: number;
                                                                            requestHeaders: Record<string, string>;
                                                                            responseHeaders: Record<string, string>;
                                                                            requestBodyText?: string;
                                                                            responseBodyHash?: string;
                                                                            responseBodySize?: number;
                                                                            eventIds: string[];
                                                                        }
                                                                        +
                                                                        +
                                                                        +
                                                                        +
                                                                        Index
                                                                        +
                                                                        +
                                                                        + +

                                                                        Properties

                                                                        reqId: string
                                                                        url: string
                                                                        method: string
                                                                        status?: number
                                                                        statusText?: string
                                                                        mimeType?: string
                                                                        startMono: number
                                                                        endMono: number
                                                                        durationMs: number
                                                                        startWallTime: number
                                                                        endWallTime: number
                                                                        failed: boolean
                                                                        errorText?: string
                                                                        actionId?: string
                                                                        encodedDataLength?: number
                                                                        requestHeaders: Record<string, string>
                                                                        responseHeaders: Record<string, string>
                                                                        requestBodyText?: string
                                                                        responseBodyHash?: string
                                                                        responseBodySize?: number
                                                                        eventIds: string[]
                                                                        +
                                                                        +
                                                                        + +
                                                                        +
                                                                        + +
                                                                        reqId: string
                                                                        +
                                                                        + +
                                                                        url: string
                                                                        +
                                                                        + +
                                                                        method: string
                                                                        +
                                                                        + +
                                                                        status?: number
                                                                        +
                                                                        + +
                                                                        statusText?: string
                                                                        +
                                                                        + +
                                                                        mimeType?: string
                                                                        +
                                                                        + +
                                                                        startMono: number
                                                                        +
                                                                        + +
                                                                        endMono: number
                                                                        +
                                                                        + +
                                                                        durationMs: number
                                                                        +
                                                                        + +
                                                                        startWallTime: number
                                                                        +
                                                                        + +
                                                                        endWallTime: number
                                                                        +
                                                                        + +
                                                                        failed: boolean
                                                                        +
                                                                        + +
                                                                        errorText?: string
                                                                        +
                                                                        + +
                                                                        actionId?: string
                                                                        +
                                                                        + +
                                                                        encodedDataLength?: number
                                                                        +
                                                                        + +
                                                                        requestHeaders: Record<string, string>
                                                                        +
                                                                        + +
                                                                        responseHeaders: Record<string, string>
                                                                        +
                                                                        + +
                                                                        requestBodyText?: string
                                                                        +
                                                                        + +
                                                                        responseBodyHash?: string
                                                                        +
                                                                        + +
                                                                        responseBodySize?: number
                                                                        +
                                                                        + +
                                                                        eventIds: string[]
                                                                        +
                                                                        +
                                                                        diff --git a/docs/api/player-sdk/types/PerformanceArtifactEntry.html b/docs/api/player-sdk/types/PerformanceArtifactEntry.html index 0dcd208..a5accd9 100644 --- a/docs/api/player-sdk/types/PerformanceArtifactEntry.html +++ b/docs/api/player-sdk/types/PerformanceArtifactEntry.html @@ -1,5 +1,29 @@ -PerformanceArtifactEntry | @webblackbox/player-sdk API
                                                                        @webblackbox/player-sdk API
                                                                          Preparing search index...

                                                                          Type Alias PerformanceArtifactEntry

                                                                          Performance artifact timeline entry.

                                                                          -
                                                                          type PerformanceArtifactEntry = {
                                                                              eventId: string;
                                                                              eventType: WebBlackboxEventType;
                                                                              t: number;
                                                                              mono: number;
                                                                              kind: "trace" | "cpu" | "heap" | "longtask" | "vitals" | "other";
                                                                              hash?: string;
                                                                              size?: number;
                                                                              reason?: string;
                                                                              snapshot?: unknown;
                                                                          }
                                                                          Index

                                                                          Properties

                                                                          eventId +PerformanceArtifactEntry | @webblackbox/player-sdk API
                                                                          +
                                                                          @webblackbox/player-sdk API + +
                                                                            +
                                                                            +
                                                                            Preparing search index...
                                                                            +
                                                                            +
                                                                            +
                                                                            + +

                                                                            Type Alias PerformanceArtifactEntry

                                                                            +
                                                                            +

                                                                            Performance artifact timeline entry.

                                                                            +
                                                                            +
                                                                            type PerformanceArtifactEntry = {
                                                                                eventId: string;
                                                                                eventType: WebBlackboxEventType;
                                                                                t: number;
                                                                                mono: number;
                                                                                kind: "trace" | "cpu" | "heap" | "longtask" | "vitals" | "other";
                                                                                hash?: string;
                                                                                size?: number;
                                                                                reason?: string;
                                                                                snapshot?: unknown;
                                                                            }
                                                                            +
                                                                            +
                                                                            +
                                                                            +
                                                                            Index
                                                                            +
                                                                            +
                                                                            + +

                                                                            Properties

                                                                            eventId: string
                                                                            eventType: WebBlackboxEventType
                                                                            t: number
                                                                            mono: number
                                                                            kind: "trace" | "cpu" | "heap" | "longtask" | "vitals" | "other"
                                                                            hash?: string
                                                                            size?: number
                                                                            reason?: string
                                                                            snapshot?: unknown
                                                                            +
                                                                            +
                                                                            + +
                                                                            +
                                                                            + +
                                                                            eventId: string
                                                                            +
                                                                            + +
                                                                            eventType: WebBlackboxEventType
                                                                            +
                                                                            + +
                                                                            t: number
                                                                            +
                                                                            + +
                                                                            mono: number
                                                                            +
                                                                            + +
                                                                            kind: "trace" | "cpu" | "heap" | "longtask" | "vitals" | "other"
                                                                            +
                                                                            + +
                                                                            hash?: string
                                                                            +
                                                                            + +
                                                                            size?: number
                                                                            +
                                                                            + +
                                                                            reason?: string
                                                                            +
                                                                            + +
                                                                            snapshot?: unknown
                                                                            +
                                                                            + +
                                                                            +
                                                                            diff --git a/docs/api/player-sdk/types/PlayerArchive.html b/docs/api/player-sdk/types/PlayerArchive.html index 0f7c343..afd1c8b 100644 --- a/docs/api/player-sdk/types/PlayerArchive.html +++ b/docs/api/player-sdk/types/PlayerArchive.html @@ -1,7 +1,87 @@ -PlayerArchive | @webblackbox/player-sdk API
                                                                            @webblackbox/player-sdk API
                                                                              Preparing search index...

                                                                              Type Alias PlayerArchive

                                                                              Parsed archive metadata and indexes.

                                                                              -
                                                                              type PlayerArchive = {
                                                                                  manifest: ExportManifest;
                                                                                  timeIndex: ChunkTimeIndexEntry[];
                                                                                  requestIndex: RequestIndexEntry[];
                                                                                  invertedIndex: InvertedIndexEntry[];
                                                                                  integrity: HashesManifest;
                                                                              }
                                                                              Index

                                                                              Properties

                                                                              manifest +PlayerArchive | @webblackbox/player-sdk API
                                                                              +
                                                                              @webblackbox/player-sdk API + +
                                                                                +
                                                                                +
                                                                                Preparing search index...
                                                                                +
                                                                                +
                                                                                +
                                                                                + +

                                                                                Type Alias PlayerArchive

                                                                                +
                                                                                +

                                                                                Parsed archive metadata and indexes.

                                                                                +
                                                                                +
                                                                                type PlayerArchive = {
                                                                                    manifest: ExportManifest;
                                                                                    timeIndex: ChunkTimeIndexEntry[];
                                                                                    requestIndex: RequestIndexEntry[];
                                                                                    invertedIndex: InvertedIndexEntry[];
                                                                                    integrity: HashesManifest;
                                                                                    privacyManifest: PrivacyManifest | null;
                                                                                }
                                                                                +
                                                                                +
                                                                                +
                                                                                +
                                                                                Index
                                                                                +

                                                                                Properties

                                                                                manifest: ExportManifest
                                                                                timeIndex: ChunkTimeIndexEntry[]
                                                                                requestIndex: RequestIndexEntry[]
                                                                                invertedIndex: InvertedIndexEntry[]
                                                                                integrity: HashesManifest
                                                                                +privacyManifest +
                                                                                +
                                                                                + +
                                                                                +
                                                                                + +
                                                                                manifest: ExportManifest
                                                                                +
                                                                                + +
                                                                                timeIndex: ChunkTimeIndexEntry[]
                                                                                +
                                                                                + +
                                                                                requestIndex: RequestIndexEntry[]
                                                                                +
                                                                                + +
                                                                                invertedIndex: InvertedIndexEntry[]
                                                                                +
                                                                                + +
                                                                                integrity: HashesManifest
                                                                                +
                                                                                + +
                                                                                privacyManifest: PrivacyManifest | null
                                                                                +
                                                                                + +
                                                                                +
                                                                                diff --git a/docs/api/player-sdk/types/PlayerBlob.html b/docs/api/player-sdk/types/PlayerBlob.html new file mode 100644 index 0000000..002a62b --- /dev/null +++ b/docs/api/player-sdk/types/PlayerBlob.html @@ -0,0 +1,63 @@ +PlayerBlob | @webblackbox/player-sdk API
                                                                                +
                                                                                @webblackbox/player-sdk API + +
                                                                                  +
                                                                                  +
                                                                                  Preparing search index...
                                                                                  +
                                                                                  +
                                                                                  +
                                                                                  + +

                                                                                  Type Alias PlayerBlob

                                                                                  +
                                                                                  +

                                                                                  Decrypted or plaintext blob content resolved from an archive hash.

                                                                                  +
                                                                                  +
                                                                                  type PlayerBlob = {
                                                                                      mime: string;
                                                                                      bytes: Uint8Array;
                                                                                  }
                                                                                  +
                                                                                  +
                                                                                  +
                                                                                  +
                                                                                  Index
                                                                                  +
                                                                                  +
                                                                                  + +
                                                                                  +
                                                                                  + +
                                                                                  +
                                                                                  + +
                                                                                  mime: string
                                                                                  +
                                                                                  + +
                                                                                  bytes: Uint8Array
                                                                                  +
                                                                                  + +
                                                                                  +
                                                                                  diff --git a/docs/api/player-sdk/types/PlayerComparison.html b/docs/api/player-sdk/types/PlayerComparison.html index 353649b..207eed5 100644 --- a/docs/api/player-sdk/types/PlayerComparison.html +++ b/docs/api/player-sdk/types/PlayerComparison.html @@ -1,5 +1,29 @@ -PlayerComparison | @webblackbox/player-sdk API
                                                                                  @webblackbox/player-sdk API
                                                                                    Preparing search index...

                                                                                    Type Alias PlayerComparison

                                                                                    Session-vs-session comparison summary.

                                                                                    -
                                                                                    type PlayerComparison = {
                                                                                        leftSessionId: string;
                                                                                        rightSessionId: string;
                                                                                        leftSid: string;
                                                                                        rightSid: string;
                                                                                        eventDelta: number;
                                                                                        errorDelta: number;
                                                                                        requestDelta: number;
                                                                                        durationDeltaMs: number;
                                                                                        typeDeltas: { type: string; left: number; right: number; delta: number }[];
                                                                                        endpointRegressions: {
                                                                                            endpoint: string;
                                                                                            method: string;
                                                                                            leftCount: number;
                                                                                            rightCount: number;
                                                                                            countDelta: number;
                                                                                            leftFailed: number;
                                                                                            rightFailed: number;
                                                                                            failedDelta: number;
                                                                                            leftFailureRate: number;
                                                                                            rightFailureRate: number;
                                                                                            failureRateDelta: number;
                                                                                            leftP95DurationMs: number;
                                                                                            rightP95DurationMs: number;
                                                                                            p95DurationDeltaMs: number;
                                                                                        }[];
                                                                                    }
                                                                                    Index

                                                                                    Properties

                                                                                    leftSessionId +PlayerComparison | @webblackbox/player-sdk API
                                                                                    +
                                                                                    @webblackbox/player-sdk API + +
                                                                                      +
                                                                                      +
                                                                                      Preparing search index...
                                                                                      +
                                                                                      +
                                                                                      +
                                                                                      + +

                                                                                      Type Alias PlayerComparison

                                                                                      +
                                                                                      +

                                                                                      Session-vs-session comparison summary.

                                                                                      +
                                                                                      +
                                                                                      type PlayerComparison = {
                                                                                          leftSessionId: string;
                                                                                          rightSessionId: string;
                                                                                          leftSid: string;
                                                                                          rightSid: string;
                                                                                          eventDelta: number;
                                                                                          errorDelta: number;
                                                                                          requestDelta: number;
                                                                                          durationDeltaMs: number;
                                                                                          typeDeltas: { type: string; left: number; right: number; delta: number }[];
                                                                                          endpointRegressions: {
                                                                                              endpoint: string;
                                                                                              method: string;
                                                                                              leftCount: number;
                                                                                              rightCount: number;
                                                                                              countDelta: number;
                                                                                              leftFailed: number;
                                                                                              rightFailed: number;
                                                                                              failedDelta: number;
                                                                                              leftFailureRate: number;
                                                                                              rightFailureRate: number;
                                                                                              failureRateDelta: number;
                                                                                              leftP95DurationMs: number;
                                                                                              rightP95DurationMs: number;
                                                                                              p95DurationDeltaMs: number;
                                                                                          }[];
                                                                                      }
                                                                                      +
                                                                                      +
                                                                                      +
                                                                                      +
                                                                                      Index
                                                                                      +

                                                                                      Properties

                                                                                      leftSessionId: string
                                                                                      rightSessionId: string
                                                                                      leftSid: string

                                                                                      Use leftSessionId instead.

                                                                                      -
                                                                                      rightSid: string

                                                                                      Use rightSessionId instead.

                                                                                      -
                                                                                      eventDelta: number
                                                                                      errorDelta: number
                                                                                      requestDelta: number
                                                                                      durationDeltaMs: number
                                                                                      typeDeltas: { type: string; left: number; right: number; delta: number }[]
                                                                                      endpointRegressions: {
                                                                                          endpoint: string;
                                                                                          method: string;
                                                                                          leftCount: number;
                                                                                          rightCount: number;
                                                                                          countDelta: number;
                                                                                          leftFailed: number;
                                                                                          rightFailed: number;
                                                                                          failedDelta: number;
                                                                                          leftFailureRate: number;
                                                                                          rightFailureRate: number;
                                                                                          failureRateDelta: number;
                                                                                          leftP95DurationMs: number;
                                                                                          rightP95DurationMs: number;
                                                                                          p95DurationDeltaMs: number;
                                                                                      }[]
                                                                                      +
                                                                                      +
                                                                                      + +
                                                                                      +
                                                                                      + +
                                                                                      leftSessionId: string
                                                                                      +
                                                                                      + +
                                                                                      rightSessionId: string
                                                                                      +
                                                                                      + +
                                                                                      leftSid: string
                                                                                      +
                                                                                      +
                                                                                      +

                                                                                      Use leftSessionId instead.

                                                                                      +
                                                                                      +
                                                                                      + +
                                                                                      rightSid: string
                                                                                      +
                                                                                      +
                                                                                      +

                                                                                      Use rightSessionId instead.

                                                                                      +
                                                                                      +
                                                                                      + +
                                                                                      eventDelta: number
                                                                                      +
                                                                                      + +
                                                                                      errorDelta: number
                                                                                      +
                                                                                      + +
                                                                                      requestDelta: number
                                                                                      +
                                                                                      + +
                                                                                      durationDeltaMs: number
                                                                                      +
                                                                                      + +
                                                                                      typeDeltas: { type: string; left: number; right: number; delta: number }[]
                                                                                      +
                                                                                      + +
                                                                                      endpointRegressions: {
                                                                                          endpoint: string;
                                                                                          method: string;
                                                                                          leftCount: number;
                                                                                          rightCount: number;
                                                                                          countDelta: number;
                                                                                          leftFailed: number;
                                                                                          rightFailed: number;
                                                                                          failedDelta: number;
                                                                                          leftFailureRate: number;
                                                                                          rightFailureRate: number;
                                                                                          failureRateDelta: number;
                                                                                          leftP95DurationMs: number;
                                                                                          rightP95DurationMs: number;
                                                                                          p95DurationDeltaMs: number;
                                                                                      }[]
                                                                                      +
                                                                                      + +
                                                                                      +
                                                                                      diff --git a/docs/api/player-sdk/types/PlayerDerivedView.html b/docs/api/player-sdk/types/PlayerDerivedView.html index 4493f36..9f3f13a 100644 --- a/docs/api/player-sdk/types/PlayerDerivedView.html +++ b/docs/api/player-sdk/types/PlayerDerivedView.html @@ -1,4 +1,63 @@ -PlayerDerivedView | @webblackbox/player-sdk API
                                                                                      @webblackbox/player-sdk API
                                                                                        Preparing search index...

                                                                                        Type Alias PlayerDerivedView

                                                                                        Cached derived analysis view.

                                                                                        -
                                                                                        type PlayerDerivedView = {
                                                                                            actionSpans: ActionSpan[];
                                                                                            totals: { events: number; errors: number; requests: number };
                                                                                        }
                                                                                        Index

                                                                                        Properties

                                                                                        actionSpans +PlayerDerivedView | @webblackbox/player-sdk API
                                                                                        +
                                                                                        @webblackbox/player-sdk API + +
                                                                                          +
                                                                                          +
                                                                                          Preparing search index...
                                                                                          +
                                                                                          +
                                                                                          +
                                                                                          + +

                                                                                          Type Alias PlayerDerivedView

                                                                                          +
                                                                                          +

                                                                                          Cached derived analysis view.

                                                                                          +
                                                                                          +
                                                                                          type PlayerDerivedView = {
                                                                                              actionSpans: ActionSpan[];
                                                                                              totals: { events: number; errors: number; requests: number };
                                                                                          }
                                                                                          +
                                                                                          +
                                                                                          +
                                                                                          +
                                                                                          Index
                                                                                          +
                                                                                          +
                                                                                          + +

                                                                                          Properties

                                                                                          actionSpans: ActionSpan[]
                                                                                          totals: { events: number; errors: number; requests: number }
                                                                                          +
                                                                                          +
                                                                                          + +
                                                                                          +
                                                                                          + +
                                                                                          actionSpans: ActionSpan[]
                                                                                          +
                                                                                          + +
                                                                                          totals: { events: number; errors: number; requests: number }
                                                                                          +
                                                                                          + +
                                                                                          +
                                                                                          diff --git a/docs/api/player-sdk/types/PlayerOpenInput.html b/docs/api/player-sdk/types/PlayerOpenInput.html index 4062f20..ff70621 100644 --- a/docs/api/player-sdk/types/PlayerOpenInput.html +++ b/docs/api/player-sdk/types/PlayerOpenInput.html @@ -1,2 +1,34 @@ -PlayerOpenInput | @webblackbox/player-sdk API
                                                                                          @webblackbox/player-sdk API
                                                                                            Preparing search index...

                                                                                            Type Alias PlayerOpenInput

                                                                                            PlayerOpenInput: ArrayBuffer | Uint8Array | Blob

                                                                                            Supported input payloads when opening an archive.

                                                                                            -
                                                                                            +PlayerOpenInput | @webblackbox/player-sdk API
                                                                                            +
                                                                                            @webblackbox/player-sdk API + +
                                                                                              +
                                                                                              +
                                                                                              Preparing search index...
                                                                                              +
                                                                                              +
                                                                                              +
                                                                                              + +

                                                                                              Type Alias PlayerOpenInput

                                                                                              +
                                                                                              PlayerOpenInput: ArrayBuffer | Uint8Array | Blob
                                                                                              +

                                                                                              Supported input payloads when opening an archive.

                                                                                              +
                                                                                              +
                                                                                              + +
                                                                                              +
                                                                                              diff --git a/docs/api/player-sdk/types/PlayerOpenOptions.html b/docs/api/player-sdk/types/PlayerOpenOptions.html index 3f63148..5a6269d 100644 --- a/docs/api/player-sdk/types/PlayerOpenOptions.html +++ b/docs/api/player-sdk/types/PlayerOpenOptions.html @@ -1,4 +1,69 @@ -PlayerOpenOptions | @webblackbox/player-sdk API
                                                                                              @webblackbox/player-sdk API
                                                                                                Preparing search index...

                                                                                                Type Alias PlayerOpenOptions

                                                                                                Optional archive open settings.

                                                                                                -
                                                                                                type PlayerOpenOptions = {
                                                                                                    passphrase?: string;
                                                                                                    range?: PlayerRange;
                                                                                                }
                                                                                                Index

                                                                                                Properties

                                                                                                passphrase? +PlayerOpenOptions | @webblackbox/player-sdk API
                                                                                                +
                                                                                                @webblackbox/player-sdk API + +
                                                                                                  +
                                                                                                  +
                                                                                                  Preparing search index...
                                                                                                  +
                                                                                                  +
                                                                                                  +
                                                                                                  + +

                                                                                                  Type Alias PlayerOpenOptions

                                                                                                  +
                                                                                                  +

                                                                                                  Optional archive open settings.

                                                                                                  +
                                                                                                  +
                                                                                                  type PlayerOpenOptions = {
                                                                                                      passphrase?: string;
                                                                                                      range?: PlayerRange;
                                                                                                      resourceLimits?: Partial<ArchiveResourceLimits>;
                                                                                                  }
                                                                                                  +
                                                                                                  +
                                                                                                  +
                                                                                                  +
                                                                                                  Index
                                                                                                  +
                                                                                                  +
                                                                                                  + +

                                                                                                  Properties

                                                                                                  passphrase?: string
                                                                                                  range?: PlayerRange
                                                                                                  +resourceLimits? +
                                                                                                  +
                                                                                                  + +
                                                                                                  +
                                                                                                  + +
                                                                                                  passphrase?: string
                                                                                                  +
                                                                                                  + +
                                                                                                  range?: PlayerRange
                                                                                                  +
                                                                                                  + +
                                                                                                  resourceLimits?: Partial<ArchiveResourceLimits>
                                                                                                  +
                                                                                                  + +
                                                                                                  +
                                                                                                  diff --git a/docs/api/player-sdk/types/PlayerQuery.html b/docs/api/player-sdk/types/PlayerQuery.html index 5e3c76b..71f81cb 100644 --- a/docs/api/player-sdk/types/PlayerQuery.html +++ b/docs/api/player-sdk/types/PlayerQuery.html @@ -1,9 +1,93 @@ -PlayerQuery | @webblackbox/player-sdk API
                                                                                                  @webblackbox/player-sdk API
                                                                                                    Preparing search index...

                                                                                                    Type Alias PlayerQuery

                                                                                                    Event query filter model.

                                                                                                    -
                                                                                                    type PlayerQuery = {
                                                                                                        range?: PlayerRange;
                                                                                                        types?: WebBlackboxEventType[];
                                                                                                        levels?: EventLevel[];
                                                                                                        text?: string;
                                                                                                        requestId?: string;
                                                                                                        limit?: number;
                                                                                                        offset?: number;
                                                                                                    }
                                                                                                    Index

                                                                                                    Properties

                                                                                                    range? +PlayerQuery | @webblackbox/player-sdk API
                                                                                                    +
                                                                                                    @webblackbox/player-sdk API + +
                                                                                                      +
                                                                                                      +
                                                                                                      Preparing search index...
                                                                                                      +
                                                                                                      +
                                                                                                      +
                                                                                                      + +

                                                                                                      Type Alias PlayerQuery

                                                                                                      +
                                                                                                      +

                                                                                                      Event query filter model.

                                                                                                      +
                                                                                                      +
                                                                                                      type PlayerQuery = {
                                                                                                          range?: PlayerRange;
                                                                                                          types?: WebBlackboxEventType[];
                                                                                                          levels?: EventLevel[];
                                                                                                          text?: string;
                                                                                                          requestId?: string;
                                                                                                          limit?: number;
                                                                                                          offset?: number;
                                                                                                      }
                                                                                                      +
                                                                                                      +
                                                                                                      +
                                                                                                      +
                                                                                                      Index
                                                                                                      +

                                                                                                      Properties

                                                                                                      range?: PlayerRange
                                                                                                      types?: WebBlackboxEventType[]
                                                                                                      levels?: EventLevel[]
                                                                                                      text?: string
                                                                                                      requestId?: string
                                                                                                      limit?: number
                                                                                                      offset?: number
                                                                                                      +
                                                                                                      +
                                                                                                      + +
                                                                                                      +
                                                                                                      + +
                                                                                                      range?: PlayerRange
                                                                                                      +
                                                                                                      + +
                                                                                                      types?: WebBlackboxEventType[]
                                                                                                      +
                                                                                                      + +
                                                                                                      levels?: EventLevel[]
                                                                                                      +
                                                                                                      + +
                                                                                                      text?: string
                                                                                                      +
                                                                                                      + +
                                                                                                      requestId?: string
                                                                                                      +
                                                                                                      + +
                                                                                                      limit?: number
                                                                                                      +
                                                                                                      + +
                                                                                                      offset?: number
                                                                                                      +
                                                                                                      + +
                                                                                                      +
                                                                                                      diff --git a/docs/api/player-sdk/types/PlayerRange.html b/docs/api/player-sdk/types/PlayerRange.html index 1d53f9a..689cd7b 100644 --- a/docs/api/player-sdk/types/PlayerRange.html +++ b/docs/api/player-sdk/types/PlayerRange.html @@ -1,4 +1,63 @@ -PlayerRange | @webblackbox/player-sdk API
                                                                                                      @webblackbox/player-sdk API
                                                                                                        Preparing search index...

                                                                                                        Type Alias PlayerRange

                                                                                                        Monotonic-time query range in milliseconds.

                                                                                                        -
                                                                                                        type PlayerRange = {
                                                                                                            monoStart?: number;
                                                                                                            monoEnd?: number;
                                                                                                        }
                                                                                                        Index

                                                                                                        Properties

                                                                                                        monoStart? +PlayerRange | @webblackbox/player-sdk API
                                                                                                        +
                                                                                                        @webblackbox/player-sdk API + +
                                                                                                          +
                                                                                                          +
                                                                                                          Preparing search index...
                                                                                                          +
                                                                                                          +
                                                                                                          +
                                                                                                          + +

                                                                                                          Type Alias PlayerRange

                                                                                                          +
                                                                                                          +

                                                                                                          Monotonic-time query range in milliseconds.

                                                                                                          +
                                                                                                          +
                                                                                                          type PlayerRange = {
                                                                                                              monoStart?: number;
                                                                                                              monoEnd?: number;
                                                                                                          }
                                                                                                          +
                                                                                                          +
                                                                                                          +
                                                                                                          +
                                                                                                          Index
                                                                                                          +
                                                                                                          +
                                                                                                          + +

                                                                                                          Properties

                                                                                                          monoStart?: number
                                                                                                          monoEnd?: number
                                                                                                          +
                                                                                                          +
                                                                                                          + +
                                                                                                          +
                                                                                                          + +
                                                                                                          monoStart?: number
                                                                                                          +
                                                                                                          + +
                                                                                                          monoEnd?: number
                                                                                                          +
                                                                                                          + +
                                                                                                          +
                                                                                                          diff --git a/docs/api/player-sdk/types/PlayerSearchResult.html b/docs/api/player-sdk/types/PlayerSearchResult.html index 5a75cf2..fda71d0 100644 --- a/docs/api/player-sdk/types/PlayerSearchResult.html +++ b/docs/api/player-sdk/types/PlayerSearchResult.html @@ -1,5 +1,69 @@ -PlayerSearchResult | @webblackbox/player-sdk API
                                                                                                          @webblackbox/player-sdk API
                                                                                                            Preparing search index...

                                                                                                            Type Alias PlayerSearchResult

                                                                                                            Ranked full-text search hit for an event.

                                                                                                            -
                                                                                                            type PlayerSearchResult = {
                                                                                                                eventId: string;
                                                                                                                score: number;
                                                                                                                event: WebBlackboxEvent;
                                                                                                            }
                                                                                                            Index

                                                                                                            Properties

                                                                                                            eventId +PlayerSearchResult | @webblackbox/player-sdk API
                                                                                                            +
                                                                                                            @webblackbox/player-sdk API + +
                                                                                                              +
                                                                                                              +
                                                                                                              Preparing search index...
                                                                                                              +
                                                                                                              +
                                                                                                              +
                                                                                                              + +

                                                                                                              Type Alias PlayerSearchResult

                                                                                                              +
                                                                                                              +

                                                                                                              Ranked full-text search hit for an event.

                                                                                                              +
                                                                                                              +
                                                                                                              type PlayerSearchResult = {
                                                                                                                  eventId: string;
                                                                                                                  score: number;
                                                                                                                  event: WebBlackboxEvent;
                                                                                                              }
                                                                                                              +
                                                                                                              +
                                                                                                              +
                                                                                                              +
                                                                                                              Index
                                                                                                              +
                                                                                                              +
                                                                                                              + +

                                                                                                              Properties

                                                                                                              eventId: string
                                                                                                              score: number
                                                                                                              event: WebBlackboxEvent
                                                                                                              +
                                                                                                              +
                                                                                                              + +
                                                                                                              +
                                                                                                              + +
                                                                                                              eventId: string
                                                                                                              +
                                                                                                              + +
                                                                                                              score: number
                                                                                                              +
                                                                                                              + +
                                                                                                              event: WebBlackboxEvent
                                                                                                              +
                                                                                                              + +
                                                                                                              +
                                                                                                              diff --git a/docs/api/player-sdk/types/PlayerStatus.html b/docs/api/player-sdk/types/PlayerStatus.html index 29fc84d..fb439ee 100644 --- a/docs/api/player-sdk/types/PlayerStatus.html +++ b/docs/api/player-sdk/types/PlayerStatus.html @@ -1,2 +1,34 @@ -PlayerStatus | @webblackbox/player-sdk API
                                                                                                              @webblackbox/player-sdk API
                                                                                                                Preparing search index...

                                                                                                                Type Alias PlayerStatus

                                                                                                                PlayerStatus: "idle" | "loaded"

                                                                                                                Player lifecycle status.

                                                                                                                -
                                                                                                                +PlayerStatus | @webblackbox/player-sdk API
                                                                                                                +
                                                                                                                @webblackbox/player-sdk API + +
                                                                                                                  +
                                                                                                                  +
                                                                                                                  Preparing search index...
                                                                                                                  +
                                                                                                                  +
                                                                                                                  +
                                                                                                                  + +

                                                                                                                  Type Alias PlayerStatus

                                                                                                                  +
                                                                                                                  PlayerStatus: "idle" | "loaded"
                                                                                                                  +

                                                                                                                  Player lifecycle status.

                                                                                                                  +
                                                                                                                  +
                                                                                                                  + +
                                                                                                                  +
                                                                                                                  diff --git a/docs/api/player-sdk/types/PlaywrightMockScriptOptions.html b/docs/api/player-sdk/types/PlaywrightMockScriptOptions.html index 799b28a..aa64ce1 100644 --- a/docs/api/player-sdk/types/PlaywrightMockScriptOptions.html +++ b/docs/api/player-sdk/types/PlaywrightMockScriptOptions.html @@ -1,2 +1,34 @@ -PlaywrightMockScriptOptions | @webblackbox/player-sdk API
                                                                                                                  @webblackbox/player-sdk API
                                                                                                                    Preparing search index...

                                                                                                                    Type Alias PlaywrightMockScriptOptions

                                                                                                                    PlaywrightMockScriptOptions: PlaywrightScriptOptions & { maxMocks?: number }

                                                                                                                    Playwright mock script generation options.

                                                                                                                    -
                                                                                                                    +PlaywrightMockScriptOptions | @webblackbox/player-sdk API
                                                                                                                    +
                                                                                                                    @webblackbox/player-sdk API + +
                                                                                                                      +
                                                                                                                      +
                                                                                                                      Preparing search index...
                                                                                                                      +
                                                                                                                      +
                                                                                                                      +
                                                                                                                      + +

                                                                                                                      Type Alias PlaywrightMockScriptOptions

                                                                                                                      +
                                                                                                                      PlaywrightMockScriptOptions: PlaywrightScriptOptions & { maxMocks?: number }
                                                                                                                      +

                                                                                                                      Playwright mock script generation options.

                                                                                                                      +
                                                                                                                      +
                                                                                                                      + +
                                                                                                                      +
                                                                                                                      diff --git a/docs/api/player-sdk/types/PlaywrightScriptOptions.html b/docs/api/player-sdk/types/PlaywrightScriptOptions.html index c45bb11..881884f 100644 --- a/docs/api/player-sdk/types/PlaywrightScriptOptions.html +++ b/docs/api/player-sdk/types/PlaywrightScriptOptions.html @@ -1,7 +1,81 @@ -PlaywrightScriptOptions | @webblackbox/player-sdk API
                                                                                                                      @webblackbox/player-sdk API
                                                                                                                        Preparing search index...

                                                                                                                        Type Alias PlaywrightScriptOptions

                                                                                                                        Playwright script generation options.

                                                                                                                        -
                                                                                                                        type PlaywrightScriptOptions = {
                                                                                                                            name?: string;
                                                                                                                            range?: PlayerRange;
                                                                                                                            startUrl?: string;
                                                                                                                            maxActions?: number;
                                                                                                                            includeHarReplay?: boolean;
                                                                                                                        }
                                                                                                                        Index

                                                                                                                        Properties

                                                                                                                        name? +PlaywrightScriptOptions | @webblackbox/player-sdk API
                                                                                                                        +
                                                                                                                        @webblackbox/player-sdk API + +
                                                                                                                          +
                                                                                                                          +
                                                                                                                          Preparing search index...
                                                                                                                          +
                                                                                                                          +
                                                                                                                          +
                                                                                                                          + +

                                                                                                                          Type Alias PlaywrightScriptOptions

                                                                                                                          +
                                                                                                                          +

                                                                                                                          Playwright script generation options.

                                                                                                                          +
                                                                                                                          +
                                                                                                                          type PlaywrightScriptOptions = {
                                                                                                                              name?: string;
                                                                                                                              range?: PlayerRange;
                                                                                                                              startUrl?: string;
                                                                                                                              maxActions?: number;
                                                                                                                              includeHarReplay?: boolean;
                                                                                                                          }
                                                                                                                          +
                                                                                                                          +
                                                                                                                          +
                                                                                                                          +
                                                                                                                          Index
                                                                                                                          +

                                                                                                                          Properties

                                                                                                                          name?: string
                                                                                                                          range?: PlayerRange
                                                                                                                          startUrl?: string
                                                                                                                          maxActions?: number
                                                                                                                          includeHarReplay?: boolean
                                                                                                                          +
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          name?: string
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          range?: PlayerRange
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          startUrl?: string
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          maxActions?: number
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          includeHarReplay?: boolean
                                                                                                                          +
                                                                                                                          + +
                                                                                                                          +
                                                                                                                          diff --git a/docs/api/player-sdk/types/PrivacyProtectionReport.html b/docs/api/player-sdk/types/PrivacyProtectionReport.html new file mode 100644 index 0000000..dc5d9d2 --- /dev/null +++ b/docs/api/player-sdk/types/PrivacyProtectionReport.html @@ -0,0 +1,75 @@ +PrivacyProtectionReport | @webblackbox/player-sdk API
                                                                                                                          +
                                                                                                                          @webblackbox/player-sdk API + +
                                                                                                                            +
                                                                                                                            +
                                                                                                                            Preparing search index...
                                                                                                                            +
                                                                                                                            +
                                                                                                                            +
                                                                                                                            + +

                                                                                                                            Type Alias PrivacyProtectionReport

                                                                                                                            +
                                                                                                                            +

                                                                                                                            Explainable privacy posture for export/share preflight review.

                                                                                                                            +
                                                                                                                            +
                                                                                                                            type PrivacyProtectionReport = {
                                                                                                                                encrypted: boolean;
                                                                                                                                redaction: {
                                                                                                                                    hashSensitiveValues: boolean;
                                                                                                                                    headers: string[];
                                                                                                                                    cookieNames: string[];
                                                                                                                                    bodyPatterns: string[];
                                                                                                                                    blockedSelectors: string[];
                                                                                                                                    strategy: string[];
                                                                                                                                };
                                                                                                                                detected: {
                                                                                                                                    redactedMarkers: number;
                                                                                                                                    hashedSensitiveValues: number;
                                                                                                                                    sensitiveKeyMentions: number;
                                                                                                                                };
                                                                                                                                scanner: {
                                                                                                                                    preEncryption: boolean;
                                                                                                                                    status: "passed"
                                                                                                                                    | "blocked"
                                                                                                                                    | "unknown";
                                                                                                                                    findingCount: number;
                                                                                                                                };
                                                                                                                            }
                                                                                                                            +
                                                                                                                            +
                                                                                                                            +
                                                                                                                            +
                                                                                                                            Index
                                                                                                                            +
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            encrypted: boolean
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            redaction: {
                                                                                                                                hashSensitiveValues: boolean;
                                                                                                                                headers: string[];
                                                                                                                                cookieNames: string[];
                                                                                                                                bodyPatterns: string[];
                                                                                                                                blockedSelectors: string[];
                                                                                                                                strategy: string[];
                                                                                                                            }
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            detected: {
                                                                                                                                redactedMarkers: number;
                                                                                                                                hashedSensitiveValues: number;
                                                                                                                                sensitiveKeyMentions: number;
                                                                                                                            }
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            scanner: {
                                                                                                                                preEncryption: boolean;
                                                                                                                                status: "passed" | "blocked" | "unknown";
                                                                                                                                findingCount: number;
                                                                                                                            }
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            +
                                                                                                                            diff --git a/docs/api/player-sdk/types/RealtimeNetworkEntry.html b/docs/api/player-sdk/types/RealtimeNetworkEntry.html index aa2f39b..402136e 100644 --- a/docs/api/player-sdk/types/RealtimeNetworkEntry.html +++ b/docs/api/player-sdk/types/RealtimeNetworkEntry.html @@ -1,5 +1,29 @@ -RealtimeNetworkEntry | @webblackbox/player-sdk API
                                                                                                                            @webblackbox/player-sdk API
                                                                                                                              Preparing search index...

                                                                                                                              Type Alias RealtimeNetworkEntry

                                                                                                                              Realtime network stream entry (WebSocket/SSE).

                                                                                                                              -
                                                                                                                              type RealtimeNetworkEntry = {
                                                                                                                                  eventId: string;
                                                                                                                                  eventType: WebBlackboxEventType;
                                                                                                                                  protocol: "ws" | "sse";
                                                                                                                                  mono: number;
                                                                                                                                  t: number;
                                                                                                                                  streamId?: string;
                                                                                                                                  direction?: "sent" | "received" | "unknown";
                                                                                                                                  phase?: string;
                                                                                                                                  url?: string;
                                                                                                                                  opcode?: number;
                                                                                                                                  payloadLength?: number;
                                                                                                                                  payloadPreview?: string;
                                                                                                                                  snapshot?: unknown;
                                                                                                                              }
                                                                                                                              Index

                                                                                                                              Properties

                                                                                                                              eventId +RealtimeNetworkEntry | @webblackbox/player-sdk API
                                                                                                                              +
                                                                                                                              @webblackbox/player-sdk API + +
                                                                                                                                +
                                                                                                                                +
                                                                                                                                Preparing search index...
                                                                                                                                +
                                                                                                                                +
                                                                                                                                +
                                                                                                                                + +

                                                                                                                                Type Alias RealtimeNetworkEntry

                                                                                                                                +
                                                                                                                                +

                                                                                                                                Realtime network stream entry (WebSocket/SSE).

                                                                                                                                +
                                                                                                                                +
                                                                                                                                type RealtimeNetworkEntry = {
                                                                                                                                    eventId: string;
                                                                                                                                    eventType: WebBlackboxEventType;
                                                                                                                                    protocol: "ws" | "sse";
                                                                                                                                    mono: number;
                                                                                                                                    t: number;
                                                                                                                                    streamId?: string;
                                                                                                                                    direction?: "sent" | "received" | "unknown";
                                                                                                                                    phase?: string;
                                                                                                                                    url?: string;
                                                                                                                                    opcode?: number;
                                                                                                                                    payloadLength?: number;
                                                                                                                                    payloadPreview?: string;
                                                                                                                                    snapshot?: unknown;
                                                                                                                                }
                                                                                                                                +
                                                                                                                                +
                                                                                                                                +
                                                                                                                                +
                                                                                                                                Index
                                                                                                                                +

                                                                                                                                Properties

                                                                                                                                eventId: string
                                                                                                                                eventType: WebBlackboxEventType
                                                                                                                                protocol: "ws" | "sse"
                                                                                                                                mono: number
                                                                                                                                t: number
                                                                                                                                streamId?: string
                                                                                                                                direction?: "sent" | "received" | "unknown"
                                                                                                                                phase?: string
                                                                                                                                url?: string
                                                                                                                                opcode?: number
                                                                                                                                payloadLength?: number
                                                                                                                                payloadPreview?: string
                                                                                                                                snapshot?: unknown
                                                                                                                                +
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                eventId: string
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                eventType: WebBlackboxEventType
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                protocol: "ws" | "sse"
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                mono: number
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                t: number
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                streamId?: string
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                direction?: "sent" | "received" | "unknown"
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                phase?: string
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                url?: string
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                opcode?: number
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                payloadLength?: number
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                payloadPreview?: string
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                snapshot?: unknown
                                                                                                                                +
                                                                                                                                + +
                                                                                                                                +
                                                                                                                                diff --git a/docs/api/player-sdk/types/ReplayDiagnosticEntry.html b/docs/api/player-sdk/types/ReplayDiagnosticEntry.html new file mode 100644 index 0000000..e64a4ca --- /dev/null +++ b/docs/api/player-sdk/types/ReplayDiagnosticEntry.html @@ -0,0 +1,99 @@ +ReplayDiagnosticEntry | @webblackbox/player-sdk API
                                                                                                                                +
                                                                                                                                @webblackbox/player-sdk API + +
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  Preparing search index...
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  + +

                                                                                                                                  Type Alias ReplayDiagnosticEntry

                                                                                                                                  +
                                                                                                                                  +

                                                                                                                                  Replay confidence row that links action, request/response, error, and screenshot evidence.

                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  type ReplayDiagnosticEntry = {
                                                                                                                                      actId: string;
                                                                                                                                      confidence: "high" | "medium" | "low";
                                                                                                                                      triggerEventId: string;
                                                                                                                                      triggerType: string | null;
                                                                                                                                      causeChain: string[];
                                                                                                                                      requestResponseDiffs: {
                                                                                                                                          reqId: string;
                                                                                                                                          method: string;
                                                                                                                                          url: string;
                                                                                                                                          capturedStatus: number | null;
                                                                                                                                          failed: boolean;
                                                                                                                                          hasRequestBody: boolean;
                                                                                                                                          hasResponseBody: boolean;
                                                                                                                                          responseBodySize: number | null;
                                                                                                                                      }[];
                                                                                                                                      errorMessages: string[];
                                                                                                                                      screenshotEventId: string
                                                                                                                                      | null;
                                                                                                                                  }
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  Index
                                                                                                                                  +
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  actId: string
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  confidence: "high" | "medium" | "low"
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  triggerEventId: string
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  triggerType: string | null
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  causeChain: string[]
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  requestResponseDiffs: {
                                                                                                                                      reqId: string;
                                                                                                                                      method: string;
                                                                                                                                      url: string;
                                                                                                                                      capturedStatus: number | null;
                                                                                                                                      failed: boolean;
                                                                                                                                      hasRequestBody: boolean;
                                                                                                                                      hasResponseBody: boolean;
                                                                                                                                      responseBodySize: number | null;
                                                                                                                                  }[]
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  errorMessages: string[]
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  screenshotEventId: string | null
                                                                                                                                  +
                                                                                                                                  + +
                                                                                                                                  +
                                                                                                                                  diff --git a/docs/api/player-sdk/types/RequestResponseDiff.html b/docs/api/player-sdk/types/RequestResponseDiff.html new file mode 100644 index 0000000..0559421 --- /dev/null +++ b/docs/api/player-sdk/types/RequestResponseDiff.html @@ -0,0 +1,111 @@ +RequestResponseDiff | @webblackbox/player-sdk API
                                                                                                                                  +
                                                                                                                                  @webblackbox/player-sdk API + +
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    Preparing search index...
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    + +

                                                                                                                                    Type Alias RequestResponseDiff

                                                                                                                                    +
                                                                                                                                    +

                                                                                                                                    Concrete request/response comparison for replay confidence and debugging.

                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    type RequestResponseDiff = {
                                                                                                                                        reqId: string;
                                                                                                                                        method: string;
                                                                                                                                        url: string;
                                                                                                                                        status: number | null;
                                                                                                                                        requestBodyBytes: number;
                                                                                                                                        responseBodyBytes: number;
                                                                                                                                        bodySizeDeltaBytes: number;
                                                                                                                                        requestHeaderNames: string[];
                                                                                                                                        responseHeaderNames: string[];
                                                                                                                                        missingReplayInputs: string[];
                                                                                                                                    }
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    Index
                                                                                                                                    +
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    reqId: string
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    method: string
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    url: string
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    status: number | null
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    requestBodyBytes: number
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    responseBodyBytes: number
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    bodySizeDeltaBytes: number
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    requestHeaderNames: string[]
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    responseHeaderNames: string[]
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    missingReplayInputs: string[]
                                                                                                                                    +
                                                                                                                                    + +
                                                                                                                                    +
                                                                                                                                    diff --git a/docs/api/player-sdk/types/SensitiveDataPreview.html b/docs/api/player-sdk/types/SensitiveDataPreview.html new file mode 100644 index 0000000..9f616ad --- /dev/null +++ b/docs/api/player-sdk/types/SensitiveDataPreview.html @@ -0,0 +1,63 @@ +SensitiveDataPreview | @webblackbox/player-sdk API
                                                                                                                                    +
                                                                                                                                    @webblackbox/player-sdk API + +
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      Preparing search index...
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      + +

                                                                                                                                      Type Alias SensitiveDataPreview

                                                                                                                                      +
                                                                                                                                      +

                                                                                                                                      Bounded sensitive-data preview for export/share review before publishing an archive.

                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      type SensitiveDataPreview = {
                                                                                                                                          totalMatches: number;
                                                                                                                                          samples: {
                                                                                                                                              eventId: string;
                                                                                                                                              type: WebBlackboxEventType;
                                                                                                                                              mono: number;
                                                                                                                                              reason: "redacted-marker" | "hashed-value" | "sensitive-pattern";
                                                                                                                                              snippet: string;
                                                                                                                                          }[];
                                                                                                                                      }
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      Index
                                                                                                                                      +
                                                                                                                                      +
                                                                                                                                      + +
                                                                                                                                      +
                                                                                                                                      + +
                                                                                                                                      +
                                                                                                                                      + +
                                                                                                                                      totalMatches: number
                                                                                                                                      +
                                                                                                                                      + +
                                                                                                                                      samples: {
                                                                                                                                          eventId: string;
                                                                                                                                          type: WebBlackboxEventType;
                                                                                                                                          mono: number;
                                                                                                                                          reason: "redacted-marker" | "hashed-value" | "sensitive-pattern";
                                                                                                                                          snippet: string;
                                                                                                                                      }[]
                                                                                                                                      +
                                                                                                                                      + +
                                                                                                                                      +
                                                                                                                                      diff --git a/docs/api/player-sdk/types/StorageComparison.html b/docs/api/player-sdk/types/StorageComparison.html index ec90f26..bcfd332 100644 --- a/docs/api/player-sdk/types/StorageComparison.html +++ b/docs/api/player-sdk/types/StorageComparison.html @@ -1,7 +1,81 @@ -StorageComparison | @webblackbox/player-sdk API
                                                                                                                                      @webblackbox/player-sdk API
                                                                                                                                        Preparing search index...

                                                                                                                                        Type Alias StorageComparison

                                                                                                                                        Storage-only comparison summary.

                                                                                                                                        -
                                                                                                                                        type StorageComparison = {
                                                                                                                                            leftEvents: number;
                                                                                                                                            rightEvents: number;
                                                                                                                                            kindDeltas: {
                                                                                                                                                kind: StorageTimelineEntry["kind"];
                                                                                                                                                left: number;
                                                                                                                                                right: number;
                                                                                                                                                delta: number;
                                                                                                                                            }[];
                                                                                                                                            hashOnlyLeft: string[];
                                                                                                                                            hashOnlyRight: string[];
                                                                                                                                        }
                                                                                                                                        Index

                                                                                                                                        Properties

                                                                                                                                        leftEvents +StorageComparison | @webblackbox/player-sdk API
                                                                                                                                        +
                                                                                                                                        @webblackbox/player-sdk API + +
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          Preparing search index...
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          + +

                                                                                                                                          Type Alias StorageComparison

                                                                                                                                          +
                                                                                                                                          +

                                                                                                                                          Storage-only comparison summary.

                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          type StorageComparison = {
                                                                                                                                              leftEvents: number;
                                                                                                                                              rightEvents: number;
                                                                                                                                              kindDeltas: {
                                                                                                                                                  kind: StorageTimelineEntry["kind"];
                                                                                                                                                  left: number;
                                                                                                                                                  right: number;
                                                                                                                                                  delta: number;
                                                                                                                                              }[];
                                                                                                                                              hashOnlyLeft: string[];
                                                                                                                                              hashOnlyRight: string[];
                                                                                                                                          }
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          Index
                                                                                                                                          +

                                                                                                                                          Properties

                                                                                                                                          leftEvents: number
                                                                                                                                          rightEvents: number
                                                                                                                                          kindDeltas: {
                                                                                                                                              kind: StorageTimelineEntry["kind"];
                                                                                                                                              left: number;
                                                                                                                                              right: number;
                                                                                                                                              delta: number;
                                                                                                                                          }[]
                                                                                                                                          hashOnlyLeft: string[]
                                                                                                                                          hashOnlyRight: string[]
                                                                                                                                          +
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          leftEvents: number
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          rightEvents: number
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          kindDeltas: {
                                                                                                                                              kind: StorageTimelineEntry["kind"];
                                                                                                                                              left: number;
                                                                                                                                              right: number;
                                                                                                                                              delta: number;
                                                                                                                                          }[]
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          hashOnlyLeft: string[]
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          hashOnlyRight: string[]
                                                                                                                                          +
                                                                                                                                          + +
                                                                                                                                          +
                                                                                                                                          diff --git a/docs/api/player-sdk/types/StorageTimelineEntry.html b/docs/api/player-sdk/types/StorageTimelineEntry.html index 5b4612a..136baab 100644 --- a/docs/api/player-sdk/types/StorageTimelineEntry.html +++ b/docs/api/player-sdk/types/StorageTimelineEntry.html @@ -1,5 +1,29 @@ -StorageTimelineEntry | @webblackbox/player-sdk API
                                                                                                                                          @webblackbox/player-sdk API
                                                                                                                                            Preparing search index...

                                                                                                                                            Type Alias StorageTimelineEntry

                                                                                                                                            Storage event timeline entry.

                                                                                                                                            -
                                                                                                                                            type StorageTimelineEntry = {
                                                                                                                                                eventId: string;
                                                                                                                                                eventType: WebBlackboxEventType;
                                                                                                                                                t: number;
                                                                                                                                                mono: number;
                                                                                                                                                kind: "cookie" | "local" | "session" | "idb" | "cache" | "sw" | "unknown";
                                                                                                                                                operation?: string;
                                                                                                                                                hash?: string;
                                                                                                                                                mode?: string;
                                                                                                                                                count?: number;
                                                                                                                                                reason?: string;
                                                                                                                                                snapshot?: unknown;
                                                                                                                                            }
                                                                                                                                            Index

                                                                                                                                            Properties

                                                                                                                                            eventId +StorageTimelineEntry | @webblackbox/player-sdk API
                                                                                                                                            +
                                                                                                                                            @webblackbox/player-sdk API + +
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              Preparing search index...
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              + +

                                                                                                                                              Type Alias StorageTimelineEntry

                                                                                                                                              +
                                                                                                                                              +

                                                                                                                                              Storage event timeline entry.

                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              type StorageTimelineEntry = {
                                                                                                                                                  eventId: string;
                                                                                                                                                  eventType: WebBlackboxEventType;
                                                                                                                                                  t: number;
                                                                                                                                                  mono: number;
                                                                                                                                                  kind: "cookie" | "local" | "session" | "idb" | "cache" | "sw" | "unknown";
                                                                                                                                                  operation?: string;
                                                                                                                                                  hash?: string;
                                                                                                                                                  mode?: string;
                                                                                                                                                  count?: number;
                                                                                                                                                  reason?: string;
                                                                                                                                                  snapshot?: unknown;
                                                                                                                                              }
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              Index
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              + +

                                                                                                                                              Properties

                                                                                                                                              eventId: string
                                                                                                                                              eventType: WebBlackboxEventType
                                                                                                                                              t: number
                                                                                                                                              mono: number
                                                                                                                                              kind: "cookie" | "local" | "session" | "idb" | "cache" | "sw" | "unknown"
                                                                                                                                              operation?: string
                                                                                                                                              hash?: string
                                                                                                                                              mode?: string
                                                                                                                                              count?: number
                                                                                                                                              reason?: string
                                                                                                                                              snapshot?: unknown
                                                                                                                                              +
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              eventId: string
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              eventType: WebBlackboxEventType
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              t: number
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              mono: number
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              kind: "cookie" | "local" | "session" | "idb" | "cache" | "sw" | "unknown"
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              operation?: string
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              hash?: string
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              mode?: string
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              count?: number
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              reason?: string
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              snapshot?: unknown
                                                                                                                                              +
                                                                                                                                              + +
                                                                                                                                              +
                                                                                                                                              diff --git a/docs/api/player-sdk/types/TeamIssueTemplateOptions.html b/docs/api/player-sdk/types/TeamIssueTemplateOptions.html index 6ad79a1..c3ee2cd 100644 --- a/docs/api/player-sdk/types/TeamIssueTemplateOptions.html +++ b/docs/api/player-sdk/types/TeamIssueTemplateOptions.html @@ -1,5 +1,29 @@ -TeamIssueTemplateOptions | @webblackbox/player-sdk API
                                                                                                                                              @webblackbox/player-sdk API
                                                                                                                                                Preparing search index...

                                                                                                                                                Type Alias TeamIssueTemplateOptions

                                                                                                                                                Shared options for team issue template generation.

                                                                                                                                                -
                                                                                                                                                type TeamIssueTemplateOptions = {
                                                                                                                                                    title?: string;
                                                                                                                                                    range?: PlayerRange;
                                                                                                                                                    maxItems?: number;
                                                                                                                                                    labels?: string[];
                                                                                                                                                    assignees?: string[];
                                                                                                                                                    issueType?: string;
                                                                                                                                                    projectKey?: string;
                                                                                                                                                    priority?: string;
                                                                                                                                                }
                                                                                                                                                Index

                                                                                                                                                Properties

                                                                                                                                                title? +TeamIssueTemplateOptions | @webblackbox/player-sdk API
                                                                                                                                                +
                                                                                                                                                @webblackbox/player-sdk API + +
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  Preparing search index...
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  + +

                                                                                                                                                  Type Alias TeamIssueTemplateOptions

                                                                                                                                                  +
                                                                                                                                                  +

                                                                                                                                                  Shared options for team issue template generation.

                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  type TeamIssueTemplateOptions = {
                                                                                                                                                      title?: string;
                                                                                                                                                      range?: PlayerRange;
                                                                                                                                                      maxItems?: number;
                                                                                                                                                      labels?: string[];
                                                                                                                                                      assignees?: string[];
                                                                                                                                                      issueType?: string;
                                                                                                                                                      projectKey?: string;
                                                                                                                                                      priority?: string;
                                                                                                                                                  }
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  Index
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  + +

                                                                                                                                                  Properties

                                                                                                                                                  title?: string
                                                                                                                                                  range?: PlayerRange
                                                                                                                                                  maxItems?: number
                                                                                                                                                  labels?: string[]
                                                                                                                                                  assignees?: string[]
                                                                                                                                                  issueType?: string
                                                                                                                                                  projectKey?: string
                                                                                                                                                  priority?: string
                                                                                                                                                  +
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  title?: string
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  range?: PlayerRange
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  maxItems?: number
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  labels?: string[]
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  assignees?: string[]
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  issueType?: string
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  projectKey?: string
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  priority?: string
                                                                                                                                                  +
                                                                                                                                                  + +
                                                                                                                                                  +
                                                                                                                                                  diff --git a/docs/api/player-sdk/variables/DEFAULT_ARCHIVE_RESOURCE_LIMITS.html b/docs/api/player-sdk/variables/DEFAULT_ARCHIVE_RESOURCE_LIMITS.html new file mode 100644 index 0000000..a859556 --- /dev/null +++ b/docs/api/player-sdk/variables/DEFAULT_ARCHIVE_RESOURCE_LIMITS.html @@ -0,0 +1,34 @@ +DEFAULT_ARCHIVE_RESOURCE_LIMITS | @webblackbox/player-sdk API
                                                                                                                                                  +
                                                                                                                                                  @webblackbox/player-sdk API + +
                                                                                                                                                    +
                                                                                                                                                    +
                                                                                                                                                    Preparing search index...
                                                                                                                                                    +
                                                                                                                                                    +
                                                                                                                                                    +
                                                                                                                                                    + +

                                                                                                                                                    Variable DEFAULT_ARCHIVE_RESOURCE_LIMITSConst

                                                                                                                                                    +
                                                                                                                                                    DEFAULT_ARCHIVE_RESOURCE_LIMITS: Readonly<ArchiveResourceLimits> = ...
                                                                                                                                                    +

                                                                                                                                                    Safe upper bounds used by archive consumers unless a caller supplies tighter values.

                                                                                                                                                    +
                                                                                                                                                    +
                                                                                                                                                    + +
                                                                                                                                                    +
                                                                                                                                                    diff --git a/package.json b/package.json index 666d8af..3a6a6d2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "bench:pipeline": "pnpm --filter @webblackbox/pipeline bench", "bundle:size": "node scripts/check-bundle-size.mjs", "docs:api": "pnpm --filter @webblackbox/player-sdk docs:api", + "docs:api:check": "node scripts/check-api-docs.mjs", "format": "prettier --write .", "format:check": "prettier --check .", "changeset": "changeset", diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index 19b5224..0bd1628 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -438,7 +438,8 @@ type BlobRef = { mime: string; }; -type PlayerBlob = { +/** Decrypted or plaintext blob content resolved from an archive hash. */ +export type PlayerBlob = { mime: string; bytes: Uint8Array; }; diff --git a/packages/player-sdk/typedoc.json b/packages/player-sdk/typedoc.json index a4c9a03..266fbc8 100644 --- a/packages/player-sdk/typedoc.json +++ b/packages/player-sdk/typedoc.json @@ -8,6 +8,7 @@ "excludeInternal": true, "readme": "README.md", "name": "@webblackbox/player-sdk API", + "gitRevision": "main", "plugin": [], "sort": ["source-order"] } diff --git a/scripts/check-api-docs.mjs b/scripts/check-api-docs.mjs new file mode 100644 index 0000000..ba82482 --- /dev/null +++ b/scripts/check-api-docs.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtemp, readFile, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const packageRoot = join(workspaceRoot, "packages/player-sdk"); +const committedDocsRoot = join(workspaceRoot, "docs/api/player-sdk"); + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); + +async function main() { + const temporaryRoot = await mkdtemp(join(tmpdir(), "webblackbox-api-docs-")); + const generatedDocsRoot = join(temporaryRoot, "player-sdk"); + + try { + const result = spawnSync( + "pnpm", + ["exec", "typedoc", "--options", "typedoc.json", "--out", generatedDocsRoot], + { + cwd: packageRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + } + ); + + if (result.status !== 0) { + throw new Error( + `TypeDoc generation failed.\n${result.stderr || result.stdout || "No output was produced."}` + ); + } + + const committedFiles = await collectFiles(committedDocsRoot); + const generatedFiles = await collectFiles(generatedDocsRoot); + const differences = []; + + for (const path of generatedFiles) { + if (!committedFiles.has(path)) { + differences.push(`missing committed file: ${path}`); + continue; + } + + const [committed, generated] = await Promise.all([ + readFile(join(committedDocsRoot, path)), + readFile(join(generatedDocsRoot, path)) + ]); + if (!committed.equals(generated)) { + differences.push(`changed file: ${path}`); + } + } + + for (const path of committedFiles) { + if (!generatedFiles.has(path)) { + differences.push(`stale committed file: ${path}`); + } + } + + if (differences.length > 0) { + const preview = differences + .slice(0, 30) + .map((entry) => `- ${entry}`) + .join("\n"); + const omitted = Math.max(0, differences.length - 30); + throw new Error( + `Player SDK API documentation is stale. Run 'pnpm docs:api' and commit the result.\n${preview}${omitted > 0 ? `\n- ...and ${omitted} more` : ""}` + ); + } + + console.log(`Player SDK API documentation matches ${generatedFiles.size} generated files.`); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +async function collectFiles(root) { + const files = new Set(); + const directories = [root]; + + while (directories.length > 0) { + const current = directories.pop(); + if (!current) { + continue; + } + + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(current, entry.name); + if (entry.isDirectory()) { + directories.push(fullPath); + } else if (entry.isFile()) { + files.add(relative(root, fullPath)); + } + } + } + + return files; +} From cd76e50631ca636b72fbb3ec83960d53fbba8025 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:14:02 +0800 Subject: [PATCH 057/181] fix(pipeline): bound crash recovery checkpoints --- apps/extension/src/offscreen/index.ts | 18 + apps/extension/src/sw/index.ts | 194 +++- apps/extension/src/sw/runtime-state.test.ts | 118 +++ apps/extension/src/sw/runtime-state.ts | 190 ++++ packages/pipeline/src/index.test.ts | 263 ++++- packages/pipeline/src/pipeline.ts | 349 +++++-- packages/pipeline/src/storage.test.ts | 257 ++++- packages/pipeline/src/storage.ts | 1021 ++++++++++++++++++- 8 files changed, 2248 insertions(+), 162 deletions(-) diff --git a/apps/extension/src/offscreen/index.ts b/apps/extension/src/offscreen/index.ts index 5f26276..10ae008 100644 --- a/apps/extension/src/offscreen/index.ts +++ b/apps/extension/src/offscreen/index.ts @@ -24,6 +24,7 @@ type OffscreenPipelineRequest = { | "putBlob" | "exportDownload" | "close" + | "getScreenRecordingStatus" | "startScreenRecording" | "stopScreenRecording"; sid: string; @@ -225,6 +226,23 @@ async function processPipelineRequest(message: OffscreenPipelineRequest): Promis return pipeline.getResumeState(); } + if (message.op === "getScreenRecordingStatus") { + const recording = screenRecordings.get(message.sid); + + if (!recording) { + return { active: false }; + } + + return { + active: true, + recordingId: recording.recordingId, + mime: recording.mime, + ...(recording.width === undefined ? {} : { width: recording.width }), + ...(recording.height === undefined ? {} : { height: recording.height }), + ...(recording.frameRate === undefined ? {} : { frameRate: recording.frameRate }) + }; + } + if (message.op === "startScreenRecording") { return startOffscreenScreenRecording(message); } diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index ac46fae..0e62a4d 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -109,13 +109,16 @@ import { import { RuntimeCleanupScheduler, RuntimeStartCoordinator, + buildInterruptedScreenRecordingRecoveries, capCleanupDeadline, compactScreenRecordingChunkHashes, createRuntimeStateSnapshot, evaluateActiveRuntimeRestoration, extractPersistedRuntimeIdentities, mergeRuntimeCountersWithSequenceWatermark, + normalizeOffscreenScreenRecordingStatus, parseRuntimeStateSnapshot, + reconcileScreenRecordingResumeState, restoreScreenRecordingChunkHashes, type PersistedActiveRuntime, type PersistedRuntime, @@ -274,6 +277,7 @@ type OffscreenPipelineRequest = { | "putBlob" | "exportDownload" | "close" + | "getScreenRecordingStatus" | "startScreenRecording" | "stopScreenRecording"; sid: string; @@ -1090,6 +1094,9 @@ async function startSessionUnlocked( await attachCdp(runtime); } + // Make the owning session durable before an offscreen MediaRecorder can outlive this worker. + await persistRuntimeState(); + if (shouldStartScreenRecording(runtime)) { try { await startScreenRecording(runtime); @@ -4337,22 +4344,30 @@ function normalizePipelineResumeState(raw: unknown): PipelineResumeState { const chunk = asRecord(chunkCandidate); const index = chunk?.index; const hash = chunk?.hash; + const size = chunk?.size; + const chunkKeys = chunk ? Object.keys(chunk) : []; if ( !chunk || - Object.keys(chunk).length !== 2 || + (chunkKeys.length !== 2 && chunkKeys.length !== 3) || + (chunkKeys.length === 3 && (!chunkKeys.includes("size") || size === undefined)) || !Number.isSafeInteger(index) || (index as number) < 0 || (index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS || indexes.has(index as number) || typeof hash !== "string" || - !/^[a-f0-9]{64}$/u.test(hash) + !/^[a-f0-9]{64}$/u.test(hash) || + (size !== undefined && (!Number.isSafeInteger(size) || (size as number) < 0)) ) { throw new Error("Invalid offscreen screen-recording chunk reference."); } indexes.add(index as number); - chunks.push({ index: index as number, hash }); + chunks.push({ + index: index as number, + hash, + ...(size === undefined ? {} : { size: size as number }) + }); } screenRecordings.push({ @@ -5789,23 +5804,83 @@ async function restoreActiveRuntime( persisted.counters, resumeState.sequenceWatermark ); - const screenRecordingChunks = persisted.screenRecording + const screenRecordingResume = persisted.screenRecording ? resumeState.screenRecordings.find( (recording) => recording.recordingId === persisted.screenRecording?.recordingId - )?.chunks + ) : undefined; + const screenRecordingStatus = normalizeOffscreenScreenRecordingStatus( + await requestOffscreenPipeline({ + op: "getScreenRecordingStatus", + sid: persisted.sid + }) + ); - if ( + const reconciledScreenRecording = reconcileScreenRecordingResumeState( + persisted.screenRecording, + screenRecordingResume + ); + const screenRecordingAllowedByCurrentPolicy = + persisted.mode === "full" && config.capturePolicy?.categories.screenRecordings === "allow"; + const recorderSurvived = Boolean( + screenRecordingAllowedByCurrentPolicy && persisted.screenRecording && - persisted.screenRecording.chunkCount > 0 && - !hasCompleteScreenRecordingResumePrefix( - persisted.screenRecording.chunkCount, - screenRecordingChunks ?? [] - ) - ) { - throw new Error("Screen-recording resume state is incomplete."); + screenRecordingStatus.active && + screenRecordingStatus.recordingId === persisted.screenRecording.recordingId && + reconciledScreenRecording.screenRecording + ); + let policyStoppedRecordingId: string | undefined; + + if (screenRecordingStatus.active && !screenRecordingAllowedByCurrentPolicy) { + await requestOffscreenPipeline({ + op: "stopScreenRecording", + sid: persisted.sid, + recordingId: screenRecordingStatus.recordingId, + reason: "policy-disabled-after-restart" + }); + policyStoppedRecordingId = screenRecordingStatus.recordingId; } + const statusResume = screenRecordingStatus.active + ? resumeState.screenRecordings.find( + (recording) => recording.recordingId === screenRecordingStatus.recordingId + ) + : undefined; + const provisionalPersistedScreenRecording = + screenRecordingStatus.active && screenRecordingAllowedByCurrentPolicy && !recorderSurvived + ? screenRecordingStatus.recordingId === persisted.screenRecording?.recordingId + ? persisted.screenRecording + : { + recordingId: screenRecordingStatus.recordingId, + startedAt: Date.now(), + startedMono: monotonicTime(), + mime: screenRecordingStatus.mime, + width: screenRecordingStatus.width, + height: screenRecordingStatus.height, + frameRate: screenRecordingStatus.frameRate, + chunkCount: 0, + sizeBytes: 0 + } + : undefined; + const provisionalScreenRecording = reconcileScreenRecordingResumeState( + provisionalPersistedScreenRecording, + statusResume + ); + const screenRecording = recorderSurvived + ? reconciledScreenRecording.screenRecording + : provisionalPersistedScreenRecording + ? (provisionalScreenRecording.screenRecording ?? { + ...provisionalPersistedScreenRecording, + chunkCount: 0, + sizeBytes: 0 + }) + : undefined; + const screenRecordingChunks = recorderSurvived + ? reconciledScreenRecording.chunks + : provisionalPersistedScreenRecording + ? (provisionalScreenRecording.chunks ?? []) + : undefined; + const runtime = createSessionRuntime({ sid: persisted.sid, tabId: persisted.tabId, @@ -5823,13 +5898,37 @@ async function restoreActiveRuntime( pipeline, performanceBudget, counters: restoredCounters, - screenRecording: persisted.screenRecording, + screenRecording, screenRecordingChunks }); sessionsByTab.set(runtime.tabId, runtime); sessionsBySid.set(runtime.sid, runtime); scheduleConsentExpiry(runtime); + let recoveredRecordingId = policyStoppedRecordingId; + + if (screenRecordingStatus.active && screenRecordingAllowedByCurrentPolicy && !recorderSurvived) { + const stopped = await requestOffscreenPipeline({ + op: "stopScreenRecording", + sid: persisted.sid, + recordingId: screenRecordingStatus.recordingId, + reason: "resume-state-not-durable" + }); + await finalizeScreenRecording(runtime, stopped); + await flushBufferedPipelineEvents(runtime); + await runtime.pipeline.flush(); + recoveredRecordingId = screenRecordingStatus.recordingId; + } + + const interruptedScreenRecordings = buildInterruptedScreenRecordingRecoveries({ + persisted: persisted.screenRecording, + resumeState, + status: screenRecordingStatus, + claimedRecordingId: + recoveredRecordingId ?? + (recorderSurvived ? persisted.screenRecording?.recordingId : undefined) + }); + if (runtime.mode === "lite") { installLiteWebRequestCapture(); } @@ -5844,22 +5943,67 @@ async function restoreActiveRuntime( } await notifyAuthorizedContentFrames(runtime, true); - return runtime; -} -function hasCompleteScreenRecordingResumePrefix( - expectedChunkCount: number, - chunks: readonly ScreenRecordingChunkReference[] -): boolean { - const indexes = new Set(chunks.map((chunk) => chunk.index)); + if (interruptedScreenRecordings.length > 0) { + for (const interrupted of interruptedScreenRecordings) { + const hashes = [...interrupted.chunks] + .sort((left, right) => left.index - right.index) + .map((chunk) => chunk.hash); + const endedAt = Date.now(); - for (let index = 0; index < expectedChunkCount; index += 1) { - if (!indexes.has(index)) { - return false; + ingestRawEvent({ + source: "system", + rawType: "screen.recording.end", + sid: runtime.sid, + tabId: runtime.tabId, + t: endedAt, + mono: monotonicTime(), + payload: { + recordingId: interrupted.recordingId, + mime: interrupted.mime, + chunks: hashes, + chunkCount: hashes.length, + size: interrupted.sizeBytes, + durationMs: interrupted.startedAt + ? Math.max(0, Math.round(endedAt - interrupted.startedAt)) + : 0, + width: interrupted.width, + height: interrupted.height, + reason: "offscreen-restart" + } + }); + ingestRawEvent({ + source: "system", + rawType: "screen.recording.error", + sid: runtime.sid, + tabId: runtime.tabId, + t: endedAt, + mono: monotonicTime(), + payload: { + recordingId: interrupted.recordingId, + name: "OffscreenRecordingInterrupted", + message: "Screen recording was interrupted while the extension runtime restarted.", + stage: "restart" + } + }); } + + await flushBufferedPipelineEvents(runtime); + await runtime.pipeline.flush(); } - return true; + if (!runtime.screenRecording && shouldStartScreenRecording(runtime)) { + await startScreenRecording(runtime).catch((error) => { + console.warn("[WebBlackbox] failed to restart screen recording", { + sid: runtime.sid, + error: error instanceof Error ? error.message : String(error) + }); + }); + await flushBufferedPipelineEvents(runtime); + await runtime.pipeline.flush(); + } + + return runtime; } async function restoreStoppedRuntime( diff --git a/apps/extension/src/sw/runtime-state.test.ts b/apps/extension/src/sw/runtime-state.test.ts index 7dfb1a2..26d8fb7 100644 --- a/apps/extension/src/sw/runtime-state.test.ts +++ b/apps/extension/src/sw/runtime-state.test.ts @@ -5,13 +5,17 @@ import { describe, expect, it, vi } from "vitest"; import { RuntimeCleanupScheduler, RuntimeStartCoordinator, + buildInterruptedScreenRecordingRecoveries, capCleanupDeadline, createRuntimeCleanupAlarmName, createRuntimeStateSnapshot, compactScreenRecordingChunkHashes, + deriveDurableScreenRecordingChunkCount, evaluateActiveRuntimeRestoration, mergeRuntimeCountersWithSequenceWatermark, + normalizeOffscreenScreenRecordingStatus, parseRuntimeStateSnapshot, + reconcileScreenRecordingResumeState, restoreScreenRecordingChunkHashes, type PersistedActiveRuntime, type PersistedRuntime @@ -207,6 +211,120 @@ describe("runtime state checkpoint", () => { expect(serialized).not.toContain(hashAfterRestart); expect(serialized).not.toContain('"chunks"'); }); + + it("reconciles an ahead-of-pipeline MV3 chunk count to durable references", () => { + expect( + deriveDurableScreenRecordingChunkCount([ + { index: 0, hash: "a".repeat(64) }, + { index: 2, hash: "b".repeat(64) } + ]) + ).toBe(3); + expect(deriveDurableScreenRecordingChunkCount([])).toBe(0); + expect(() => + deriveDurableScreenRecordingChunkCount([{ index: -1, hash: "c".repeat(64) }]) + ).toThrow(/durable.*range/i); + + const persisted = { + recordingId: "VR-reconcile", + startedAt: 1, + startedMono: 1, + mime: "video/webm", + chunkCount: 4, + sizeBytes: 100 + }; + const reconciled = reconcileScreenRecordingResumeState(persisted, { + recordingId: persisted.recordingId, + chunks: [{ index: 0, hash: "d".repeat(64), size: 10 }] + }).screenRecording; + expect(reconciled?.chunkCount).toBe(1); + expect(reconciled?.sizeBytes).toBe(10); + expect(reconcileScreenRecordingResumeState(persisted, undefined)).toEqual({ + screenRecording: undefined, + chunks: undefined + }); + }); + + it("validates the offscreen recorder liveness handshake", () => { + expect(normalizeOffscreenScreenRecordingStatus({ active: false })).toEqual({ + active: false + }); + expect( + normalizeOffscreenScreenRecordingStatus({ + active: true, + recordingId: "VR-live", + mime: "video/webm" + }) + ).toEqual({ active: true, recordingId: "VR-live", mime: "video/webm" }); + expect(() => + normalizeOffscreenScreenRecordingStatus({ active: true, recordingId: "", extra: true }) + ).toThrow(/invalid offscreen/i); + }); + + it("preserves durable partial video segments when recorder liveness is lost", () => { + const persisted = { + recordingId: "VR-partial", + startedAt: 10, + startedMono: 10, + mime: "video/webm", + chunkCount: 2, + sizeBytes: 99 + }; + const resumeState = { + sequenceWatermark: { event: 2, action: 0 }, + screenRecordings: [ + { + recordingId: persisted.recordingId, + chunks: [ + { index: 0, hash: "a".repeat(64), size: 11 }, + { index: 1, hash: "b".repeat(64), size: 13 } + ] + } + ] + }; + + expect( + buildInterruptedScreenRecordingRecoveries({ + persisted, + resumeState, + status: { active: false }, + claimedRecordingId: undefined + }) + ).toEqual([ + expect.objectContaining({ + recordingId: persisted.recordingId, + mime: "video/webm", + sizeBytes: 24, + chunks: resumeState.screenRecordings[0]?.chunks + }) + ]); + expect( + buildInterruptedScreenRecordingRecoveries({ + persisted, + resumeState, + status: { + active: true, + recordingId: persisted.recordingId, + mime: "video/webm" + }, + claimedRecordingId: persisted.recordingId + }) + ).toEqual([]); + expect( + buildInterruptedScreenRecordingRecoveries({ + persisted: undefined, + resumeState: { sequenceWatermark: { event: 0, action: 0 }, screenRecordings: [] }, + status: { active: true, recordingId: "VR-unclaimed", mime: "video/webm" }, + claimedRecordingId: undefined + }) + ).toEqual([ + expect.objectContaining({ + recordingId: "VR-unclaimed", + mime: "video/webm", + chunks: [], + sizeBytes: 0 + }) + ]); + }); }); describe("MV3 cleanup scheduling", () => { diff --git a/apps/extension/src/sw/runtime-state.ts b/apps/extension/src/sw/runtime-state.ts index 5b7b126..6435b4f 100644 --- a/apps/extension/src/sw/runtime-state.ts +++ b/apps/extension/src/sw/runtime-state.ts @@ -1,3 +1,4 @@ +import type { PipelineResumeState } from "@webblackbox/pipeline"; import { evaluateCaptureScope, recorderConfigSchema, @@ -94,6 +95,29 @@ export type RuntimeSequenceWatermark = { export type RuntimeScreenRecordingChunkReference = { index: number; hash: string; + size?: number; +}; + +export type RuntimeOffscreenScreenRecordingStatus = + | { active: false } + | { + active: true; + recordingId: string; + mime: string; + width?: number; + height?: number; + frameRate?: number; + }; + +export type InterruptedScreenRecordingRecovery = { + recordingId: string; + mime: string; + chunks: readonly RuntimeScreenRecordingChunkReference[]; + sizeBytes: number; + startedAt?: number; + width?: number; + height?: number; + frameRate?: number; }; export type RuntimeCleanupSchedulerOptions = { @@ -299,6 +323,172 @@ export function restoreScreenRecordingChunkHashes( return chunks; } +/** Pipeline references are the durable source of truth when a debounced MV3 snapshot is ahead. */ +export function deriveDurableScreenRecordingChunkCount( + references: readonly RuntimeScreenRecordingChunkReference[] +): number { + if ( + references.some( + (reference) => + !Number.isSafeInteger(reference.index) || + reference.index < 0 || + reference.index >= MAX_SCREEN_RECORDING_CHUNKS + ) + ) { + throw new Error("Invalid durable screen-recording chunk range."); + } + + return references.reduce((count, reference) => Math.max(count, reference.index + 1), 0); +} + +export function reconcileScreenRecordingResumeState( + persisted: PersistedScreenRecording | undefined, + durable: + | { + recordingId: string; + chunks: readonly RuntimeScreenRecordingChunkReference[]; + } + | undefined +): { + screenRecording: PersistedScreenRecording | undefined; + chunks: readonly RuntimeScreenRecordingChunkReference[] | undefined; +} { + if (!persisted || !durable || durable.recordingId !== persisted.recordingId) { + return { + screenRecording: undefined, + chunks: undefined + }; + } + + return { + screenRecording: { + ...persisted, + chunkCount: deriveDurableScreenRecordingChunkCount(durable.chunks), + sizeBytes: deriveDurableScreenRecordingSize(persisted, durable.chunks) + }, + chunks: durable.chunks + }; +} + +export function normalizeOffscreenScreenRecordingStatus( + raw: unknown +): RuntimeOffscreenScreenRecordingStatus { + const row = asRecord(raw); + + if (!row || typeof row.active !== "boolean") { + throw new Error("Invalid offscreen screen-recording status."); + } + + if (!row.active && Object.keys(row).length === 1) { + return { active: false }; + } + + if ( + row.active && + Object.keys(row).every((key) => + ["active", "recordingId", "mime", "width", "height", "frameRate"].includes(key) + ) && + typeof row.recordingId === "string" && + row.recordingId.length > 0 && + row.recordingId.length <= 256 && + typeof row.mime === "string" && + row.mime.length > 0 && + row.mime.length <= 256 && + isOptionalPositiveNumber(row.width) && + isOptionalPositiveNumber(row.height) && + isOptionalPositiveNumber(row.frameRate) + ) { + return { + active: true, + recordingId: row.recordingId, + mime: row.mime, + ...(row.width === undefined ? {} : { width: row.width as number }), + ...(row.height === undefined ? {} : { height: row.height as number }), + ...(row.frameRate === undefined ? {} : { frameRate: row.frameRate as number }) + }; + } + + throw new Error("Invalid offscreen screen-recording status."); +} + +export function buildInterruptedScreenRecordingRecoveries(input: { + persisted: PersistedScreenRecording | undefined; + resumeState: PipelineResumeState; + status: RuntimeOffscreenScreenRecordingStatus; + claimedRecordingId: string | undefined; +}): InterruptedScreenRecordingRecovery[] { + const recordingIds = new Set( + input.resumeState.screenRecordings + .map((recording) => recording.recordingId) + .filter((recordingId) => recordingId !== input.claimedRecordingId) + ); + + if (input.persisted && input.persisted.recordingId !== input.claimedRecordingId) { + recordingIds.add(input.persisted.recordingId); + } + + if (input.status.active && input.status.recordingId !== input.claimedRecordingId) { + recordingIds.add(input.status.recordingId); + } + + return [...recordingIds] + .sort((left, right) => left.localeCompare(right)) + .map((recordingId) => { + const durable = input.resumeState.screenRecordings.find( + (recording) => recording.recordingId === recordingId + ); + const persisted = input.persisted?.recordingId === recordingId ? input.persisted : undefined; + const status = + input.status.active && input.status.recordingId === recordingId ? input.status : undefined; + const reconciled = reconcileScreenRecordingResumeState(persisted, durable); + const chunks = durable?.chunks ?? []; + const knownSize = chunks.reduce( + (total, chunk) => Math.min(Number.MAX_SAFE_INTEGER, total + (chunk.size ?? 0)), + 0 + ); + + return { + recordingId, + mime: persisted?.mime ?? status?.mime ?? "video/webm", + chunks, + sizeBytes: reconciled.screenRecording?.sizeBytes ?? knownSize, + ...(persisted ? { startedAt: persisted.startedAt } : {}), + ...((persisted?.width ?? status?.width) + ? { width: persisted?.width ?? status?.width } + : {}), + ...((persisted?.height ?? status?.height) + ? { height: persisted?.height ?? status?.height } + : {}), + ...((persisted?.frameRate ?? status?.frameRate) + ? { frameRate: persisted?.frameRate ?? status?.frameRate } + : {}) + }; + }); +} + +function deriveDurableScreenRecordingSize( + persisted: PersistedScreenRecording, + references: readonly RuntimeScreenRecordingChunkReference[] +): number { + const knownSize = references.reduce( + (total, reference) => Math.min(Number.MAX_SAFE_INTEGER, total + (reference.size ?? 0)), + 0 + ); + + if (references.every((reference) => reference.size !== undefined)) { + return knownSize; + } + + const indexes = new Set(references.map((reference) => reference.index)); + let snapshotMatchesDurablePrefix = references.length === persisted.chunkCount; + + for (let index = 0; snapshotMatchesDurablePrefix && index < persisted.chunkCount; index += 1) { + snapshotMatchesDurablePrefix = indexes.has(index); + } + + return snapshotMatchesDurablePrefix ? persisted.sizeBytes : knownSize; +} + export function compactScreenRecordingChunkHashes(chunks: readonly string[]): string[] { return chunks.filter((chunk) => typeof chunk === "string" && chunk.length > 0); } diff --git a/packages/pipeline/src/index.test.ts b/packages/pipeline/src/index.test.ts index 27207a7..0dc623d 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -9,7 +9,7 @@ import { type WebBlackboxEvent } from "@webblackbox/protocol"; -import { decodeChunkEvents } from "./codec.js"; +import { decodeChunkEvents, encodeChunkEvents } from "./codec.js"; import { readWebBlackboxArchive } from "./exporter.js"; import { FlightRecorderPipeline, type FlightRecorderPipelineOptions } from "./pipeline.js"; import { @@ -68,6 +68,16 @@ class FailOnceChunkStorage extends MemoryPipelineStorage { } } +class CheckpointBlindStorage extends MemoryPipelineStorage { + public override async getResumeState(): Promise { + return undefined; + } + + public override async initializeResumeState(): Promise { + // Simulates a compatible third-party storage implementation without normalized indexes. + } +} + function createEvent( id: string, type: WebBlackboxEvent["type"], @@ -287,7 +297,7 @@ describe("pipeline", () => { const chunks = await storage.listChunks(SESSION.sid); expect(chunks.map((chunk) => chunk.meta.seq)).toEqual([1, 2]); - expect(chunks[1]?.resumeState?.sequenceWatermark).toEqual({ event: 52, action: 7 }); + expect(chunks[1]?.resumeDelta?.sequenceWatermark).toEqual({ event: 52, action: 7 }); expect(archive.events.map((event) => event.id)).toEqual(["E-00000051", "E-00000052"]); expect(new Set(archive.events.map((event) => event.id)).size).toBe(archive.events.length); }); @@ -319,7 +329,7 @@ describe("pipeline", () => { screenRecordings: [ { recordingId: "VR-pending", - chunks: [{ index: 0, hash }] + chunks: [{ index: 0, hash, size: 10 }] } ] }); @@ -363,6 +373,253 @@ describe("pipeline", () => { }); }); + it("stores constant-size resume deltas and prunes completed recording references", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + + await pipeline.start(); + await pipeline.ingest( + createEvent("E-00000001", "screen.recording.start", 0, { + recordingId: "VR-bounded" + }) + ); + expect((await pipeline.getResumeState()).screenRecordings).toEqual([ + { recordingId: "VR-bounded", chunks: [] } + ]); + + for (let index = 0; index < 100; index += 1) { + await pipeline.ingest( + createEvent(`E-${String(index + 2).padStart(8, "0")}`, "screen.recording.chunk", index, { + recordingId: "VR-bounded", + chunkId: index.toString(16).padStart(64, "0"), + index + }) + ); + } + + const chunks = await storage.listChunks(SESSION.sid); + const checkpointSizes = chunks.map((chunk) => JSON.stringify(chunk.resumeDelta).length); + + expect(chunks).toHaveLength(101); + expect(chunks.every((chunk) => chunk.resumeState === undefined)).toBe(true); + expect(Math.max(...checkpointSizes)).toBeLessThan(300); + expect((await pipeline.getResumeState()).screenRecordings[0]?.chunks).toHaveLength(100); + + await pipeline.ingest( + createEvent("E-00000102", "screen.recording.end", 101, { + recordingId: "VR-bounded" + }) + ); + expect((await pipeline.getResumeState()).screenRecordings).toEqual([]); + await pipeline.close(); + }); + + it("rejects an over-limit live resume state before buffering the event", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1_000_000 + }); + + await pipeline.start(); + + for (let index = 0; index < 32; index += 1) { + await pipeline.ingest( + createEvent(`E-${String(index + 1).padStart(8, "0")}`, "screen.recording.start", index, { + recordingId: `VR-live-${index}` + }) + ); + } + + await expect( + pipeline.ingest( + createEvent("E-00000033", "screen.recording.start", 33, { + recordingId: "VR-live-overflow" + }) + ) + ).rejects.toThrow(/too many active screen recordings/i); + expect((await pipeline.getResumeState()).screenRecordings).toHaveLength(32); + await pipeline.close(); + expect((await storage.listChunks(SESSION.sid))[0]?.meta.eventCount).toBe(32); + }); + + it("rebuilds resume deltas for compatible storage without normalized indexes", async () => { + const storage = new CheckpointBlindStorage(); + const first = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + const firstHash = "a".repeat(64); + const secondHash = "b".repeat(64); + + await first.start(); + await first.ingest( + createEvent("E-00000201", "screen.recording.chunk", 1, { + recordingId: "VR-compatible", + chunkId: firstHash, + index: 0 + }) + ); + await first.ingest( + createEvent("E-00000202", "screen.recording.chunk", 2, { + recordingId: "VR-compatible", + chunkId: secondHash, + index: 1 + }) + ); + await first.close(); + + const restored = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await restored.start(); + expect(await restored.getResumeState()).toEqual({ + sequenceWatermark: { event: 202, action: 0 }, + screenRecordings: [ + { + recordingId: "VR-compatible", + chunks: [ + { index: 0, hash: firstHash }, + { index: 1, hash: secondHash } + ] + } + ] + }); + await restored.close(); + }); + + it("applies recording lifecycle events while migrating a cumulative checkpoint", async () => { + const storage = new MemoryPipelineStorage(); + const hash = "f".repeat(64); + const ended = createEvent("E-00000301", "screen.recording.end", 3, { + recordingId: "VR-legacy-ended" + }); + const encoded = await encodeChunkEvents([ended], "none"); + + await storage.putSession(SESSION); + await storage.putChunk({ + sid: SESSION.sid, + meta: { + chunkId: "C-00000001", + seq: 1, + tStart: ended.t, + tEnd: ended.t, + monoStart: ended.mono, + monoEnd: ended.mono, + eventCount: 1, + byteLength: encoded.bytes.byteLength, + codec: encoded.codec, + sha256: "0".repeat(64) + }, + bytes: encoded.bytes, + resumeState: { + sequenceWatermark: { event: 301, action: 0 }, + screenRecordings: [ + { + recordingId: "VR-legacy-ended", + chunks: [{ index: 0, hash }] + } + ] + } + }); + + const restored = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await restored.start(); + expect(await restored.getResumeState()).toEqual({ + sequenceWatermark: { event: 301, action: 0 }, + screenRecordings: [] + }); + await restored.close(); + }); + + it("replays lifecycle events from legacy checkpoints before later deltas", async () => { + const storage = new CheckpointBlindStorage(); + const hash = "e".repeat(64); + const ended = createEvent("E-00000311", "screen.recording.end", 3, { + recordingId: "VR-mixed-ended" + }); + const encoded = await encodeChunkEvents([ended], "none"); + + await storage.putSession(SESSION); + await storage.putChunk({ + sid: SESSION.sid, + meta: { + chunkId: "C-00000001", + seq: 1, + tStart: ended.t, + tEnd: ended.t, + monoStart: ended.mono, + monoEnd: ended.mono, + eventCount: 1, + byteLength: encoded.bytes.byteLength, + codec: encoded.codec, + sha256: "1".repeat(64) + }, + bytes: encoded.bytes, + resumeState: { + sequenceWatermark: { event: 311, action: 0 }, + screenRecordings: [ + { + recordingId: "VR-mixed-ended", + chunks: [{ index: 0, hash }] + } + ] + } + }); + await storage.putChunk({ + sid: SESSION.sid, + meta: { + chunkId: "C-00000002", + seq: 2, + tStart: 4, + tEnd: 4, + monoStart: 4, + monoEnd: 4, + eventCount: 0, + byteLength: 0, + codec: "none", + sha256: "2".repeat(64) + }, + bytes: new Uint8Array(), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 312, action: 0 }, + screenRecordingChanges: [] + } + }); + + const restored = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await restored.start(); + expect(await restored.getResumeState()).toEqual({ + sequenceWatermark: { event: 312, action: 0 }, + screenRecordings: [] + }); + await restored.close(); + }); + + it("closes a durable recording checkpoint after a restart interruption", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + + await pipeline.start(); + await pipeline.ingest( + createEvent("E-00000401", "screen.recording.start", 1, { + recordingId: "VR-interrupted" + }) + ); + await pipeline.ingest( + createEvent("E-00000402", "screen.recording.error", 2, { + recordingId: "VR-interrupted", + name: "OffscreenRecordingInterrupted", + message: "runtime restarted", + stage: "restart" + }) + ); + expect((await pipeline.getResumeState()).screenRecordings).toEqual([]); + await pipeline.close(); + + const restored = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await restored.start(); + expect((await restored.getResumeState()).screenRecordings).toEqual([]); + await restored.close(); + }); + it("can retry an ingest after chunk persistence fails without a sequence gap or duplicate", async () => { const storage = new FailOnceChunkStorage(); const pipeline = createTestPipeline({ diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index d0c2718..c567faa 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -19,10 +19,16 @@ import { sha256Hex } from "./hash.js"; import { EventIndexer } from "./indexer.js"; import { assertPrivacyScannerPassed, buildPrivacyManifest } from "./privacy.js"; import { + assertPipelineResumeChangesWithinLimits, + isPipelineResumeDelta, + isPipelineResumeState, PIPELINE_STORAGE_SECURITY, + type PipelineResumeDelta, type PipelineResumeState, type PipelineStorage, type RecorderSequenceWatermark, + type ScreenRecordingChunkReference, + type ScreenRecordingResumeChange, type ScreenRecordingResumeState, type StoredBlob, type StoredChunk @@ -66,6 +72,8 @@ type PreparedExportChunk = { blobHashes: string[]; }; +type ScreenRecordingChunkCheckpoint = Omit; + type ExportIndexes = { time: ReturnType["time"]; request: RequestIndexEntry[]; @@ -112,7 +120,10 @@ export class FlightRecorderPipeline { private acceptingEvents = true; private closePromise: Promise | null = null; private sequenceWatermark: RecorderSequenceWatermark = { event: 0, action: 0 }; - private readonly screenRecordingChunks = new Map>(); + private readonly screenRecordingChunks = new Map< + string, + Map + >(); public constructor(private readonly options: FlightRecorderPipelineOptions) { const codec = resolveChunkCodec(options.chunkCodec); @@ -136,10 +147,19 @@ export class FlightRecorderPipeline { this.chunker.restoreSequence(lastSequence); const resumeState = latestMeta - ? await this.restoreResumeStateFromLatestChunk(latestMeta.chunkId) + ? await this.restoreResumeStateFromLatestChunk(latestMeta.chunkId, latestMeta.seq) : emptyPipelineResumeState(); this.sequenceWatermark = resumeState.sequenceWatermark; this.restoreScreenRecordingResumeState(resumeState.screenRecordings); + + if (latestMeta) { + await this.options.storage.initializeResumeState?.( + this.options.session.sid, + latestMeta.seq, + resumeState + ); + } + await this.options.storage.putSession(this.options.session); } @@ -150,6 +170,7 @@ export class FlightRecorderPipeline { } await this.enqueueChunkOperation(async () => { + this.assertEventResumeStateWithinLimits(event); await this.chunker.append(event); this.applyEventToResumeState(event); }); @@ -170,6 +191,7 @@ export class FlightRecorderPipeline { await this.enqueueChunkOperation(async () => { for (const event of events) { + this.assertEventResumeStateWithinLimits(event); await this.chunker.append(event); this.applyEventToResumeState(event); } @@ -715,13 +737,29 @@ export class FlightRecorderPipeline { sha256: hash }, bytes, - resumeState: derivePipelineResumeState(this.snapshotResumeState(), events) + resumeDelta: derivePipelineResumeDelta(this.sequenceWatermark, events) }; await this.options.storage.putChunk(chunk); } - private async restoreResumeStateFromLatestChunk(chunkId: string): Promise { + private async restoreResumeStateFromLatestChunk( + chunkId: string, + chunkSequence: number + ): Promise { + const normalized = await this.options.storage.getResumeState?.( + this.options.session.sid, + chunkSequence + ); + + if (normalized !== undefined) { + if (!isPipelineResumeState(normalized)) { + throw new Error("Pipeline storage returned an invalid resume state."); + } + + return clonePipelineResumeState(normalized); + } + const latest = await this.options.storage.getChunk(this.options.session.sid, chunkId); if (!latest) { @@ -729,7 +767,16 @@ export class FlightRecorderPipeline { } if (isPipelineResumeState(latest.resumeState)) { - return clonePipelineResumeState(latest.resumeState); + const checkpoint = clonePipelineResumeState(latest.resumeState); + const events = await decodeChunkEvents(latest.bytes, latest.meta.codec); + return applyPipelineResumeDelta( + checkpoint, + derivePipelineResumeDelta(checkpoint.sequenceWatermark, events) + ); + } + + if (isPipelineResumeDelta(latest.resumeDelta)) { + return this.rebuildResumeStateFromChunkCheckpoints(); } const events = await decodeChunkEvents(latest.bytes, latest.meta.codec); @@ -737,7 +784,11 @@ export class FlightRecorderPipeline { // Legacy chunks did not persist an exact action high-water. Advancing the // action sequence to the durable event high-water is deterministic and // prevents reuse without scanning an unbounded archive history. - const legacy = derivePipelineResumeState(emptyPipelineResumeState(), events); + const empty = emptyPipelineResumeState(); + const legacy = applyPipelineResumeDelta( + empty, + derivePipelineResumeDelta(empty.sequenceWatermark, events) + ); legacy.sequenceWatermark.action = Math.max( legacy.sequenceWatermark.action, legacy.sequenceWatermark.event @@ -745,12 +796,62 @@ export class FlightRecorderPipeline { return legacy; } + private async rebuildResumeStateFromChunkCheckpoints(): Promise { + const chunks = await this.options.storage.listChunks(this.options.session.sid); + let sequenceWatermark: RecorderSequenceWatermark = { event: 0, action: 0 }; + let recordings = new Map>(); + + for (const chunk of chunks.sort((left, right) => left.meta.seq - right.meta.seq)) { + if (isPipelineResumeState(chunk.resumeState)) { + sequenceWatermark = mergeSequenceWatermarks( + sequenceWatermark, + chunk.resumeState.sequenceWatermark + ); + recordings = screenRecordingCheckpointMapFromState(chunk.resumeState); + const events = await decodeChunkEvents(chunk.bytes, chunk.meta.codec); + const replay = derivePipelineResumeDelta(sequenceWatermark, events); + assertPipelineResumeChangesWithinLimits(recordings, replay.screenRecordingChanges); + sequenceWatermark = mergeSequenceWatermarks(sequenceWatermark, replay.sequenceWatermark); + + for (const change of replay.screenRecordingChanges) { + applyScreenRecordingResumeChange(recordings, change); + } + continue; + } + + if (isPipelineResumeDelta(chunk.resumeDelta)) { + assertPipelineResumeChangesWithinLimits( + recordings, + chunk.resumeDelta.screenRecordingChanges + ); + sequenceWatermark = mergeSequenceWatermarks( + sequenceWatermark, + chunk.resumeDelta.sequenceWatermark + ); + + for (const change of chunk.resumeDelta.screenRecordingChanges) { + applyScreenRecordingResumeChange(recordings, change); + } + } + } + + return createPipelineResumeStateFromCheckpoints(sequenceWatermark, recordings); + } + private applyEventToResumeState(event: WebBlackboxEvent): void { this.sequenceWatermark = mergeSequenceWatermarks( this.sequenceWatermark, deriveRecorderSequenceWatermark([event]) ); - applyScreenRecordingChunkEvent(this.screenRecordingChunks, event); + applyScreenRecordingEvent(this.screenRecordingChunks, event); + } + + private assertEventResumeStateWithinLimits(event: WebBlackboxEvent): void { + const change = readScreenRecordingResumeChange(event); + + if (change) { + assertPipelineResumeChangesWithinLimits(this.screenRecordingChunks, [change]); + } } private snapshotResumeState(): PipelineResumeState { @@ -760,7 +861,7 @@ export class FlightRecorderPipeline { .map(([recordingId, chunks]) => ({ recordingId, chunks: [...chunks.entries()] - .map(([index, hash]) => ({ index, hash })) + .map(([index, checkpoint]) => ({ index, ...checkpoint })) .sort((left, right) => left.index - right.index) })) .sort((left, right) => left.recordingId.localeCompare(right.recordingId)) @@ -775,7 +876,15 @@ export class FlightRecorderPipeline { for (const recording of screenRecordings) { this.screenRecordingChunks.set( recording.recordingId, - new Map(recording.chunks.map((chunk) => [chunk.index, chunk.hash])) + new Map( + recording.chunks.map((chunk) => [ + chunk.index, + { + hash: chunk.hash, + ...(chunk.size === undefined ? {} : { size: chunk.size }) + } + ]) + ) ); } } @@ -1111,143 +1220,181 @@ function mergeSequenceWatermarks( }; } -function derivePipelineResumeState( - base: PipelineResumeState, +function derivePipelineResumeDelta( + base: RecorderSequenceWatermark, events: readonly WebBlackboxEvent[] -): PipelineResumeState { - const screenRecordingChunks = new Map>(); - - for (const recording of base.screenRecordings) { - screenRecordingChunks.set( - recording.recordingId, - new Map(recording.chunks.map((chunk) => [chunk.index, chunk.hash])) - ); - } - - let sequenceWatermark = { ...base.sequenceWatermark }; +): PipelineResumeDelta { + let sequenceWatermark = { ...base }; + const screenRecordingChanges: ScreenRecordingResumeChange[] = []; for (const event of events) { sequenceWatermark = mergeSequenceWatermarks( sequenceWatermark, deriveRecorderSequenceWatermark([event]) ); - applyScreenRecordingChunkEvent(screenRecordingChunks, event); + const change = readScreenRecordingResumeChange(event); + + if (change) { + screenRecordingChanges.push(change); + } } return { + version: 1, sequenceWatermark, + screenRecordingChanges + }; +} + +function applyPipelineResumeDelta( + base: PipelineResumeState, + delta: PipelineResumeDelta +): PipelineResumeState { + const screenRecordingChunks = screenRecordingCheckpointMapFromState(base); + assertPipelineResumeChangesWithinLimits(screenRecordingChunks, delta.screenRecordingChanges); + + for (const change of delta.screenRecordingChanges) { + applyScreenRecordingResumeChange(screenRecordingChunks, change); + } + + return createPipelineResumeStateFromCheckpoints(delta.sequenceWatermark, screenRecordingChunks); +} + +function screenRecordingCheckpointMapFromState( + state: PipelineResumeState +): Map> { + return new Map( + state.screenRecordings.map((recording) => [ + recording.recordingId, + new Map( + recording.chunks.map((chunk) => [ + chunk.index, + { + hash: chunk.hash, + ...(chunk.size === undefined ? {} : { size: chunk.size }) + } + ]) + ) + ]) + ); +} + +function createPipelineResumeStateFromCheckpoints( + sequenceWatermark: RecorderSequenceWatermark, + screenRecordingChunks: Map> +): PipelineResumeState { + return { + sequenceWatermark: { ...sequenceWatermark }, screenRecordings: [...screenRecordingChunks.entries()] .map(([recordingId, chunks]) => ({ recordingId, chunks: [...chunks.entries()] - .map(([index, hash]) => ({ index, hash })) + .map(([index, checkpoint]) => ({ index, ...checkpoint })) .sort((left, right) => left.index - right.index) })) .sort((left, right) => left.recordingId.localeCompare(right.recordingId)) }; } -function applyScreenRecordingChunkEvent( - output: Map>, +function applyScreenRecordingEvent( + output: Map>, event: WebBlackboxEvent ): void { - if (event.type !== "screen.recording.chunk") { + const change = readScreenRecordingResumeChange(event); + + if (!change) { return; } - const data = asUnknownRecord(event.data); - const recordingId = data?.recordingId; - const hash = data?.chunkId; - const index = data?.index; + applyScreenRecordingResumeChange(output, change); +} - if ( - typeof recordingId !== "string" || - recordingId.length === 0 || - recordingId.length > 256 || - typeof hash !== "string" || - !BLOB_HASH_PATTERN.test(hash) || - !Number.isSafeInteger(index) || - (index as number) < 0 || - (index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS - ) { +function applyScreenRecordingResumeChange( + output: Map>, + change: ScreenRecordingResumeChange +): void { + if (change.operation === "delete") { + output.delete(change.recordingId); return; } - const chunks = output.get(recordingId) ?? new Map(); - chunks.set(index as number, hash); - output.set(recordingId, chunks); -} - -function isPipelineResumeState(value: unknown): value is PipelineResumeState { - if (!value || typeof value !== "object" || Array.isArray(value)) { - return false; + if (change.operation === "reset") { + output.set(change.recordingId, new Map()); + return; } - const record = value as Record; - const sequence = asUnknownRecord(record.sequenceWatermark); + const chunks = + output.get(change.recordingId) ?? new Map(); + chunks.set(change.index, { + hash: change.hash, + ...(change.size === undefined ? {} : { size: change.size }) + }); + output.set(change.recordingId, chunks); +} +function readScreenRecordingResumeChange( + event: WebBlackboxEvent +): ScreenRecordingResumeChange | null { if ( - Object.keys(record).length !== 2 || - !sequence || - Object.keys(sequence).length !== 2 || - !Number.isSafeInteger(sequence.event) || - (sequence.event as number) < 0 || - !Number.isSafeInteger(sequence.action) || - (sequence.action as number) < 0 || - !Array.isArray(record.screenRecordings) || - record.screenRecordings.length > 32 + event.type !== "screen.recording.start" && + event.type !== "screen.recording.chunk" && + event.type !== "screen.recording.end" && + event.type !== "screen.recording.error" ) { - return false; + return null; } - const recordingIds = new Set(); - let totalChunks = 0; + const data = asUnknownRecord(event.data); + const recordingId = data?.recordingId; - for (const candidate of record.screenRecordings) { - const recording = asUnknownRecord(candidate); + if (typeof recordingId !== "string" || recordingId.length === 0 || recordingId.length > 256) { + return null; + } - if ( - !recording || - Object.keys(recording).length !== 2 || - typeof recording.recordingId !== "string" || - recording.recordingId.length === 0 || - recording.recordingId.length > 256 || - recordingIds.has(recording.recordingId) || - !Array.isArray(recording.chunks) - ) { - return false; - } + if (event.type === "screen.recording.start") { + return { + operation: "reset", + recordingId + }; + } - recordingIds.add(recording.recordingId); - totalChunks += recording.chunks.length; + if (event.type === "screen.recording.end") { + return { + operation: "delete", + recordingId + }; + } - if (totalChunks > PIPELINE_RESUME_MAX_SCREEN_CHUNKS) { - return false; - } + if (event.type === "screen.recording.error") { + return data?.stage === "restart" + ? { + operation: "delete", + recordingId + } + : null; + } - const indexes = new Set(); - - for (const chunkCandidate of recording.chunks) { - const chunk = asUnknownRecord(chunkCandidate); - - if ( - !chunk || - Object.keys(chunk).length !== 2 || - !Number.isSafeInteger(chunk.index) || - (chunk.index as number) < 0 || - (chunk.index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS || - indexes.has(chunk.index as number) || - typeof chunk.hash !== "string" || - !BLOB_HASH_PATTERN.test(chunk.hash) - ) { - return false; - } + const hash = data?.chunkId; + const index = data?.index; + const size = data?.size; - indexes.add(chunk.index as number); - } + if ( + typeof hash !== "string" || + !BLOB_HASH_PATTERN.test(hash) || + !Number.isSafeInteger(index) || + (index as number) < 0 || + (index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS + ) { + return null; } - return true; + return { + operation: "put", + recordingId, + index: index as number, + hash, + ...(Number.isSafeInteger(size) && (size as number) >= 0 ? { size: size as number } : {}) + }; } function emptyPipelineResumeState(): PipelineResumeState { diff --git a/packages/pipeline/src/storage.test.ts b/packages/pipeline/src/storage.test.ts index e5941ac..7fe455c 100644 --- a/packages/pipeline/src/storage.test.ts +++ b/packages/pipeline/src/storage.test.ts @@ -4,6 +4,7 @@ import type { ChunkTimeIndexEntry, SessionMetadata } from "@webblackbox/protocol import { describe, expect, it, vi } from "vitest"; import { + assertPipelineResumeChangesWithinLimits, deleteIndexedDbDatabase, derivePipelineStorageKey, EncryptedPipelineStorage, @@ -105,7 +106,60 @@ async function writeRawRows( }); } +async function readRawRows( + db: IDBDatabase, + storeName: string +): Promise>> { + return new Promise((resolve, reject) => { + const request = db.transaction(storeName, "readonly").objectStore(storeName).getAll(); + request.onsuccess = () => resolve(request.result as Array>); + request.onerror = () => reject(request.error ?? new Error("Raw row read failed")); + }); +} + describe("storage", () => { + it("rejects projected resume states beyond recording and chunk limits", async () => { + const tooManyRecordings = Array.from({ length: 33 }, (_, index) => ({ + operation: "reset" as const, + recordingId: `VR-${index}` + })); + + for (const storage of [ + new MemoryPipelineStorage(), + new IndexedDbPipelineStorage(createDbName()) + ]) { + const sid = `S-resume-bounds-${storage.constructor.name}`; + await storage.putSession({ ...SESSION_A, sid }); + await expect( + storage.putChunk({ + ...createChunk(sid, "C-bounds", 1, "bounds"), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 33, action: 0 }, + screenRecordingChanges: tooManyRecordings + } + }) + ).rejects.toThrow(/too many active screen recordings/i); + expect(await storage.listChunks(sid)).toEqual([]); + expect(await storage.getResumeState?.(sid)).toBeUndefined(); + } + + const atChunkLimit = { + size: 500_000, + has: () => false + } as unknown as ReadonlyMap; + expect(() => + assertPipelineResumeChangesWithinLimits(new Map([["VR-limit", atChunkLimit]]), [ + { + operation: "put", + recordingId: "VR-limit", + index: 499_999, + hash: "f".repeat(64) + } + ]) + ).toThrow(/too many screen-recording chunks/i); + }); + it("supports memory storage CRUD and session-scoped blob ref-count cleanup", async () => { const storage = new MemoryPipelineStorage(); const hash = "f".repeat(64); @@ -196,6 +250,14 @@ describe("storage", () => { }); expect(await storage.getSession(sid)).toEqual(expect.objectContaining({ sid })); await storage.putChunk(chunk); + await storage.initializeResumeState(sid, 1, { + sequenceWatermark: { event: 1, action: 0 }, + screenRecordings: [] + }); + expect(await storage.getResumeState(sid, 1)).toEqual({ + sequenceWatermark: { event: 1, action: 0 }, + screenRecordings: [] + }); await storage.putBlob(createBlob(hash, blobBytes), sid); await storage.putIndexes(sid, { time: [chunkMeta("C-enc", 1)], @@ -231,7 +293,14 @@ describe("storage", () => { files: {} }); - await baseStorage.putChunk(createChunk(sid, "C-plain", 2, '{"plain":true}\n')); + await baseStorage.putChunk({ + ...createChunk(sid, "C-plain", 2, '{"plain":true}\n'), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 2, action: 0 }, + screenRecordingChanges: [] + } + }); await baseStorage.putBlob(createBlob("b".repeat(64), Uint8Array.from([9, 9, 9])), sid); await expect(storage.getChunk(sid, "C-plain")).rejects.toThrow(/refusing.*plaintext/i); await expect(storage.getBlob("b".repeat(64))).rejects.toThrow(/refusing.*plaintext/i); @@ -372,6 +441,192 @@ describe("storage", () => { expect(await storage.getBlob(hash)).toBeUndefined(); }); + it("stores indexeddb blob ownership as constant-size rows", async () => { + const databaseName = createDbName(); + const storage = new IndexedDbPipelineStorage(databaseName); + const sid = "S-normalized-blob-refs"; + + await storage.putSession({ ...SESSION_A, sid }); + + for (let index = 0; index < 64; index += 1) { + const hash = index.toString(16).padStart(64, "0"); + await storage.putBlob(createBlob(hash, Uint8Array.from([index])), sid); + } + + const db = await openRawDb(databaseName, 4, () => undefined); + const refs = await readRawRows(db, "blobRefs"); + db.close(); + + expect(refs).toHaveLength(64); + expect(refs.every((row) => row.sid === sid && typeof row.hash === "string")).toBe(true); + expect(refs.every((row) => row.value === undefined)).toBe(true); + expect(Math.max(...refs.map((row) => JSON.stringify(row).length))).toBeLessThan(220); + + await storage.deleteSession(sid); + expect(await storage.listBlobs()).toEqual([]); + }); + + it("migrates a legacy cumulative blob-ref row once", async () => { + const databaseName = createDbName(); + const sid = "S-legacy-blob-refs"; + const oldHash = "1".repeat(64); + const newHash = "2".repeat(64); + const db = await openRawDb(databaseName, 3, (raw) => { + for (const storeName of ["sessions", "chunks", "blobs", "blobRefs", "indexes", "integrity"]) { + raw.createObjectStore(storeName, { keyPath: "key" }); + } + }); + await writeRawRows(db, "sessions", [{ key: sid, value: { ...SESSION_A, sid } }]); + await writeRawRows(db, "blobs", [ + { key: oldHash, value: createBlob(oldHash, Uint8Array.from([1])) } + ]); + await writeRawRows(db, "blobRefs", [{ key: sid, value: [oldHash] }]); + db.close(); + + const storage = new IndexedDbPipelineStorage(databaseName); + await storage.putBlob(createBlob(oldHash, Uint8Array.from([1])), sid); + await storage.putBlob(createBlob(newHash, Uint8Array.from([2])), sid); + + const upgraded = await openRawDb(databaseName, 4, () => undefined); + const refs = await readRawRows(upgraded, "blobRefs"); + upgraded.close(); + + expect(refs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sid, hash: oldHash }), + expect.objectContaining({ sid, hash: newHash }) + ]) + ); + expect(refs.some((row) => row.key === sid || Array.isArray(row.value))).toBe(false); + expect((await storage.getBlob(oldHash))?.refCount).toBe(1); + + await storage.deleteSession(sid); + expect(await storage.listBlobs()).toEqual([]); + }); + + it("commits indexeddb chunks and normalized resume changes atomically", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const sid = "S-resume-rollback"; + const hash = "7".repeat(64); + const chunk: StoredChunk = { + ...createChunk(sid, "C-resume", 1, "resume-event"), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 41, action: 5 }, + screenRecordingChanges: [ + { + operation: "put", + recordingId: "VR-atomic", + index: 0, + hash, + size: 12 + } + ] + } + }; + const originalPut = IDBObjectStore.prototype.put; + + await storage.putSession({ ...SESSION_A, sid }); + const putSpy = vi.spyOn(IDBObjectStore.prototype, "put").mockImplementation(function ( + this: IDBObjectStore, + value: unknown, + key?: IDBValidKey + ): IDBRequest { + if (this.name === "resumeRefs") { + throw new Error("simulated resumeRefs write failure"); + } + + return key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); + }); + + try { + await expect(storage.putChunk(chunk)).rejects.toThrow(/simulated resumeRefs/i); + } finally { + putSpy.mockRestore(); + } + + expect(await storage.getChunk(sid, "C-resume")).toBeUndefined(); + expect(await storage.getResumeState(sid)).toBeUndefined(); + + await storage.putChunk(chunk); + expect(await storage.getResumeState(sid)).toEqual({ + sequenceWatermark: { event: 41, action: 5 }, + screenRecordings: [ + { + recordingId: "VR-atomic", + chunks: [{ index: 0, hash, size: 12 }] + } + ] + }); + await storage.putChunk(chunk); + expect(await storage.listChunks(sid)).toHaveLength(1); + await expect( + storage.putChunk({ + ...chunk, + meta: { + ...chunk.meta, + sha256: "9".repeat(64) + } + }) + ).rejects.toThrow(/sequence.*conflict/i); + + await storage.putChunk({ + ...createChunk(sid, "C-resume-end", 2, "resume-end"), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 42, action: 5 }, + screenRecordingChanges: [{ operation: "delete", recordingId: "VR-atomic" }] + } + }); + await expect( + storage.putChunk({ + ...createChunk(sid, "C-resume-stale", 1, "resume-stale"), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 1, action: 0 }, + screenRecordingChanges: [ + { + operation: "put", + recordingId: "VR-atomic", + index: 1, + hash: "8".repeat(64) + } + ] + } + }) + ).rejects.toThrow(/sequence.*conflict/i); + expect(await storage.getResumeState(sid, 2)).toEqual({ + sequenceWatermark: { event: 42, action: 5 }, + screenRecordings: [] + }); + expect((await storage.listChunks(sid)).map((candidate) => candidate.meta.chunkId)).toEqual([ + "C-resume", + "C-resume-end" + ]); + await expect( + storage.putChunk(createChunk(sid, "C-missing-resume-delta", 3, "missing-delta")) + ).rejects.toThrow(/missing a resume delta/i); + }); + + it("initializes an indexeddb resume index from a legacy checkpoint once", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const sid = "S-resume-initialize"; + const state = { + sequenceWatermark: { event: 9, action: 2 }, + screenRecordings: [{ recordingId: "VR-initialize", chunks: [] }] + }; + + await storage.putSession({ ...SESSION_A, sid }); + await storage.putChunk(createChunk(sid, "C-initialize", 1, "legacy")); + await storage.initializeResumeState(sid, 1, state); + await storage.initializeResumeState(sid, 1, { + sequenceWatermark: { event: 99, action: 99 }, + screenRecordings: [] + }); + + expect(await storage.getResumeState(sid, 1)).toEqual(state); + }); + it("rolls back every indexeddb session store when atomic deletion fails", async () => { const storage = new IndexedDbPipelineStorage(createDbName()); const sid = "S-delete-rollback"; diff --git a/packages/pipeline/src/storage.ts b/packages/pipeline/src/storage.ts index 0c108de..63574f3 100644 --- a/packages/pipeline/src/storage.ts +++ b/packages/pipeline/src/storage.ts @@ -12,7 +12,10 @@ export type StoredChunk = { sid: string; meta: ChunkTimeIndexEntry; bytes: Uint8Array; + /** Legacy cumulative checkpoint retained for backwards-compatible reads. */ resumeState?: PipelineResumeState; + /** Constant-size changes committed atomically with this chunk. */ + resumeDelta?: PipelineResumeDelta; }; export type RecorderSequenceWatermark = { @@ -23,6 +26,12 @@ export type RecorderSequenceWatermark = { export type ScreenRecordingChunkReference = { index: number; hash: string; + size?: number; +}; + +type ScreenRecordingChunkCheckpoint = { + hash: string; + size?: number; }; export type ScreenRecordingResumeState = { @@ -35,6 +44,29 @@ export type PipelineResumeState = { screenRecordings: ScreenRecordingResumeState[]; }; +export type ScreenRecordingResumeChange = + | { + operation: "put"; + recordingId: string; + index: number; + hash: string; + size?: number; + } + | { + operation: "reset"; + recordingId: string; + } + | { + operation: "delete"; + recordingId: string; + }; + +export type PipelineResumeDelta = { + version: 1; + sequenceWatermark: RecorderSequenceWatermark; + screenRecordingChanges: ScreenRecordingResumeChange[]; +}; + export type StoredBlob = { hash: string; mime: string; @@ -67,6 +99,12 @@ export type PipelineStorage = { listChunks(sid: string): Promise; getLatestChunkMeta(sid: string): Promise; getChunk(sid: string, chunkId: string): Promise; + getResumeState?(sid: string, chunkSequence?: number): Promise; + initializeResumeState?( + sid: string, + chunkSequence: number, + state: PipelineResumeState + ): Promise; putBlob(blob: StoredBlob, sidHint?: string): Promise; getBlob(hash: string): Promise; listBlobs(): Promise; @@ -89,6 +127,8 @@ const STORAGE_ENCRYPTION_IV_BYTES = 12; const STORAGE_ENCRYPTION_KDF_ITERATIONS = 120_000; const STORAGE_KEYRING_VERSION = 1; const STORAGE_KEYRING_STORE = "keys"; +const PIPELINE_RESUME_MAX_RECORDINGS = 32; +const PIPELINE_RESUME_MAX_SCREEN_CHUNKS = 500_000; export type PipelineStorageKeyOptions = { salt?: Uint8Array; @@ -136,6 +176,15 @@ export class MemoryPipelineStorage implements PipelineStorage { private readonly blobRefs = new Map>(); + private readonly resumeSequences = new Map(); + + private readonly resumeChunkSequences = new Map(); + + private readonly resumeScreenRecordings = new Map< + string, + Map> + >(); + private readonly indexes = new Map(); private readonly integrity = new Map(); @@ -150,6 +199,37 @@ export class MemoryPipelineStorage implements PipelineStorage { public async putChunk(chunk: StoredChunk): Promise { const existing = this.chunks.get(chunk.sid) ?? []; + + if (isPipelineResumeDelta(chunk.resumeDelta)) { + const checkpointSequence = this.resumeChunkSequences.get(chunk.sid) ?? 0; + + if (checkpointSequence >= chunk.meta.seq) { + const previous = existing.find((candidate) => candidate.meta.seq === chunk.meta.seq); + + if ( + checkpointSequence === chunk.meta.seq && + previous && + isSameChunkCommit(previous, chunk) + ) { + return; + } + + throw new Error("Pipeline chunk sequence would conflict with durable resume state."); + } + + const recordings = this.resumeScreenRecordings.get(chunk.sid) ?? new Map(); + assertPipelineResumeChangesWithinLimits(recordings, chunk.resumeDelta.screenRecordingChanges); + existing.push(chunk); + this.chunks.set(chunk.sid, existing); + this.applyResumeDelta(chunk.sid, chunk.resumeDelta); + this.resumeChunkSequences.set(chunk.sid, chunk.meta.seq); + return; + } + + if (this.resumeChunkSequences.has(chunk.sid)) { + throw new Error("Pipeline chunk is missing a resume delta after checkpoint initialization."); + } + existing.push(chunk); this.chunks.set(chunk.sid, existing); } @@ -177,6 +257,44 @@ export class MemoryPipelineStorage implements PipelineStorage { return chunks.find((chunk) => chunk.meta.chunkId === chunkId); } + public async getResumeState( + sid: string, + chunkSequence?: number + ): Promise { + const sequenceWatermark = this.resumeSequences.get(sid); + + if ( + !sequenceWatermark || + (chunkSequence !== undefined && this.resumeChunkSequences.get(sid) !== chunkSequence) + ) { + return undefined; + } + + return createPipelineResumeState( + sequenceWatermark, + this.resumeScreenRecordings.get(sid) ?? new Map() + ); + } + + public async initializeResumeState( + sid: string, + chunkSequence: number, + state: PipelineResumeState + ): Promise { + if (this.resumeSequences.has(sid)) { + return; + } + + if (!Number.isSafeInteger(chunkSequence) || chunkSequence < 1) { + throw new Error("Pipeline resume chunk sequence must be a positive safe integer."); + } + + const normalized = requirePipelineResumeState(state); + this.resumeSequences.set(sid, { ...normalized.sequenceWatermark }); + this.resumeChunkSequences.set(sid, chunkSequence); + this.resumeScreenRecordings.set(sid, screenRecordingMapFromState(normalized)); + } + public async putBlob(blob: StoredBlob, sidHint?: string): Promise { const trackingSid = normalizeTrackingSid(sidHint); @@ -228,6 +346,9 @@ export class MemoryPipelineStorage implements PipelineStorage { this.sessions.delete(sid); this.chunks.delete(sid); this.blobRefs.delete(sid); + this.resumeSequences.delete(sid); + this.resumeChunkSequences.delete(sid); + this.resumeScreenRecordings.delete(sid); this.indexes.delete(sid); this.integrity.delete(sid); @@ -269,6 +390,14 @@ export class MemoryPipelineStorage implements PipelineStorage { private getTrackedBlobHashes(sid: string): string[] { return [...(this.blobRefs.get(sid) ?? new Set())]; } + + private applyResumeDelta(sid: string, delta: PipelineResumeDelta): void { + this.resumeSequences.set(sid, { ...delta.sequenceWatermark }); + const recordings = this.resumeScreenRecordings.get(sid) ?? new Map(); + + applyScreenRecordingChanges(recordings, delta.screenRecordingChanges); + this.resumeScreenRecordings.set(sid, recordings); + } } /** @@ -473,6 +602,21 @@ export class EncryptedPipelineStorage implements PipelineStorage { }; } + public async getResumeState( + sid: string, + chunkSequence?: number + ): Promise { + return this.storage.getResumeState?.(sid, chunkSequence); + } + + public async initializeResumeState( + sid: string, + chunkSequence: number, + state: PipelineResumeState + ): Promise { + await this.storage.initializeResumeState?.(sid, chunkSequence, state); + } + public async putBlob(blob: StoredBlob, sidHint?: string): Promise { await this.storage.putBlob( { @@ -600,13 +744,36 @@ type ChunkRow = { value: StoredChunk; }; type BlobRow = DbRow; -type BlobRefsRow = DbRow; +type LegacyBlobRefsRow = DbRow; +type BlobRefRow = { + key: string; + sid: string; + hash: string; +}; type SessionRow = DbRow; type IndexRow = DbRow; type IntegrityRow = DbRow; +type ResumeMetaRow = { + key: string; + chunkSequence: number; + recordingIds: string[]; + screenRecordingChunkCount: number; + value: RecorderSequenceWatermark; +}; +type ResumeRefRow = { + key: string; + sid: string; + recordingId: string; + index: number; + hash: string; + size?: number; +}; -const DB_VERSION = 3; +const DB_VERSION = 4; const CHUNKS_BY_SID_SEQ_INDEX = "by-sid-seq"; +const BLOB_REFS_BY_SID_INDEX = "by-sid"; +const RESUME_REFS_BY_SID_INDEX = "by-sid"; +const RESUME_REFS_BY_SID_RECORDING_INDEX = "by-sid-recording"; export class IndexedDbPipelineStorage implements PipelineStorage { public readonly [PIPELINE_STORAGE_SECURITY] = Object.freeze({ @@ -642,19 +809,12 @@ export class IndexedDbPipelineStorage implements PipelineStorage { } public async putChunk(chunk: StoredChunk): Promise { - await this.put( - "chunks", - { - key: this.chunkKey(chunk.sid, chunk.meta.chunkId), - sid: chunk.sid, - seq: chunk.meta.seq, - value: chunk - }, - { - allowQuotaRecovery: true, - protectedSid: chunk.sid - } - ); + if (isPipelineResumeDelta(chunk.resumeDelta)) { + await this.putChunkWithResumeDelta(chunk, chunk.resumeDelta); + return; + } + + await this.putLegacyChunk(chunk); } public async listChunks(sid: string): Promise { @@ -707,6 +867,111 @@ export class IndexedDbPipelineStorage implements PipelineStorage { return row?.value; } + public async getResumeState( + sid: string, + chunkSequence?: number + ): Promise { + const db = await this.db(); + + return runMultiStoreTransaction( + db, + ["resumeMeta", "resumeRefs"], + "readonly", + async (transaction) => { + const [meta, refs] = await Promise.all([ + requestToPromise( + transaction.objectStore("resumeMeta").get(sid) + ), + listResumeRefsBySid(transaction.objectStore("resumeRefs"), sid) + ]); + + if (!meta || (chunkSequence !== undefined && meta.chunkSequence !== chunkSequence)) { + return undefined; + } + + if ( + !Number.isSafeInteger(meta.screenRecordingChunkCount) || + meta.screenRecordingChunkCount < 0 || + meta.screenRecordingChunkCount > PIPELINE_RESUME_MAX_SCREEN_CHUNKS || + meta.screenRecordingChunkCount !== refs.length + ) { + throw new Error("IndexedDB pipeline resume reference count is inconsistent."); + } + + const recordings = new Map>( + requireResumeRecordingIds(meta.recordingIds).map((recordingId) => [ + recordingId, + new Map() + ]) + ); + + for (const ref of refs) { + if (!recordings.has(ref.recordingId)) { + throw new Error("Pipeline resume reference is missing its recording marker."); + } + + const chunks = + recordings.get(ref.recordingId) ?? new Map(); + chunks.set(ref.index, { + hash: ref.hash, + ...(ref.size === undefined ? {} : { size: ref.size }) + }); + recordings.set(ref.recordingId, chunks); + } + + return requirePipelineResumeState(createPipelineResumeState(meta.value, recordings)); + } + ); + } + + public async initializeResumeState( + sid: string, + chunkSequence: number, + state: PipelineResumeState + ): Promise { + const normalized = requirePipelineResumeState(state); + + if (!Number.isSafeInteger(chunkSequence) || chunkSequence < 1) { + throw new Error("Pipeline resume chunk sequence must be a positive safe integer."); + } + + const db = await this.db(); + + await runMultiStoreTransaction( + db, + ["resumeMeta", "resumeRefs"], + "readwrite", + async (transaction) => { + const metaStore = transaction.objectStore("resumeMeta"); + const existing = await requestToPromise(metaStore.get(sid)); + + if (existing) { + return; + } + + metaStore.put({ + key: sid, + chunkSequence, + recordingIds: normalized.screenRecordings.map((recording) => recording.recordingId), + screenRecordingChunkCount: normalized.screenRecordings.reduce( + (count, recording) => count + recording.chunks.length, + 0 + ), + value: { ...normalized.sequenceWatermark } + } satisfies ResumeMetaRow); + const refsStore = transaction.objectStore("resumeRefs"); + + for (const recording of normalized.screenRecordings) { + for (const chunk of recording.chunks) { + refsStore.put( + createResumeRefRow(sid, recording.recordingId, chunk.index, chunk.hash, chunk.size) + ); + } + } + } + ); + } + public async putBlob(blob: StoredBlob, sidHint?: string): Promise { const trackingSid = normalizeTrackingSid(sidHint); @@ -745,12 +1010,14 @@ export class IndexedDbPipelineStorage implements PipelineStorage { return; } - const [session, existingBlob, existingRefs] = await Promise.all([ + const blobRefKey = createBlobRefKey(trackingSid, blob.hash); + const [session, existingBlob, legacyRefs, existingRef] = await Promise.all([ requestToPromise( transaction.objectStore("sessions").get(trackingSid) ), existingBlobRequest, - requestToPromise(blobRefsStore.get(trackingSid)) + requestToPromise(blobRefsStore.get(trackingSid)), + requestToPromise(blobRefsStore.get(blobRefKey)) ]); if (!session) { @@ -759,9 +1026,31 @@ export class IndexedDbPipelineStorage implements PipelineStorage { ); } - const trackedHashes = normalizeBlobHashes(existingRefs?.value ?? []); + const trackedHashes = normalizeBlobHashes(legacyRefs?.value ?? []); + + if (legacyRefs) { + for (const trackedHash of trackedHashes) { + blobRefsStore.put({ + key: createBlobRefKey(trackingSid, trackedHash), + sid: trackingSid, + hash: trackedHash + } satisfies BlobRefRow); + } + + blobRefsStore.delete(trackingSid); + } + + if (existingRef || trackedHashes.includes(blob.hash)) { + if (!existingBlob) { + blobsStore.put({ + key: blob.hash, + value: { + ...blob, + refCount: 1 + } + } satisfies BlobRow); + } - if (trackedHashes.includes(blob.hash)) { return; } @@ -778,9 +1067,10 @@ export class IndexedDbPipelineStorage implements PipelineStorage { } } satisfies BlobRow); blobRefsStore.put({ - key: trackingSid, - value: [...trackedHashes, blob.hash] - } satisfies BlobRefsRow); + key: blobRefKey, + sid: trackingSid, + hash: blob.hash + } satisfies BlobRefRow); } ); return; @@ -852,7 +1142,16 @@ export class IndexedDbPipelineStorage implements PipelineStorage { await runMultiStoreTransaction( db, - ["sessions", "chunks", "blobs", "blobRefs", "indexes", "integrity"], + [ + "sessions", + "chunks", + "blobs", + "blobRefs", + "indexes", + "integrity", + "resumeMeta", + "resumeRefs" + ], "readwrite", async (transaction) => { const sessionsStore = transaction.objectStore("sessions"); @@ -861,17 +1160,26 @@ export class IndexedDbPipelineStorage implements PipelineStorage { const blobRefsStore = transaction.objectStore("blobRefs"); const indexesStore = transaction.objectStore("indexes"); const integrityStore = transaction.objectStore("integrity"); - const [session, trackedRefs, chunks] = await Promise.all([ + const resumeMetaStore = transaction.objectStore("resumeMeta"); + const resumeRefsStore = transaction.objectStore("resumeRefs"); + const [session, legacyRefs, normalizedRefs, chunks] = await Promise.all([ requestToPromise(sessionsStore.get(sid)), - requestToPromise(blobRefsStore.get(sid)), - deleteChunksBySidInTransaction(chunksStore, sid) + requestToPromise(blobRefsStore.get(sid)), + listBlobRefsBySid(blobRefsStore, sid), + deleteChunksBySidInTransaction(chunksStore, sid), + deleteResumeRefsBySid(resumeRefsStore, sid) ]); const inferredBlobHashes = collectBlobHashesFromChunks(chunks); - const ownedBlobHashes = trackedRefs - ? normalizeBlobHashes(trackedRefs.value) - : session || chunks.length > 0 - ? mergeBlobHashes(blobHashes, [...inferredBlobHashes]) - : []; + const trackedBlobHashes = mergeBlobHashes( + legacyRefs?.value ?? [], + normalizedRefs.map((row) => row.hash) + ); + const ownedBlobHashes = + legacyRefs || normalizedRefs.length > 0 + ? trackedBlobHashes + : session || chunks.length > 0 + ? mergeBlobHashes(blobHashes, [...inferredBlobHashes]) + : []; const storedBlobs = await Promise.all( ownedBlobHashes.map((hash) => requestToPromise(blobsStore.get(hash))) @@ -902,10 +1210,186 @@ export class IndexedDbPipelineStorage implements PipelineStorage { indexesStore.delete(sid); integrityStore.delete(sid); blobRefsStore.delete(sid); + resumeMetaStore.delete(sid); + + for (const ref of normalizedRefs) { + blobRefsStore.delete(ref.key); + } } ); } + private async putChunkWithResumeDelta( + chunk: StoredChunk, + delta: PipelineResumeDelta + ): Promise { + let attempt = 0; + + while (true) { + const db = await this.db(); + + try { + await runMultiStoreTransaction( + db, + ["chunks", "resumeMeta", "resumeRefs"], + "readwrite", + async (transaction) => { + const chunksStore = transaction.objectStore("chunks"); + const metaStore = transaction.objectStore("resumeMeta"); + const chunkKey = this.chunkKey(chunk.sid, chunk.meta.chunkId); + const [existingMeta, existingChunk] = await Promise.all([ + requestToPromise(metaStore.get(chunk.sid)), + requestToPromise(chunksStore.get(chunkKey)) + ]); + + if ( + existingMeta && + Number.isSafeInteger(existingMeta.chunkSequence) && + existingMeta.chunkSequence >= chunk.meta.seq + ) { + if ( + existingMeta.chunkSequence === chunk.meta.seq && + existingChunk && + isSameChunkCommit(existingChunk.value, chunk) + ) { + return; + } + + throw new Error("Pipeline chunk sequence would conflict with durable resume state."); + } + + if (existingChunk) { + throw new Error("Pipeline chunk id already exists without matching resume state."); + } + + chunksStore.put({ + key: chunkKey, + sid: chunk.sid, + seq: chunk.meta.seq, + value: chunk + } satisfies ChunkRow); + + const refsStore = transaction.objectStore("resumeRefs"); + const recordingIds = new Set( + existingMeta ? requireResumeRecordingIds(existingMeta.recordingIds) : [] + ); + let screenRecordingChunkCount = existingMeta + ? requireResumeChunkCount(existingMeta.screenRecordingChunkCount) + : 0; + + for (const change of delta.screenRecordingChanges) { + if (change.operation === "put") { + recordingIds.add(change.recordingId); + const ref = createResumeRefRow( + chunk.sid, + change.recordingId, + change.index, + change.hash, + change.size + ); + const existingRef = await requestToPromise( + refsStore.get(ref.key) + ); + refsStore.put(ref); + + if (!existingRef) { + screenRecordingChunkCount += 1; + } + } else { + screenRecordingChunkCount -= await deleteResumeRefsByRecording( + refsStore, + chunk.sid, + change.recordingId + ); + + if (change.operation === "reset") { + recordingIds.add(change.recordingId); + } else { + recordingIds.delete(change.recordingId); + } + } + } + + if (recordingIds.size > PIPELINE_RESUME_MAX_RECORDINGS) { + throw new Error("Pipeline resume state has too many active screen recordings."); + } + + if ( + screenRecordingChunkCount < 0 || + screenRecordingChunkCount > PIPELINE_RESUME_MAX_SCREEN_CHUNKS + ) { + throw new Error("Pipeline resume state has too many screen-recording chunks."); + } + + metaStore.put({ + key: chunk.sid, + chunkSequence: chunk.meta.seq, + recordingIds: [...recordingIds].sort((left, right) => left.localeCompare(right)), + screenRecordingChunkCount, + value: { ...delta.sequenceWatermark } + } satisfies ResumeMetaRow); + } + ); + return; + } catch (error) { + if (!isQuotaExceededError(error) || attempt >= MAX_QUOTA_RECOVERY_ATTEMPTS) { + throw error; + } + + attempt += 1; + + if (!(await this.recoverQuotaPressure(chunk.sid))) { + throw error; + } + } + } + } + + private async putLegacyChunk(chunk: StoredChunk): Promise { + let attempt = 0; + + while (true) { + const db = await this.db(); + + try { + await runMultiStoreTransaction( + db, + ["chunks", "resumeMeta"], + "readwrite", + async (transaction) => { + const resumeMeta = await requestToPromise( + transaction.objectStore("resumeMeta").get(chunk.sid) + ); + + if (resumeMeta) { + throw new Error( + "Pipeline chunk is missing a resume delta after checkpoint initialization." + ); + } + + transaction.objectStore("chunks").put({ + key: this.chunkKey(chunk.sid, chunk.meta.chunkId), + sid: chunk.sid, + seq: chunk.meta.seq, + value: chunk + } satisfies ChunkRow); + } + ); + return; + } catch (error) { + if (!isQuotaExceededError(error) || attempt >= MAX_QUOTA_RECOVERY_ATTEMPTS) { + throw error; + } + + attempt += 1; + + if (!(await this.recoverQuotaPressure(chunk.sid))) { + throw error; + } + } + } + } + private chunkKey(sid: string, chunkId: string): string { return `${sid}:${chunkId}`; } @@ -1032,7 +1516,9 @@ export class IndexedDbPipelineStorage implements PipelineStorage { "blobs", "blobRefs", "indexes", - "integrity" + "integrity", + "resumeMeta", + "resumeRefs" ]) { if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName, { keyPath: "key" }); @@ -1045,6 +1531,27 @@ export class IndexedDbPipelineStorage implements PipelineStorage { if (chunksStore && !chunksStore.indexNames.contains(CHUNKS_BY_SID_SEQ_INDEX)) { chunksStore.createIndex(CHUNKS_BY_SID_SEQ_INDEX, ["sid", "seq"], { unique: false }); } + + const blobRefsStore = transaction?.objectStore("blobRefs"); + + if (blobRefsStore && !blobRefsStore.indexNames.contains(BLOB_REFS_BY_SID_INDEX)) { + blobRefsStore.createIndex(BLOB_REFS_BY_SID_INDEX, "sid", { unique: false }); + } + + const resumeRefsStore = transaction?.objectStore("resumeRefs"); + + if (resumeRefsStore && !resumeRefsStore.indexNames.contains(RESUME_REFS_BY_SID_INDEX)) { + resumeRefsStore.createIndex(RESUME_REFS_BY_SID_INDEX, "sid", { unique: false }); + } + + if ( + resumeRefsStore && + !resumeRefsStore.indexNames.contains(RESUME_REFS_BY_SID_RECORDING_INDEX) + ) { + resumeRefsStore.createIndex(RESUME_REFS_BY_SID_RECORDING_INDEX, ["sid", "recordingId"], { + unique: false + }); + } }; request.onsuccess = () => { @@ -1061,6 +1568,456 @@ export class IndexedDbPipelineStorage implements PipelineStorage { } } +export function isPipelineResumeDelta(value: unknown): value is PipelineResumeDelta { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + const sequence = asStorageRecord(record.sequenceWatermark); + const changes = record.screenRecordingChanges; + + if ( + Object.keys(record).length !== 3 || + record.version !== 1 || + !isRecorderSequenceWatermark(sequence) || + !Array.isArray(changes) || + changes.length > PIPELINE_RESUME_MAX_SCREEN_CHUNKS + ) { + return false; + } + + return changes.every((candidate) => { + const change = asStorageRecord(candidate); + + if (!change || !isValidRecordingId(change.recordingId)) { + return false; + } + + if (change.operation === "delete" || change.operation === "reset") { + return Object.keys(change).length === 2; + } + + const keys = Object.keys(change); + const hasValidSize = + change.size === undefined || + (Number.isSafeInteger(change.size) && (change.size as number) >= 0); + + return ( + change.operation === "put" && + (keys.length === 4 || keys.length === 5) && + (keys.length !== 5 || change.size !== undefined) && + hasValidSize && + Number.isSafeInteger(change.index) && + (change.index as number) >= 0 && + (change.index as number) < PIPELINE_RESUME_MAX_SCREEN_CHUNKS && + typeof change.hash === "string" && + SHA256_HEX_PATTERN.test(change.hash) + ); + }); +} + +export function assertPipelineResumeChangesWithinLimits( + recordings: ReadonlyMap>, + changes: readonly ScreenRecordingResumeChange[] +): void { + const activeRecordingIds = new Set(recordings.keys()); + let totalChunks = 0; + + for (const chunks of recordings.values()) { + totalChunks += chunks.size; + } + + if ( + activeRecordingIds.size > PIPELINE_RESUME_MAX_RECORDINGS || + totalChunks > PIPELINE_RESUME_MAX_SCREEN_CHUNKS + ) { + throw new Error("Pipeline resume state exceeds its resource limits."); + } + + type Projection = { + active: boolean; + base: ReadonlyMap | null; + additions: Set; + count: number; + }; + const projections = new Map(); + const resolveProjection = (recordingId: string): Projection => { + const existing = projections.get(recordingId); + + if (existing) { + return existing; + } + + const base = recordings.get(recordingId) ?? null; + const projection: Projection = { + active: base !== null, + base, + additions: new Set(), + count: base?.size ?? 0 + }; + projections.set(recordingId, projection); + return projection; + }; + + for (const change of changes) { + let projection = resolveProjection(change.recordingId); + + if (change.operation === "reset") { + if (projection.active) { + totalChunks -= projection.count; + } + + projection = { + active: true, + base: null, + additions: new Set(), + count: 0 + }; + projections.set(change.recordingId, projection); + activeRecordingIds.add(change.recordingId); + } else if (change.operation === "delete") { + if (projection.active) { + totalChunks -= projection.count; + } + + projections.set(change.recordingId, { + active: false, + base: null, + additions: new Set(), + count: 0 + }); + activeRecordingIds.delete(change.recordingId); + } else { + if (!projection.active) { + projection = { + active: true, + base: null, + additions: new Set(), + count: 0 + }; + projections.set(change.recordingId, projection); + activeRecordingIds.add(change.recordingId); + } + + if (!projection.base?.has(change.index) && !projection.additions.has(change.index)) { + projection.additions.add(change.index); + projection.count += 1; + totalChunks += 1; + } + } + + if (activeRecordingIds.size > PIPELINE_RESUME_MAX_RECORDINGS) { + throw new Error("Pipeline resume state has too many active screen recordings."); + } + + if (totalChunks > PIPELINE_RESUME_MAX_SCREEN_CHUNKS) { + throw new Error("Pipeline resume state has too many screen-recording chunks."); + } + } +} + +export function isPipelineResumeState(value: unknown): value is PipelineResumeState { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const record = value as Record; + const sequence = asStorageRecord(record.sequenceWatermark); + + if ( + Object.keys(record).length !== 2 || + !isRecorderSequenceWatermark(sequence) || + !Array.isArray(record.screenRecordings) || + record.screenRecordings.length > PIPELINE_RESUME_MAX_RECORDINGS + ) { + return false; + } + + const recordingIds = new Set(); + let totalChunks = 0; + + for (const candidate of record.screenRecordings) { + const recording = asStorageRecord(candidate); + + if ( + !recording || + Object.keys(recording).length !== 2 || + !isValidRecordingId(recording.recordingId) || + recordingIds.has(recording.recordingId as string) || + !Array.isArray(recording.chunks) + ) { + return false; + } + + recordingIds.add(recording.recordingId as string); + totalChunks += recording.chunks.length; + + if (totalChunks > PIPELINE_RESUME_MAX_SCREEN_CHUNKS) { + return false; + } + + const indexes = new Set(); + + for (const candidateChunk of recording.chunks) { + const chunk = asStorageRecord(candidateChunk); + const chunkKeys = chunk ? Object.keys(chunk) : []; + + if ( + !chunk || + (chunkKeys.length !== 2 && chunkKeys.length !== 3) || + (chunkKeys.length === 3 && (!chunkKeys.includes("size") || chunk.size === undefined)) || + !Number.isSafeInteger(chunk.index) || + (chunk.index as number) < 0 || + (chunk.index as number) >= PIPELINE_RESUME_MAX_SCREEN_CHUNKS || + indexes.has(chunk.index as number) || + typeof chunk.hash !== "string" || + !SHA256_HEX_PATTERN.test(chunk.hash) || + (chunk.size !== undefined && + (!Number.isSafeInteger(chunk.size) || (chunk.size as number) < 0)) + ) { + return false; + } + + indexes.add(chunk.index as number); + } + } + + return true; +} + +function requirePipelineResumeState(state: PipelineResumeState): PipelineResumeState { + if (!isPipelineResumeState(state)) { + throw new Error("Invalid pipeline resume state."); + } + + return state; +} + +function isRecorderSequenceWatermark( + value: Record | null +): value is Record<"event" | "action", number> { + return Boolean( + value && + Object.keys(value).length === 2 && + Number.isSafeInteger(value.event) && + (value.event as number) >= 0 && + Number.isSafeInteger(value.action) && + (value.action as number) >= 0 + ); +} + +function isValidRecordingId(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 256; +} + +function requireResumeRecordingIds(value: unknown): string[] { + if (!Array.isArray(value) || value.length > PIPELINE_RESUME_MAX_RECORDINGS) { + throw new Error("Invalid pipeline resume recording markers."); + } + + const unique = new Set(); + + for (const recordingId of value) { + if (!isValidRecordingId(recordingId) || unique.has(recordingId)) { + throw new Error("Invalid pipeline resume recording marker."); + } + + unique.add(recordingId); + } + + return [...unique]; +} + +function requireResumeChunkCount(value: unknown): number { + if ( + !Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) > PIPELINE_RESUME_MAX_SCREEN_CHUNKS + ) { + throw new Error("Invalid pipeline resume screen-recording chunk count."); + } + + return value as number; +} + +function asStorageRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function screenRecordingMapFromState( + state: PipelineResumeState +): Map> { + return new Map( + state.screenRecordings.map((recording) => [ + recording.recordingId, + new Map( + recording.chunks.map((chunk) => [ + chunk.index, + { + hash: chunk.hash, + ...(chunk.size === undefined ? {} : { size: chunk.size }) + } + ]) + ) + ]) + ); +} + +function createPipelineResumeState( + sequenceWatermark: RecorderSequenceWatermark, + recordings: Map> +): PipelineResumeState { + return { + sequenceWatermark: { ...sequenceWatermark }, + screenRecordings: [...recordings.entries()] + .map(([recordingId, chunks]) => ({ + recordingId, + chunks: [...chunks.entries()] + .map(([index, checkpoint]) => ({ index, ...checkpoint })) + .sort((left, right) => left.index - right.index) + })) + .sort((left, right) => left.recordingId.localeCompare(right.recordingId)) + }; +} + +function applyScreenRecordingChanges( + recordings: Map>, + changes: readonly ScreenRecordingResumeChange[] +): void { + for (const change of changes) { + if (change.operation === "delete") { + recordings.delete(change.recordingId); + continue; + } + + if (change.operation === "reset") { + recordings.set(change.recordingId, new Map()); + continue; + } + + const chunks = + recordings.get(change.recordingId) ?? new Map(); + chunks.set(change.index, { + hash: change.hash, + ...(change.size === undefined ? {} : { size: change.size }) + }); + recordings.set(change.recordingId, chunks); + } +} + +function createBlobRefKey(sid: string, hash: string): string { + return JSON.stringify([sid, hash]); +} + +function isSameChunkCommit(left: StoredChunk, right: StoredChunk): boolean { + return ( + left.sid === right.sid && + left.meta.chunkId === right.meta.chunkId && + left.meta.seq === right.meta.seq && + left.meta.tStart === right.meta.tStart && + left.meta.tEnd === right.meta.tEnd && + left.meta.monoStart === right.meta.monoStart && + left.meta.monoEnd === right.meta.monoEnd && + left.meta.eventCount === right.meta.eventCount && + left.meta.byteLength === right.meta.byteLength && + left.meta.codec === right.meta.codec && + left.meta.sha256 === right.meta.sha256 && + JSON.stringify(left.resumeDelta) === JSON.stringify(right.resumeDelta) + ); +} + +function createResumeRefRow( + sid: string, + recordingId: string, + index: number, + hash: string, + size?: number +): ResumeRefRow { + return { + key: JSON.stringify([sid, recordingId, index]), + sid, + recordingId, + index, + hash, + ...(size === undefined ? {} : { size }) + }; +} + +function listBlobRefsBySid(store: IDBObjectStore, sid: string): Promise { + if (!store.indexNames.contains(BLOB_REFS_BY_SID_INDEX)) { + return requestToPromise>(store.getAll()).then((rows) => + rows.filter((row): row is BlobRefRow => "sid" in row && row.sid === sid) + ); + } + + return requestToPromise( + store.index(BLOB_REFS_BY_SID_INDEX).getAll(IDBKeyRange.only(sid)) + ); +} + +function listResumeRefsBySid(store: IDBObjectStore, sid: string): Promise { + if (!store.indexNames.contains(RESUME_REFS_BY_SID_INDEX)) { + return Promise.reject(new Error("IndexedDB resume-ref index is unavailable.")); + } + + return requestToPromise( + store + .index(RESUME_REFS_BY_SID_INDEX) + .getAll(IDBKeyRange.only(sid), PIPELINE_RESUME_MAX_SCREEN_CHUNKS + 1) + ).then((rows) => { + if (rows.length > PIPELINE_RESUME_MAX_SCREEN_CHUNKS) { + throw new Error("Pipeline resume state has too many screen-recording chunks."); + } + + return rows; + }); +} + +function deleteResumeRefsBySid(store: IDBObjectStore, sid: string): Promise { + const request = store.index(RESUME_REFS_BY_SID_INDEX).openCursor(IDBKeyRange.only(sid)); + + return deleteCursorRows(request, "IndexedDB resume-ref session deletion failed"); +} + +function deleteResumeRefsByRecording( + store: IDBObjectStore, + sid: string, + recordingId: string +): Promise { + const index = store.index(RESUME_REFS_BY_SID_RECORDING_INDEX); + const request = index.openCursor(IDBKeyRange.only([sid, recordingId])); + + return deleteCursorRows(request, "IndexedDB resume-ref recording deletion failed"); +} + +function deleteCursorRows( + request: IDBRequest, + failureMessage: string +): Promise { + return new Promise((resolve, reject) => { + let deleted = 0; + + request.onerror = () => { + reject(request.error ?? new Error(failureMessage)); + }; + request.onsuccess = () => { + const cursor = request.result; + + if (!cursor) { + resolve(deleted); + return; + } + + cursor.delete(); + deleted += 1; + cursor.continue(); + }; + }); +} + function isQuotaExceededError(error: unknown): boolean { const DomException = globalThis.DOMException; From ef2dd72b17ffcbfebe2a7001573251cbc317e19a Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:18:41 +0800 Subject: [PATCH 058/181] fix(share): build direct runtime dependencies --- apps/share-server/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/share-server/package.json b/apps/share-server/package.json index 22d676c..2a0b1a3 100644 --- a/apps/share-server/package.json +++ b/apps/share-server/package.json @@ -7,8 +7,9 @@ "main": "./dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "prebuild": "pnpm --filter @webblackbox/protocol build && pnpm --filter @webblackbox/player-sdk build", "build": "tsup src/index.ts --format esm --clean", - "e2e:share": "pnpm --filter @webblackbox/player-sdk build && pnpm build && node scripts/e2e-share-flow.mjs", + "e2e:share": "pnpm build && node scripts/e2e-share-flow.mjs", "start": "node dist/index.js", "lint": "eslint src --ext .ts", "typecheck": "tsc -p tsconfig.json --noEmit", From c15ac6b7380427d45212da5f5192d6dfd21556f6 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:23:57 +0800 Subject: [PATCH 059/181] ci(bundle): gate every shipped JavaScript surface --- bundle-size/budgets.json | 106 ++++++++++++++++++++++++++++++---- scripts/check-bundle-size.mjs | 88 ++++++++++++++++++++++++---- 2 files changed, 173 insertions(+), 21 deletions(-) diff --git a/bundle-size/budgets.json b/bundle-size/budgets.json index 52cf19f..e7f13c3 100644 --- a/bundle-size/budgets.json +++ b/bundle-size/budgets.json @@ -1,29 +1,115 @@ { "entries": [ { + "name": "protocol runtime", + "path": "packages/protocol/dist/index.js", + "maxBytes": 57000, + "maxGzipBytes": 14000 + }, + { + "name": "CDP router runtime", + "path": "packages/cdp-router/dist/index.js", + "maxBytes": 6500, + "maxGzipBytes": 1800 + }, + { + "name": "recorder runtime", "path": "packages/recorder/dist/index.js", - "maxBytes": 55000, - "maxGzipBytes": 15000 + "maxBytes": 59000, + "maxGzipBytes": 14000 }, { + "name": "pipeline runtime", "path": "packages/pipeline/dist/index.js", - "maxBytes": 76000, - "maxGzipBytes": 18000 + "maxBytes": 143000, + "maxGzipBytes": 30000 }, { + "name": "Player SDK runtime", "path": "packages/player-sdk/dist/index.js", - "maxBytes": 90000, - "maxGzipBytes": 22000 + "maxBytes": 128000, + "maxGzipBytes": 30000 }, { + "name": "webblackbox package JavaScript", + "directory": "packages/webblackbox/dist", + "extensions": [".js"], + "maxBytes": 175000, + "maxGzipBytes": 41000 + }, + { + "name": "MCP server JavaScript", + "directory": "apps/mcp-server/dist", + "extensions": [".js"], + "maxBytes": 65000, + "maxGzipBytes": 14000 + }, + { + "name": "Share server runtime", + "path": "apps/share-server/dist/index.js", + "maxBytes": 96000, + "maxGzipBytes": 23000 + }, + { + "name": "Player application", "path": "apps/player/build/main.js", - "maxBytes": 1800000, - "maxGzipBytes": 330000 + "maxBytes": 1000000, + "maxGzipBytes": 265000 }, { + "name": "Extension JavaScript total", + "directory": "apps/extension/build", + "extensions": [".js"], + "maxBytes": 5000000, + "maxGzipBytes": 850000 + }, + { + "name": "Extension service worker", "path": "apps/extension/build/sw.js", - "maxBytes": 800000, - "maxGzipBytes": 150000 + "maxBytes": 975000, + "maxGzipBytes": 170000 + }, + { + "name": "Extension offscreen runtime", + "path": "apps/extension/build/offscreen.js", + "maxBytes": 995000, + "maxGzipBytes": 180000 + }, + { + "name": "Extension content agent", + "path": "apps/extension/build/content-agent.js", + "maxBytes": 880000, + "maxGzipBytes": 163000 + }, + { + "name": "Extension content bridge", + "path": "apps/extension/build/content.js", + "maxBytes": 39000, + "maxGzipBytes": 11000 + }, + { + "name": "Extension injected runtime", + "path": "apps/extension/build/injected.js", + "maxBytes": 680000, + "maxGzipBytes": 108000 + }, + { + "name": "Extension options UI", + "path": "apps/extension/build/options.js", + "maxBytes": 685000, + "maxGzipBytes": 110000 + }, + { + "name": "Extension popup UI", + "path": "apps/extension/build/popup.js", + "maxBytes": 700000, + "maxGzipBytes": 113000 + }, + { + "name": "Extension sessions UI", + "path": "apps/extension/build/sessions.js", + "maxBytes": 46000, + "maxGzipBytes": 12000 } ] } diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs index 3f60fda..4df861e 100644 --- a/scripts/check-bundle-size.mjs +++ b/scripts/check-bundle-size.mjs @@ -1,8 +1,8 @@ #!/usr/bin/env node import { gzipSync } from "node:zlib"; -import { readFile, stat, writeFile, mkdir } from "node:fs/promises"; -import { dirname, resolve } from "node:path"; +import { lstat, readFile, readdir, writeFile, mkdir } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -27,11 +27,17 @@ async function main() { const failures = []; for (const entry of entries) { - const target = resolve(root, String(entry.path)); - const fileStats = await stat(target); - const bytes = fileStats.size; - const source = await readFile(target); - const gzipBytes = gzipSync(source).byteLength; + const files = await resolveEntryFiles(entry); + const label = entry.name ?? entry.path ?? entry.directory; + let bytes = 0; + let gzipBytes = 0; + + for (const file of files) { + const source = await readFile(file); + bytes += source.byteLength; + gzipBytes += gzipSync(source).byteLength; + } + const maxBytes = Number(entry.maxBytes); const maxGzipBytes = Number(entry.maxGzipBytes); @@ -39,7 +45,8 @@ async function main() { const gzipOk = Number.isFinite(maxGzipBytes) ? gzipBytes <= maxGzipBytes : true; report.push({ - path: entry.path, + name: label, + files: files.map((file) => relative(root, file)), bytes, gzipBytes, maxBytes: Number.isFinite(maxBytes) ? maxBytes : null, @@ -49,13 +56,13 @@ async function main() { if (!rawOk) { failures.push( - `${entry.path}: raw size ${bytes} exceeds budget ${maxBytes} (+${bytes - maxBytes})` + `${label}: raw size ${bytes} exceeds budget ${maxBytes} (+${bytes - maxBytes})` ); } if (!gzipOk) { failures.push( - `${entry.path}: gzip size ${gzipBytes} exceeds budget ${maxGzipBytes} (+${gzipBytes - maxGzipBytes})` + `${label}: gzip size ${gzipBytes} exceeds budget ${maxGzipBytes} (+${gzipBytes - maxGzipBytes})` ); } } @@ -75,7 +82,7 @@ async function main() { ); for (const row of report) { - console.log(`${row.path}: raw=${row.bytes} gzip=${row.gzipBytes}`); + console.log(`${row.name}: files=${row.files.length} raw=${row.bytes} gzip=${row.gzipBytes}`); } console.log("Bundle size report:", reportPath); @@ -83,3 +90,62 @@ async function main() { throw new Error(`Bundle size budgets failed:\n- ${failures.join("\n- ")}`); } } + +async function resolveEntryFiles(entry) { + const hasPath = typeof entry.path === "string" && entry.path.length > 0; + const hasDirectory = typeof entry.directory === "string" && entry.directory.length > 0; + + if (hasPath === hasDirectory) { + throw new Error("Each bundle budget must define exactly one of path or directory"); + } + + if (hasPath) { + const target = resolveInsideRoot(entry.path); + const fileStats = await lstat(target); + if (!fileStats.isFile()) { + throw new Error(`Bundle budget path is not a regular file: ${entry.path}`); + } + return [target]; + } + + const extensions = Array.isArray(entry.extensions) ? entry.extensions.map(String) : [".js"]; + if (extensions.length === 0 || extensions.some((value) => !value.startsWith("."))) { + throw new Error(`Invalid extensions for bundle budget: ${entry.directory}`); + } + + const files = await collectFiles(resolveInsideRoot(entry.directory), extensions); + if (files.length === 0) { + throw new Error(`Bundle budget directory has no matching files: ${entry.directory}`); + } + return files; +} + +async function collectFiles(directory, extensions) { + const directoryStats = await lstat(directory); + if (!directoryStats.isDirectory()) { + throw new Error(`Bundle budget directory is not a directory: ${relative(root, directory)}`); + } + + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const target = resolve(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new Error(`Bundle budget directory contains a symlink: ${relative(root, target)}`); + } + if (entry.isDirectory()) { + files.push(...(await collectFiles(target, extensions))); + } else if (entry.isFile() && extensions.some((extension) => entry.name.endsWith(extension))) { + files.push(target); + } + } + return files.sort(); +} + +function resolveInsideRoot(value) { + const target = resolve(root, String(value)); + const fromRoot = relative(root, target); + if (fromRoot.startsWith("..") || fromRoot === "") { + throw new Error(`Bundle budget target must be inside the repository: ${value}`); + } + return target; +} From bbf41b12eedce31c54c10f98bf02d6803a551575 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:25:23 +0800 Subject: [PATCH 060/181] fix(share): preserve records above admission limit --- apps/share-server/README.md | 2 +- apps/share-server/src/index.test.ts | 84 +++++++++++++++++++++++++++++ apps/share-server/src/index.ts | 30 +++++------ 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index ed111f7..027187c 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -39,7 +39,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_DEFAULT_TTL_MS`: default share lifetime in ms (default `604800000`, seven days). - `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days). - `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). -- `WEBBLACKBOX_SHARE_MAX_RECORDS`: maximum retained record files admitted by one server (default and hard ceiling `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. +- `WEBBLACKBOX_SHARE_MAX_RECORDS`: upload admission limit for retained record files (default `10000`, with a non-relaxable hard ceiling of `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. Lowering this value blocks new uploads at the new limit but does not prevent startup, listing, metadata access, or revocation for existing valid records within the hard ceiling. - `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 9dbfa6b..bed69f8 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -794,6 +794,90 @@ describe("share-server", () => { await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toHaveLength(2); }); + it("keeps existing shares operable when the upload admission limit is lowered", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_MAX_RECORDS: "4" + }); + const firstUpload = await uploadEncryptedFixture(server); + const secondUpload = await uploadEncryptedFixture(server); + const thirdUpload = await uploadEncryptedFixture(server); + + await stopShareServer(server, false); + const restarted = await startShareServer( + { + WEBBLACKBOX_SHARE_MAX_RECORDS: "1" + }, + server.dataDir + ); + + const firstListResponse = await fetch(`${restarted.baseUrl}/api/share/list?limit=2&offset=0`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const firstList = (await firstListResponse.json()) as { + items: Array<{ id: string }>; + total: number; + nextOffset: number | null; + }; + expect(firstListResponse.status).toBe(200); + expect(firstList).toMatchObject({ total: 3, nextOffset: 2 }); + expect(firstList.items).toHaveLength(2); + + const secondListResponse = await fetch(`${restarted.baseUrl}/api/share/list?limit=2&offset=2`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const secondList = (await secondListResponse.json()) as { + items: Array<{ id: string }>; + total: number; + nextOffset: number | null; + }; + expect(secondListResponse.status).toBe(200); + expect(secondList).toMatchObject({ total: 3, nextOffset: null }); + expect(secondList.items).toHaveLength(1); + expect([...firstList.items, ...secondList.items].map(({ id }) => id).sort()).toEqual( + [firstUpload.shareId, secondUpload.shareId, thirdUpload.shareId].sort() + ); + + const metadataResponse = await fetch( + `${restarted.baseUrl}/api/share/${firstUpload.shareId}/meta`, + { + headers: { "x-webblackbox-api-key": apiKey } + } + ); + expect(metadataResponse.status).toBe(200); + await expect(metadataResponse.json()).resolves.toMatchObject({ id: firstUpload.shareId }); + + const revokeResponse = await fetch( + `${restarted.baseUrl}/api/share/${secondUpload.shareId}/revoke`, + { + method: "POST", + headers: { "x-webblackbox-api-key": apiKey } + } + ); + expect(revokeResponse.status).toBe(200); + await expect(revokeResponse.json()).resolves.toMatchObject({ + shareId: secondUpload.shareId + }); + + const archive = await createEncryptedEnvelopeArchive(); + const rejectedUploadResponse = await fetch(`${restarted.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) + }, + body: Buffer.from(archive) + }); + expect(rejectedUploadResponse.status).toBe(507); + await expect(rejectedUploadResponse.json()).resolves.toEqual({ + error: + "Share record capacity is exhausted (1). Revoke or expire retained shares before uploading." + }); + await expect(readdir(resolve(restarted.dataDir, "archives"))).resolves.toHaveLength(3); + }); + it("atomically persists records and reconciles interrupted storage on restart", async () => { const server = await startShareServer(); const upload = await uploadEncryptedFixture(server); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 11d3fd6..82ebfb3 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -210,9 +210,10 @@ const SHARE_READ_SESSION_COOKIE = "webblackbox_share_read"; const SHARE_READ_SESSION_TTL_MS = 10 * 60 * 1000; const MAX_SHARE_RECORD_BYTES = 256 * 1024; const SHARE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; -const MAX_SHARE_RECORDS = Math.min( +const MAX_SHARE_RECORD_FILES = 10_000; +const SHARE_RECORD_ADMISSION_LIMIT = Math.min( parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_RECORDS, 10_000), - 10_000 + MAX_SHARE_RECORD_FILES ); const MAX_SHARE_RECORD_DIRECTORY_ENTRIES = 20_000; const DEFAULT_SHARE_LIST_LIMIT = 100; @@ -434,11 +435,11 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): outcome: "blocked", details: { reason: "record-capacity", - limit: MAX_SHARE_RECORDS + limit: SHARE_RECORD_ADMISSION_LIMIT } }); respondJson(response, 507, { - error: `Share record capacity is exhausted (${MAX_SHARE_RECORDS}). Revoke or expire retained shares before uploading.` + error: `Share record capacity is exhausted (${SHARE_RECORD_ADMISSION_LIMIT}). Revoke or expire retained shares before uploading.` }); return; } @@ -1855,8 +1856,8 @@ async function loadAllRecords(): Promise { } recordFiles += 1; - if (recordFiles > MAX_SHARE_RECORDS) { - throw new Error(`Share record capacity exceeded (${MAX_SHARE_RECORDS}).`); + if (recordFiles > MAX_SHARE_RECORD_FILES) { + throw new Error(`Share record hard limit exceeded (${MAX_SHARE_RECORD_FILES}).`); } const id = entry.name.slice(0, -".json".length); @@ -1870,19 +1871,18 @@ async function loadAllRecords(): Promise { } async function hasShareRecordCapacity(): Promise { - const initialCount = await countShareRecordFiles(); - if (initialCount < MAX_SHARE_RECORDS) { + const initialCount = await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT); + if (initialCount < SHARE_RECORD_ADMISSION_LIMIT) { return true; } - if (initialCount > MAX_SHARE_RECORDS) { - return false; - } await pruneExpiredShareRecords(Date.now()); - return (await countShareRecordFiles()) < MAX_SHARE_RECORDS; + return ( + (await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT)) < SHARE_RECORD_ADMISSION_LIMIT + ); } -async function countShareRecordFiles(): Promise { +async function countShareRecordFilesUpTo(stopAt: number): Promise { const directory = await opendir(RECORDS_DIR); let directoryEntries = 0; let recordFiles = 0; @@ -1892,7 +1892,7 @@ async function countShareRecordFiles(): Promise { assertShareRecordDirectoryEntryBudget(directoryEntries); if (entry.isFile() && entry.name.endsWith(".json")) { recordFiles += 1; - if (recordFiles > MAX_SHARE_RECORDS) { + if (recordFiles >= stopAt) { return recordFiles; } } @@ -2162,7 +2162,7 @@ function parseShareListPagination(requestUrl: URL): { offset: number; limit: num requestUrl.searchParams.get("offset"), 0, 0, - MAX_SHARE_RECORDS + MAX_SHARE_RECORD_FILES ); const limit = parseBoundedQueryInteger( requestUrl.searchParams.get("limit"), From 9fb0c08392fa28a1d76394f7ddb87a0ebe248572 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:30:19 +0800 Subject: [PATCH 061/181] fix(share): correct capacity recovery guidance --- apps/share-server/src/index.test.ts | 4 ++-- apps/share-server/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index bed69f8..435c4fb 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -789,7 +789,7 @@ describe("share-server", () => { expect(response.status).toBe(507); await expect(response.json()).resolves.toEqual({ error: - "Share record capacity is exhausted (2). Revoke or expire retained shares before uploading." + "Share record capacity is exhausted (2). Wait for expired records to pass retention and be pruned, remove retained data administratively, or raise the admission limit." }); await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toHaveLength(2); }); @@ -873,7 +873,7 @@ describe("share-server", () => { expect(rejectedUploadResponse.status).toBe(507); await expect(rejectedUploadResponse.json()).resolves.toEqual({ error: - "Share record capacity is exhausted (1). Revoke or expire retained shares before uploading." + "Share record capacity is exhausted (1). Wait for expired records to pass retention and be pruned, remove retained data administratively, or raise the admission limit." }); await expect(readdir(resolve(restarted.dataDir, "archives"))).resolves.toHaveLength(3); }); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 82ebfb3..5fa2c78 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -439,7 +439,7 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): } }); respondJson(response, 507, { - error: `Share record capacity is exhausted (${SHARE_RECORD_ADMISSION_LIMIT}). Revoke or expire retained shares before uploading.` + error: `Share record capacity is exhausted (${SHARE_RECORD_ADMISSION_LIMIT}). Wait for expired records to pass retention and be pruned, remove retained data administratively, or raise the admission limit.` }); return; } From eb04cab69249efdfa2814dbcf501bc670415c7b8 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:28:58 +0800 Subject: [PATCH 062/181] fix(share): verify persisted archive integrity --- apps/share-server/src/index.test.ts | 83 ++++++++++ apps/share-server/src/index.ts | 238 +++++++++++++++++++++++++--- 2 files changed, 298 insertions(+), 23 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 435c4fb..e5c4202 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -723,6 +723,9 @@ describe("share-server", () => { const sourceRecord = JSON.parse( await readFile(resolve(server.dataDir, "records", `${upload.shareId}.json`), "utf8") ) as Record; + const sourceArchive = await readFile( + resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`) + ); const createdAt = Number(sourceRecord.createdAt); const cloneIds = ["a".repeat(32), "b".repeat(32)]; @@ -735,6 +738,7 @@ describe("share-server", () => { createdAt: createdAt - index - 1 }) ); + await writeFile(resolve(server.dataDir, "archives", `${id}.webblackbox`), sourceArchive); } const firstResponse = await fetch(`${server.baseUrl}/api/share/list?limit=2&offset=0`, { @@ -916,6 +920,85 @@ describe("share-server", () => { expect(metadataResponse.status).toBe(200); }); + it.each(["truncated", "same-size replacement"] as const)( + "removes a %s persisted archive during restart reconciliation", + async (corruption) => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const archivePath = resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`); + const storedArchive = await readFile(archivePath); + const corruptedArchive = + corruption === "truncated" + ? storedArchive.subarray(0, storedArchive.byteLength - 1) + : Buffer.alloc(storedArchive.byteLength, 0xa5); + expect(sha256Hex(corruptedArchive)).not.toBe(sha256Hex(storedArchive)); + + await stopShareServer(server, false); + await writeFile(archivePath, corruptedArchive); + const restarted = await startShareServer({}, server.dataDir); + + const listResponse = await fetch(`${restarted.baseUrl}/api/share/list`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const list = (await listResponse.json()) as { items: unknown[]; total: number }; + expect(listResponse.status).toBe(200); + expect(list).toMatchObject({ items: [], total: 0 }); + await expect(readdir(resolve(restarted.dataDir, "archives"))).resolves.toEqual([]); + await expect(readdir(resolve(restarted.dataDir, "records"))).resolves.toEqual([]); + + const metadataResponse = await fetch( + `${restarted.baseUrl}/api/share/${upload.shareId}/meta`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + const downloadResponse = await fetch( + `${restarted.baseUrl}/api/share/${upload.shareId}/archive`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + expect(metadataResponse.status).toBe(404); + expect(downloadResponse.status).toBe(404); + } + ); + + it("does not expose stale metadata or downloads after archives change at runtime", async () => { + const server = await startShareServer(); + const truncatedUpload = await uploadEncryptedFixture(server); + const truncatedPath = resolve( + server.dataDir, + "archives", + `${truncatedUpload.shareId}.webblackbox` + ); + const truncatedBytes = await readFile(truncatedPath); + await writeFile(truncatedPath, truncatedBytes.subarray(0, truncatedBytes.byteLength - 1)); + + const metadataResponse = await fetch( + `${server.baseUrl}/api/share/${truncatedUpload.shareId}/meta`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + expect(metadataResponse.status).toBe(404); + + const replacedUpload = await uploadEncryptedFixture(server); + const replacedPath = resolve( + server.dataDir, + "archives", + `${replacedUpload.shareId}.webblackbox` + ); + const replacedBytes = await readFile(replacedPath); + await writeFile(replacedPath, Buffer.alloc(replacedBytes.byteLength, 0x5a)); + + const downloadResponse = await fetch( + `${server.baseUrl}/api/share/${replacedUpload.shareId}/archive`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + expect(downloadResponse.status).toBe(404); + expect(downloadResponse.headers.get("content-type")).toContain("application/json"); + + const listResponse = await fetch(`${server.baseUrl}/api/share/list`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const list = (await listResponse.json()) as { items: unknown[]; total: number }; + expect(list).toMatchObject({ items: [], total: 0 }); + }); + it("expires shares and blocks archive download after ttl", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 5fa2c78..0831bd1 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,6 +1,16 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; -import { createReadStream } from "node:fs"; -import { appendFile, mkdir, open, opendir, rename, rm, stat } from "node:fs/promises"; +import type { BigIntStats } from "node:fs"; +import { + appendFile, + lstat, + mkdir, + open, + opendir, + rename, + rm, + stat, + type FileHandle +} from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { isIP } from "node:net"; import { join, resolve } from "node:path"; @@ -216,6 +226,8 @@ const SHARE_RECORD_ADMISSION_LIMIT = Math.min( MAX_SHARE_RECORD_FILES ); const MAX_SHARE_RECORD_DIRECTORY_ENTRIES = 20_000; +const MAX_VERIFIED_ARCHIVE_CACHE_ENTRIES = 10_000; +const ARCHIVE_CHECKSUM_BUFFER_BYTES = 64 * 1024; const DEFAULT_SHARE_LIST_LIMIT = 100; const MAX_SHARE_LIST_LIMIT = 200; const MAX_SHARE_AUDIT_LOG_BYTES = Math.min( @@ -227,6 +239,7 @@ const RATE_LIMIT_CLEANUP_INTERVAL = 64; const MAX_TRACKED_RATE_BUCKETS = 4096; const uploadRateWindows = new Map(); const shareReadSessions = new Map(); +const verifiedArchiveCache = new Map(); let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; let sharePublicOrigin = DEFAULT_BASE_URL; @@ -776,7 +789,7 @@ async function handleList( } await pruneExpiredShareRecords(Date.now()); - const records = (await loadAllRecords()).sort( + const records = (await filterRecordsWithValidArchives(await loadAllRecords())).sort( (left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id) ); const endOffset = Math.min(records.length, pagination.offset + pagination.limit); @@ -827,21 +840,18 @@ async function handleDownloadArchive( return; } - const archivePath = archivePathForId(id); + let verifiedArchive: VerifiedArchiveFile | null = null; try { - const archiveStat = await stat(archivePath); - if (!archiveStat.isFile()) { - throw new ArchiveFileNotFoundError(); - } + verifiedArchive = await openVerifiedArchive(record); response.writeHead(200, { "content-type": "application/zip", - "content-length": String(archiveStat.size), + "content-length": String(record.sizeBytes), "content-disposition": `attachment; filename="${record.fileName}"` }); - await streamArchiveDownload(request, response, archivePath); + await streamArchiveDownload(request, response, verifiedArchive.handle, record.sizeBytes); await writeShareAuditEvent(request, { action: "download", shareId: id, @@ -850,6 +860,10 @@ async function handleDownloadArchive( } catch (error) { const clientAborted = error instanceof ClientAbortedDownloadError; + if (error instanceof ArchiveIntegrityError) { + await removeStoredShare(id); + } + if (!clientAborted && !response.headersSent && !response.writableEnded) { respondJson(response, 404, { error: "Archive file not found." @@ -863,15 +877,22 @@ async function handleDownloadArchive( shareId: id, outcome: clientAborted ? "error" : "not-found" }); + } finally { + await verifiedArchive?.handle.close().catch(() => undefined); } } async function streamArchiveDownload( request: IncomingMessage, response: ServerResponse, - archivePath: string + archiveHandle: FileHandle, + sizeBytes: number ): Promise { - const source = createReadStream(archivePath); + const source = archiveHandle.createReadStream({ + autoClose: false, + start: 0, + end: sizeBytes - 1 + }); const abortError = new ClientAbortedDownloadError(); const abort = () => { if (!source.destroyed) { @@ -906,7 +927,7 @@ class ClientAbortedDownloadError extends Error { } } -class ArchiveFileNotFoundError extends Error {} +class ArchiveIntegrityError extends Error {} async function handleSharePage( request: IncomingMessage, @@ -1029,6 +1050,20 @@ async function readAvailableShareRecord( return null; } + if (!(await hasValidStoredArchive(record))) { + await removeStoredShare(id); + respondShareUnavailable(response, action, "not-found"); + await writeShareAuditEvent(request, { + action, + shareId: id, + outcome: "not-found", + details: { + reason: "archive-integrity" + } + }); + return null; + } + return record; } @@ -1762,14 +1797,14 @@ async function reconcileStorageLayout(): Promise { const recordIds = await collectStoredIds(RECORDS_DIR, ".json", ".record"); for (const id of recordIds) { - if (archiveIds.has(id) && (await readRecord(id))) { - continue; + if (archiveIds.has(id)) { + const record = await readRecord(id); + if (record && (await hasValidStoredArchive(record))) { + continue; + } } - await Promise.all([ - rm(recordPathForId(id), { force: true }), - rm(archivePathForId(id), { force: true }) - ]); + await removeStoredShare(id); archiveIds.delete(id); } @@ -1780,6 +1815,166 @@ async function reconcileStorageLayout(): Promise { } } +async function filterRecordsWithValidArchives(records: ShareRecord[]): Promise { + const verifiedRecords: ShareRecord[] = []; + + for (const record of records) { + if (await hasValidStoredArchive(record)) { + verifiedRecords.push(record); + continue; + } + + await removeStoredShare(record.id); + } + + return verifiedRecords; +} + +async function hasValidStoredArchive(record: ShareRecord): Promise { + let verifiedArchive: VerifiedArchiveFile | null = null; + + try { + verifiedArchive = await openVerifiedArchive(record); + return true; + } catch (error) { + if (!(error instanceof ArchiveIntegrityError)) { + throw error; + } + return false; + } finally { + await verifiedArchive?.handle.close().catch(() => undefined); + } +} + +type VerifiedArchiveFile = { + handle: FileHandle; +}; + +async function openVerifiedArchive(record: ShareRecord): Promise { + let handle: FileHandle | null = null; + + try { + const archivePath = archivePathForId(record.id); + const pathStat = await lstat(archivePath, { bigint: true }); + if (!pathStat.isFile() || pathStat.size !== BigInt(record.sizeBytes)) { + throw new ArchiveIntegrityError(); + } + + handle = await open(archivePath, "r"); + const openedStat = await handle.stat({ bigint: true }); + if ( + !openedStat.isFile() || + openedStat.size !== BigInt(record.sizeBytes) || + !isSameArchiveFile(pathStat, openedStat) + ) { + throw new ArchiveIntegrityError(); + } + + const statFingerprint = archiveStatFingerprint(openedStat); + const cached = verifiedArchiveCache.get(record.id); + if ( + cached?.checksumSha256 === record.checksumSha256 && + cached.statFingerprint === statFingerprint + ) { + verifiedArchiveCache.delete(record.id); + verifiedArchiveCache.set(record.id, cached); + await assertArchivePathStillReferences(archivePath, openedStat); + return { handle }; + } + + const checksumSha256 = await checksumArchiveFileHandle(handle, record.sizeBytes); + const verifiedStat = await handle.stat({ bigint: true }); + if ( + archiveStatFingerprint(verifiedStat) !== statFingerprint || + !sha256HexMatches(checksumSha256, record.checksumSha256) + ) { + throw new ArchiveIntegrityError(); + } + + await assertArchivePathStillReferences(archivePath, verifiedStat); + cacheVerifiedArchive(record.id, checksumSha256, statFingerprint); + return { handle }; + } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof ArchiveIntegrityError) { + throw error; + } + if (isFileNotFoundError(error)) { + throw new ArchiveIntegrityError(); + } + throw error; + } +} + +async function assertArchivePathStillReferences( + archivePath: string, + openedStat: BigIntStats +): Promise { + const currentPathStat = await lstat(archivePath, { bigint: true }); + if ( + !currentPathStat.isFile() || + archiveStatFingerprint(currentPathStat) !== archiveStatFingerprint(openedStat) + ) { + throw new ArchiveIntegrityError(); + } +} + +function isSameArchiveFile(pathStat: BigIntStats, openedStat: BigIntStats): boolean { + return pathStat.dev === openedStat.dev && pathStat.ino === openedStat.ino; +} + +function archiveStatFingerprint(archiveStat: BigIntStats): string { + return [ + archiveStat.dev, + archiveStat.ino, + archiveStat.mode, + archiveStat.size, + archiveStat.mtimeNs, + archiveStat.ctimeNs + ].join(":"); +} + +async function checksumArchiveFileHandle( + handle: FileHandle, + expectedBytes: number +): Promise { + const checksum = createHash("sha256"); + const buffer = Buffer.allocUnsafe(Math.min(ARCHIVE_CHECKSUM_BUFFER_BYTES, expectedBytes)); + let offset = 0; + + while (offset < expectedBytes) { + const bytesToRead = Math.min(buffer.byteLength, expectedBytes - offset); + const { bytesRead } = await handle.read(buffer, 0, bytesToRead, offset); + if (bytesRead === 0) { + throw new ArchiveIntegrityError(); + } + checksum.update(buffer.subarray(0, bytesRead)); + offset += bytesRead; + } + + return checksum.digest("hex"); +} + +function cacheVerifiedArchive(id: string, checksumSha256: string, statFingerprint: string): void { + verifiedArchiveCache.delete(id); + while (verifiedArchiveCache.size >= MAX_VERIFIED_ARCHIVE_CACHE_ENTRIES) { + const oldestId = verifiedArchiveCache.keys().next().value as string | undefined; + if (!oldestId) { + break; + } + verifiedArchiveCache.delete(oldestId); + } + verifiedArchiveCache.set(id, { checksumSha256, statFingerprint }); +} + +async function removeStoredShare(id: string): Promise { + verifiedArchiveCache.delete(id); + await Promise.all([ + rm(recordPathForId(id), { force: true }), + rm(archivePathForId(id), { force: true }) + ]); +} + async function collectStoredIds( directoryPath: string, committedSuffix: string, @@ -2200,10 +2395,7 @@ async function pruneExpiredShareRecords(now: number): Promise { continue; } - await Promise.all([ - rm(recordPathForId(record.id), { force: true }), - rm(archivePathForId(record.id), { force: true }) - ]); + await removeStoredShare(record.id); } } From 3a6ca2f8467817e630f5748ab3db1817d7706d41 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:34:26 +0800 Subject: [PATCH 063/181] fix(share): validate retention timestamp bounds --- apps/share-server/README.md | 6 +-- apps/share-server/src/index.test.ts | 51 ++++++++++++++++++++++ apps/share-server/src/index.ts | 65 +++++++++++++++++++++++++---- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 027187c..6e534c6 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -36,9 +36,9 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_UPLOAD_IDLE_TIMEOUT_MS`: maximum idle time while receiving an upload body (default `15000`). - `WEBBLACKBOX_SHARE_UPLOAD_TOTAL_TIMEOUT_MS`: maximum total upload-body receive time (default `120000`). - `WEBBLACKBOX_SHARE_ALLOW_PLAINTEXT_UPLOADS`: default `false`. Public deployments should keep this disabled so uploads must be encrypted before reaching the server. -- `WEBBLACKBOX_SHARE_DEFAULT_TTL_MS`: default share lifetime in ms (default `604800000`, seven days). -- `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days). -- `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). +- `WEBBLACKBOX_SHARE_DEFAULT_TTL_MS`: default share lifetime in ms (default `604800000`, seven days; minimum `1000`). +- `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days; minimum `1000`). It must be greater than or equal to the default TTL. +- `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). Set it to `0` to prune as soon as a share expires. TTL and retention values must be safe integers whose combined timestamp remains representable; invalid startup configuration is rejected. - `WEBBLACKBOX_SHARE_MAX_RECORDS`: upload admission limit for retained record files (default `10000`, with a non-relaxable hard ceiling of `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. Lowering this value blocks new uploads at the new limit but does not prevent startup, listing, metadata access, or revocation for existing valid records within the hard ceiling. - `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index e5c4202..db4d623 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -894,6 +894,7 @@ describe("share-server", () => { const orphanArchiveId = "c".repeat(32); const orphanRecordId = "d".repeat(32); + const unsafeExpiryId = "e".repeat(32); await writeFile( resolve(server.dataDir, "archives", `${orphanArchiveId}.webblackbox`), "orphan" @@ -902,6 +903,18 @@ describe("share-server", () => { resolve(server.dataDir, "records", `${orphanRecordId}.json`), JSON.stringify({ ...validRecord, id: orphanRecordId }) ); + await writeFile( + resolve(server.dataDir, "archives", `${unsafeExpiryId}.webblackbox`), + await readFile(resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`)) + ); + await writeFile( + resolve(server.dataDir, "records", `${unsafeExpiryId}.json`), + JSON.stringify({ + ...validRecord, + id: unsafeExpiryId, + expiresAt: Number.MAX_SAFE_INTEGER + }) + ); await writeFile(resolve(server.dataDir, "archives", ".interrupted.upload"), "partial"); await writeFile(resolve(server.dataDir, "records", ".interrupted.record"), "partial"); @@ -1019,6 +1032,44 @@ describe("share-server", () => { expect(response.status).toBe(410); }); + it("accepts zero retention and prunes an expired share on restart", async () => { + const durationEnv = { + WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000", + WEBBLACKBOX_SHARE_MAX_TTL_MS: "1000", + WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS: "0" + }; + const server = await startShareServer(durationEnv); + await uploadEncryptedFixture(server); + + await stopShareServer(server, false); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1_100)); + const restarted = await startShareServer(durationEnv, server.dataDir); + + await expect(readdir(resolve(restarted.dataDir, "records"))).resolves.toEqual([]); + await expect(readdir(resolve(restarted.dataDir, "archives"))).resolves.toEqual([]); + }); + + it("rejects unsafe or contradictory duration configuration", async () => { + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_MAX_TTL_MS: String(Number.MAX_SAFE_INTEGER) + }) + ).rejects.toThrow(/safe timestamp range/); + + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "2000", + WEBBLACKBOX_SHARE_MAX_TTL_MS: "1000" + }) + ).rejects.toThrow(/must not exceed/); + + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS: "not-a-duration" + }) + ).rejects.toThrow(/must be an integer/); + }); + it("cancels a streamed archive download when the client disconnects", async () => { const server = await startShareServer(); const archive = await createLargeEncryptedEnvelopeArchive(8 * 1024 * 1024); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 0831bd1..861b1dd 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -188,18 +188,25 @@ const ALLOW_QUERY_API_KEY = parseBooleanFlag(process.env.WEBBLACKBOX_SHARE_ALLOW const ALLOW_PLAINTEXT_SHARE_UPLOADS = parseBooleanFlag( process.env.WEBBLACKBOX_SHARE_ALLOW_PLAINTEXT_UPLOADS ); -const SHARE_DEFAULT_TTL_MS = parseDurationMs( +const SHARE_DEFAULT_TTL_MS = parseConfiguredDurationMs( + "WEBBLACKBOX_SHARE_DEFAULT_TTL_MS", process.env.WEBBLACKBOX_SHARE_DEFAULT_TTL_MS, - 7 * 24 * 60 * 60 * 1000 + 7 * 24 * 60 * 60 * 1000, + 1_000 ); -const SHARE_MAX_TTL_MS = parseDurationMs( +const SHARE_MAX_TTL_MS = parseConfiguredDurationMs( + "WEBBLACKBOX_SHARE_MAX_TTL_MS", process.env.WEBBLACKBOX_SHARE_MAX_TTL_MS, - 30 * 24 * 60 * 60 * 1000 + 30 * 24 * 60 * 60 * 1000, + 1_000 ); -const SHARE_RETAIN_EXPIRED_MS = parseDurationMs( +const SHARE_RETAIN_EXPIRED_MS = parseConfiguredDurationMs( + "WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS", process.env.WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS, - 30 * 24 * 60 * 60 * 1000 + 30 * 24 * 60 * 60 * 1000, + 0 ); +validateShareDurationConfiguration(SHARE_DEFAULT_TTL_MS, SHARE_MAX_TTL_MS, SHARE_RETAIN_EXPIRED_MS); const UPLOAD_RATE_LIMIT_MAX = parseRateLimitCount( process.env.WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX, 10 @@ -2169,6 +2176,7 @@ function normalizePersistedShareRecord(value: unknown, expectedId: string): Shar createdAt === null || expiresAt === null || expiresAt < createdAt || + addSafeIntegers(expiresAt, SHARE_RETAIN_EXPIRED_MS) === null || revokedAt === null || (revokedAt !== undefined && revokedAt < createdAt) || sizeBytes === null || @@ -3220,7 +3228,50 @@ function parseDurationMs(value: string | undefined, fallback: number): number { if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } - return Math.max(1_000, Math.floor(parsed)); + const durationMs = Math.max(1_000, Math.floor(parsed)); + return Number.isSafeInteger(durationMs) ? durationMs : fallback; +} + +function parseConfiguredDurationMs( + name: string, + value: string | undefined, + fallback: number, + minimum: number +): number { + if (value === undefined || value.trim() === "") { + return fallback; + } + + if (!/^\d+$/.test(value.trim())) { + throw new Error(`${name} must be an integer number of milliseconds.`); + } + + const durationMs = Number(value); + if (!Number.isSafeInteger(durationMs) || durationMs < minimum) { + throw new Error(`${name} must be a safe integer greater than or equal to ${minimum}.`); + } + return durationMs; +} + +function validateShareDurationConfiguration( + defaultTtlMs: number, + maxTtlMs: number, + retainExpiredMs: number +): void { + if (defaultTtlMs > maxTtlMs) { + throw new Error( + "WEBBLACKBOX_SHARE_DEFAULT_TTL_MS must not exceed WEBBLACKBOX_SHARE_MAX_TTL_MS." + ); + } + + const maximumExpiry = addSafeIntegers(Date.now(), maxTtlMs); + const maximumRetentionDeadline = + maximumExpiry === null ? null : addSafeIntegers(maximumExpiry, retainExpiredMs); + if (maximumRetentionDeadline === null) { + throw new Error( + "The configured share TTL and expired-record retention exceed the safe timestamp range." + ); + } } function resolveClientKey(request: IncomingMessage): string { From a9e7f89bcd9b62a204fa186e29c91a0da74105a9 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:36:06 +0800 Subject: [PATCH 064/181] fix(share): validate query bootstrap targets --- apps/share-server/src/index.test.ts | 26 ++++++++++++++++++++++++++ apps/share-server/src/index.ts | 16 +++++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index db4d623..10fa9bf 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1279,6 +1279,32 @@ describe("share-server", () => { expect(response.status).toBe(401); }); + it("does not create read sessions or success audits for unavailable shares", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY: "true" + }); + const missingId = "missing-share"; + + const queryResponse = await fetch( + `${server.baseUrl}/share/${missingId}?key=${encodeURIComponent(apiKey)}`, + { redirect: "manual" } + ); + expect(queryResponse.status).toBe(404); + expect(queryResponse.headers.get("location")).toBeNull(); + expect(queryResponse.headers.get("set-cookie")).toBeNull(); + + const headerResponse = await fetch(`${server.baseUrl}/share/${missingId}`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(headerResponse.status).toBe(404); + expect(headerResponse.headers.get("set-cookie")).toBeNull(); + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + const auditLog = await readFile(resolve(server.dataDir, "audit/share-access.jsonl"), "utf8"); + expect(auditLog).toContain('"outcome":"not-found"'); + expect(auditLog).not.toContain('"authorization":"query-bootstrap"'); + }); + it("does not trust Host or forwarded-protocol headers for public URLs and CORS", async () => { const server = await startShareServer(); const encryptedArchive = await createEncryptedEnvelopeArchive(); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 861b1dd..99a8d89 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -320,6 +320,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): const requiredScope = resolveRequiredShareScope(method, pathname); const auditContext = resolveShareAuditContext(method, pathname); + let issueReadSessionForPage = false; if (requiredScope) { const authorization = authorizeRequest(request, requestUrl, method, pathname, requiredScope); @@ -346,6 +347,10 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): const readShareId = requiredScope === "read" ? extractReadShareId(pathname) : null; if (readShareId && authorization.source === "query") { + const record = await readAvailableShareRecord(request, response, readShareId, "page"); + if (!record) { + return; + } issueShareReadSessionCookie(response, readShareId); await writeShareAuditEvent(request, { action: "page", @@ -365,7 +370,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): /^\/share\/[a-zA-Z0-9_-]{1,128}$/.test(pathname) && authorization.source !== "read-session" ) { - issueShareReadSessionCookie(response, readShareId); + issueReadSessionForPage = true; } } @@ -403,7 +408,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): const sharePageMatch = /^\/share\/([a-zA-Z0-9_-]{1,128})$/.exec(pathname); if (method === "GET" && sharePageMatch?.[1]) { - await handleSharePage(request, response, sharePageMatch[1]); + await handleSharePage(request, response, sharePageMatch[1], issueReadSessionForPage); return; } @@ -939,7 +944,8 @@ class ArchiveIntegrityError extends Error {} async function handleSharePage( request: IncomingMessage, response: ServerResponse, - id: string + id: string, + issueReadSession: boolean ): Promise { const record = await readAvailableShareRecord(request, response, id, "page"); @@ -947,6 +953,10 @@ async function handleSharePage( return; } + if (issueReadSession) { + issueShareReadSessionCookie(response, id); + } + const publicMetadata = buildPublicShareMetadata(record); const metadataPretty = escapeHtml(JSON.stringify(publicMetadata.summary, null, 2)); const page = ` From 7aee9d8f81b46b60f69993930ca1eedf72ec13f8 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:35:26 +0800 Subject: [PATCH 065/181] fix(share): strictly normalize persisted summaries --- apps/share-server/src/index.test.ts | 188 ++++++++++ apps/share-server/src/index.ts | 533 +++++++++++++++++++++++++--- 2 files changed, 669 insertions(+), 52 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 10fa9bf..42da92b 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -485,6 +485,164 @@ describe("share-server", () => { await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toEqual([]); }); + it("rebuilds current persisted server summaries from allowlisted public fields", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const initialResponse = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const initialPayload = (await initialResponse.json()) as { + summary: { + source: string; + clientClaim?: { privacy?: { scanner?: { status?: string } } }; + }; + }; + expect(initialResponse.status).toBe(200); + expect(initialPayload.summary.source).toBe("client-unverified"); + expect(initialPayload.summary.clientClaim?.privacy?.scanner?.status).toBe("passed"); + + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const record = JSON.parse(await readFile(recordPath, "utf8")) as { + summary: Record; + }; + const extraSecret = "persisted-extra-secret"; + record.summary = buildPersistedServerSummary(record.summary); + record.summary.extraSecret = extraSecret; + const privacy = record.summary.privacy as Record; + privacy.extraSecret = extraSecret; + const scanner = privacy.scanner as Record; + scanner.extraSecret = extraSecret; + await writeFile(recordPath, JSON.stringify(record)); + + const response = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const payload = (await response.json()) as { + summary: { + source: string; + analyzed: boolean; + extraSecret?: unknown; + privacy?: { + extraSecret?: unknown; + scanner: { status: string; extraSecret?: unknown }; + }; + }; + }; + + expect(response.status).toBe(200); + expect(payload.summary).toMatchObject({ + source: "server", + analyzed: true, + privacy: { scanner: { status: "passed" } } + }); + expect(payload.summary.extraSecret).toBeUndefined(); + expect(payload.summary.privacy?.extraSecret).toBeUndefined(); + expect(payload.summary.privacy?.scanner.extraSecret).toBeUndefined(); + expect(JSON.stringify(payload.summary)).not.toContain(extraSecret); + }); + + it("downgrades malformed current persisted server summaries to unavailable", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const record = JSON.parse(await readFile(recordPath, "utf8")) as { + summary: Record; + }; + const validSummary = buildPersistedServerSummary(record.summary); + const mutations: Array<{ + name: string; + mutate: (summary: Record) => void; + }> = [ + { + name: "encrypted type", + mutate: (summary) => { + summary.encrypted = "true"; + } + }, + { + name: "totals type", + mutate: (summary) => { + (summary.totals as Record).events = "0"; + } + }, + { + name: "totals bound", + mutate: (summary) => { + (summary.totals as Record).events = Number.MAX_SAFE_INTEGER; + } + }, + { + name: "privacy type", + mutate: (summary) => { + const privacy = summary.privacy as Record; + (privacy.scanner as Record).preEncryption = "true"; + } + } + ]; + + for (const { name, mutate } of mutations) { + record.summary = structuredClone(validSummary); + mutate(record.summary); + await writeFile(recordPath, JSON.stringify(record)); + + const response = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const payload = (await response.json()) as { + summary: { + source: string; + analyzed: boolean; + privacy?: unknown; + clientClaim?: unknown; + trust: { privateContent: string }; + }; + }; + + expect(response.status, name).toBe(200); + expect(payload.summary, name).toMatchObject({ + source: "unavailable", + analyzed: false, + trust: { privateContent: "not-analyzed" } + }); + expect(payload.summary.privacy, name).toBeUndefined(); + expect(payload.summary.clientClaim, name).toBeUndefined(); + } + }); + + it("downgrades persisted client claims that forge top-level verified privacy", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const record = JSON.parse(await readFile(recordPath, "utf8")) as { + summary: Record; + }; + const clientClaim = record.summary.clientClaim as Record; + record.summary.privacy = clientClaim.privacy; + await writeFile(recordPath, JSON.stringify(record)); + + const response = await fetch(`${server.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const payload = (await response.json()) as { + summary: { + source: string; + analyzed: boolean; + privacy?: unknown; + clientClaim?: unknown; + trust: { privateContent: string }; + }; + }; + + expect(response.status).toBe(200); + expect(payload.summary).toMatchObject({ + source: "unavailable", + analyzed: false, + trust: { privateContent: "not-analyzed" } + }); + expect(payload.summary.privacy).toBeUndefined(); + expect(payload.summary.clientClaim).toBeUndefined(); + }); + it("downgrades legacy stored client summaries instead of exposing them as analyzed", async () => { const server = await startShareServer(); const upload = await uploadEncryptedFixture(server); @@ -1609,6 +1767,36 @@ async function uploadEncryptedFixture( return (await response.json()) as { shareId: string }; } +function buildPersistedServerSummary( + clientSummary: Record +): Record { + const envelopeTotals = clientSummary.totals as Record; + const clientClaim = clientSummary.clientClaim as Record; + + return { + schemaVersion: 2, + source: "server", + analyzed: true, + encrypted: clientSummary.encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "server-analyzed" + }, + manifest: clientSummary.manifest, + totals: { + events: envelopeTotals.events, + blobs: envelopeTotals.blobs, + privacyViolations: 0, + errors: 0, + requests: 0, + actions: 0, + durationMs: envelopeTotals.durationMs + }, + topActionTriggers: [], + privacy: clientClaim.privacy + }; +} + function buildPassedShareSummary(archive: Uint8Array): unknown { return { schemaVersion: 2, diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 99a8d89..c65d026 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -2249,87 +2249,516 @@ function buildPublicShareMetadata(record: ShareRecord): { } function normalizePersistedShareSummary(value: unknown): ShareSummary { - const record = asRecord(value); - const source = record.source; - const trust = asRecord(record.trust); - const currentTrustIsConsistent = - (source === "server" && - record.analyzed === true && - trust.privateContent === "server-analyzed") || - (source === "client-unverified" && - record.analyzed === false && - trust.privateContent === "client-claim-unverified") || - (source === "unavailable" && - record.analyzed === false && - trust.privateContent === "not-analyzed"); + const record = readPersistedObject(value); + const encrypted = record?.encrypted === true; - if ( - record.schemaVersion === 2 && - trust.archiveEnvelope === "server-inspected" && - currentTrustIsConsistent - ) { - return value as ShareSummary; + if (record?.schemaVersion === 2) { + return ( + normalizeCurrentPersistedShareSummary(record) ?? unavailablePersistedShareSummary(encrypted) + ); } - const legacy = value as Partial & { - source?: string; - archiveSha256?: unknown; - }; - const legacyWithoutDigest = { ...legacy }; - delete legacyWithoutDigest.archiveSha256; - const encrypted = legacy.encrypted === true; + return ( + normalizeLegacyPersistedShareSummary(record) ?? unavailablePersistedShareSummary(encrypted) + ); +} + +function normalizeCurrentPersistedShareSummary( + record: Record +): ShareSummary | null { + if (typeof record.encrypted !== "boolean") { + return null; + } + + const trust = readPersistedObject(record.trust); + if (trust?.archiveEnvelope !== "server-inspected") { + return null; + } + + const manifest = normalizePersistedShareManifest(record.manifest); + if (manifest === null) { + return null; + } + + if (record.source === "server") { + const totals = normalizePersistedShareTotals(record.totals, "server"); + const topActionTriggers = normalizePersistedActionTriggerSummaries(record.topActionTriggers); + const privacy = normalizePersistedSharePrivacySummary(record.privacy); + if ( + record.analyzed !== true || + trust.privateContent !== "server-analyzed" || + trust.archiveDigestMatched !== undefined || + record.analysisError !== undefined || + record.clientClaim !== undefined || + totals === null || + topActionTriggers === null || + privacy === null + ) { + return null; + } + + return { + schemaVersion: 2, + source: "server", + analyzed: true, + encrypted: record.encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "server-analyzed" + }, + manifest, + totals, + topActionTriggers, + privacy + }; + } - if (source === "client" || source === "client-unverified") { - const legacyTotals = legacy.totals; + if (record.source === "client-unverified") { + const totals = normalizePersistedShareTotals(record.totals, "envelope"); + const clientClaim = normalizePersistedClientClaim(record.clientClaim); + if ( + record.analyzed !== false || + trust.privateContent !== "client-claim-unverified" || + (trust.archiveDigestMatched !== undefined && trust.archiveDigestMatched !== true) || + record.privacy !== undefined || + record.topActionTriggers !== undefined || + !isOptionalPersistedAnalysisError(record.analysisError) || + totals === null || + clientClaim === null + ) { + return null; + } return { schemaVersion: 2, source: "client-unverified", analyzed: false, - encrypted, + encrypted: record.encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "client-claim-unverified", + ...(trust.archiveDigestMatched === true ? { archiveDigestMatched: true } : {}) + }, + manifest, + totals, + clientClaim, + analysisError: + trust.archiveDigestMatched === true + ? "Encrypted private content was not analyzed by the share server; privacy fields are unverified client claims." + : "Legacy client privacy fields were not archive-digest-bound and remain unverified claims." + }; + } + + if (record.source === "unavailable") { + const totals = normalizePersistedShareTotals(record.totals, "envelope"); + if ( + record.analyzed !== false || + trust.privateContent !== "not-analyzed" || + trust.archiveDigestMatched !== undefined || + record.privacy !== undefined || + record.topActionTriggers !== undefined || + record.clientClaim !== undefined || + !isOptionalPersistedAnalysisError(record.analysisError) || + totals === null + ) { + return null; + } + + return { + schemaVersion: 2, + source: "unavailable", + analyzed: false, + encrypted: record.encrypted, + trust: { + archiveEnvelope: "server-inspected", + privateContent: "not-analyzed" + }, + manifest, + totals, + analysisError: "Stored share analysis is unavailable." + }; + } + + return null; +} + +function normalizeLegacyPersistedShareSummary( + record: Record | null +): ShareSummary | null { + if (!record || typeof record.encrypted !== "boolean") { + return null; + } + + const manifest = normalizePersistedShareManifest(record.manifest); + const totals = normalizePersistedShareTotals(record.totals, "server"); + const topActionTriggers = normalizePersistedActionTriggerSummaries(record.topActionTriggers); + const privacy = normalizePersistedSharePrivacySummary(record.privacy); + if (manifest === null || totals === null || topActionTriggers === null || privacy === null) { + return null; + } + + if (record.source === "client" || record.source === "client-unverified") { + if (record.analyzed !== undefined && typeof record.analyzed !== "boolean") { + return null; + } + + return { + schemaVersion: 2, + source: "client-unverified", + analyzed: false, + encrypted: record.encrypted, trust: { archiveEnvelope: "server-inspected", privateContent: "client-claim-unverified" }, - manifest: legacy.manifest, - totals: legacyTotals - ? { - events: legacyTotals.events, - blobs: legacyTotals.blobs, - durationMs: legacyTotals.durationMs - } - : undefined, + manifest, + totals: + totals === undefined + ? undefined + : { + events: totals.events, + ...(totals.blobs === undefined ? {} : { blobs: totals.blobs }), + durationMs: totals.durationMs + }, clientClaim: { - totals: legacyTotals - ? { - privacyViolations: legacyTotals.privacyViolations, - errors: legacyTotals.errors, - requests: legacyTotals.requests, - actions: legacyTotals.actions - } - : undefined, - topActionTriggers: legacy.topActionTriggers, - privacy: legacy.privacy + totals: + totals === undefined + ? undefined + : { + ...(totals.privacyViolations === undefined + ? {} + : { privacyViolations: totals.privacyViolations }), + ...(totals.errors === undefined ? {} : { errors: totals.errors }), + ...(totals.requests === undefined ? {} : { requests: totals.requests }), + ...(totals.actions === undefined ? {} : { actions: totals.actions }) + }, + topActionTriggers, + privacy }, analysisError: "Legacy client privacy fields were not archive-digest-bound and remain unverified claims." }; } - if (source === "server" && legacy.analyzed === true) { + if (record.source === "server" && record.analyzed === true) { return { - ...legacyWithoutDigest, schemaVersion: 2, source: "server", analyzed: true, - encrypted, + encrypted: record.encrypted, trust: { archiveEnvelope: "server-inspected", privateContent: "server-analyzed" - } - } as ShareSummary; + }, + manifest, + totals, + topActionTriggers, + privacy + }; + } + + return null; +} + +function normalizePersistedShareManifest(value: unknown): ShareSummary["manifest"] | null { + if (value === undefined) { + return undefined; + } + + const record = readPersistedObject(value); + const mode = exportManifestSchema.shape.mode.safeParse(record?.mode); + const chunkCodec = exportManifestSchema.shape.chunkCodec.safeParse(record?.chunkCodec); + const recordedAt = exportManifestSchema.shape.createdAt.safeParse(record?.recordedAt); + if (!mode.success || !chunkCodec.success || !recordedAt.success) { + return null; + } + + return { + mode: mode.data, + chunkCodec: chunkCodec.data, + recordedAt: recordedAt.data + }; +} + +function normalizePersistedShareTotals( + value: unknown, + kind: "server" | "envelope" +): ShareSummary["totals"] | null { + if (value === undefined) { + return undefined; + } + + const record = readPersistedObject(value); + if (!record) { + return null; } + const events = readPersistedNonNegativeInteger( + record.events, + SHARE_ARCHIVE_RESOURCE_LIMITS.maxEventCount + ); + const durationMs = readPersistedNonNegativeInteger(record.durationMs, Number.MAX_SAFE_INTEGER); + const blobs = readOptionalPersistedNonNegativeInteger( + record, + "blobs", + SHARE_ARCHIVE_RESOURCE_LIMITS.maxEntryCount + ); + if (events === null || durationMs === null || blobs === null) { + return null; + } + + const privacyViolations = readOptionalPersistedSummaryCount(record, "privacyViolations"); + const errors = readOptionalPersistedSummaryCount(record, "errors"); + const requests = readOptionalPersistedSummaryCount(record, "requests"); + const actions = readOptionalPersistedSummaryCount(record, "actions"); + if ( + privacyViolations === null || + errors === null || + requests === null || + actions === null || + (kind === "envelope" && + (privacyViolations !== undefined || + errors !== undefined || + requests !== undefined || + actions !== undefined)) + ) { + return null; + } + + return { + events, + ...(blobs === undefined ? {} : { blobs }), + ...(kind === "server" + ? { + ...(privacyViolations === undefined ? {} : { privacyViolations }), + ...(errors === undefined ? {} : { errors }), + ...(requests === undefined ? {} : { requests }), + ...(actions === undefined ? {} : { actions }) + } + : {}), + durationMs + }; +} + +function normalizePersistedClientClaim(value: unknown): ShareSummary["clientClaim"] | null { + if (value === undefined) { + return undefined; + } + + const record = readPersistedObject(value); + if (!record) { + return null; + } + + let totals: NonNullable["totals"]; + if (record.totals !== undefined) { + const totalsRecord = readPersistedObject(record.totals); + if (!totalsRecord) { + return null; + } + const privacyViolations = readOptionalPersistedSummaryCount(totalsRecord, "privacyViolations"); + const errors = readOptionalPersistedSummaryCount(totalsRecord, "errors"); + const requests = readOptionalPersistedSummaryCount(totalsRecord, "requests"); + const actions = readOptionalPersistedSummaryCount(totalsRecord, "actions"); + if (privacyViolations === null || errors === null || requests === null || actions === null) { + return null; + } + totals = { + ...(privacyViolations === undefined ? {} : { privacyViolations }), + ...(errors === undefined ? {} : { errors }), + ...(requests === undefined ? {} : { requests }), + ...(actions === undefined ? {} : { actions }) + }; + } + + const topActionTriggers = normalizePersistedActionTriggerSummaries(record.topActionTriggers); + const privacy = normalizePersistedSharePrivacySummary(record.privacy); + if (topActionTriggers === null || privacy === null) { + return null; + } + + return { totals, topActionTriggers, privacy }; +} + +function normalizePersistedSharePrivacySummary(value: unknown): ShareSummary["privacy"] | null { + if (value === undefined) { + return undefined; + } + + const record = readPersistedObject(value); + const redaction = readPersistedObject(record?.redaction); + const detected = readPersistedObject(record?.detected); + const scanner = readPersistedObject(record?.scanner); + if (!record || !redaction || !detected || !scanner) { + return null; + } + + const headerRuleCount = readPersistedSummaryCount(redaction.headerRuleCount); + const cookieRuleCount = readPersistedSummaryCount(redaction.cookieRuleCount); + const bodyPatternCount = readPersistedSummaryCount(redaction.bodyPatternCount); + const blockedSelectorCount = readPersistedSummaryCount(redaction.blockedSelectorCount); + const redactedMarkers = readPersistedSummaryCount(detected.redactedMarkers); + const hashedSensitiveValues = readPersistedSummaryCount(detected.hashedSensitiveValues); + const sensitiveKeyMentions = readPersistedSummaryCount(detected.sensitiveKeyMentions); + const findingCount = readPersistedSummaryCount(scanner.findingCount); + if ( + typeof redaction.hashSensitiveValues !== "boolean" || + headerRuleCount === null || + cookieRuleCount === null || + bodyPatternCount === null || + blockedSelectorCount === null || + redactedMarkers === null || + hashedSensitiveValues === null || + sensitiveKeyMentions === null || + typeof scanner.preEncryption !== "boolean" || + (scanner.status !== "passed" && scanner.status !== "blocked" && scanner.status !== "unknown") || + findingCount === null + ) { + return null; + } + + const categories = normalizePersistedCategorySummaries(record.categories); + if (categories === null) { + return null; + } + + return { + redaction: { + hashSensitiveValues: redaction.hashSensitiveValues, + headerRuleCount, + cookieRuleCount, + bodyPatternCount, + blockedSelectorCount + }, + detected: { + redactedMarkers, + hashedSensitiveValues, + sensitiveKeyMentions + }, + scanner: { + preEncryption: scanner.preEncryption, + status: scanner.status, + findingCount + }, + categories + }; +} + +function normalizePersistedActionTriggerSummaries( + value: unknown +): ShareSummary["topActionTriggers"] | null { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || value.length > 10) { + return null; + } + + const summaries: NonNullable = []; + for (const entry of value) { + const record = readPersistedObject(entry); + const triggerType = readPersistedPublicText(record?.triggerType, 64); + const count = readPersistedSummaryCount(record?.count); + const errorRate = record?.errorRate; + if ( + triggerType === null || + count === null || + typeof errorRate !== "number" || + !Number.isFinite(errorRate) || + errorRate < 0 || + errorRate > 1 + ) { + return null; + } + summaries.push({ triggerType, count, errorRate }); + } + return summaries; +} + +function normalizePersistedCategorySummaries( + value: unknown +): NonNullable["categories"] | null { + if (value === undefined) { + return undefined; + } + if (!Array.isArray(value) || value.length > 24) { + return null; + } + + const summaries: NonNullable["categories"]> = []; + for (const entry of value) { + const record = readPersistedObject(entry); + const category = readPersistedPublicText(record?.category, 32); + const events = readPersistedSummaryCount(record?.events); + const low = readPersistedSummaryCount(record?.low); + const medium = readPersistedSummaryCount(record?.medium); + const high = readPersistedSummaryCount(record?.high); + const redacted = readPersistedSummaryCount(record?.redacted); + const unredacted = readPersistedSummaryCount(record?.unredacted); + if ( + category === null || + events === null || + low === null || + medium === null || + high === null || + redacted === null || + unredacted === null + ) { + return null; + } + summaries.push({ category, events, low, medium, high, redacted, unredacted }); + } + return summaries; +} + +function readPersistedObject(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readPersistedNonNegativeInteger(value: unknown, maximum: number): number | null { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum + ? value + : null; +} + +function readOptionalPersistedNonNegativeInteger( + record: Record | null, + key: string, + maximum: number +): number | undefined | null { + if (!record || !(key in record)) { + return undefined; + } + return readPersistedNonNegativeInteger(record[key], maximum); +} + +function readPersistedSummaryCount(value: unknown): number | null { + return readPersistedNonNegativeInteger( + value, + SHARE_ARCHIVE_RESOURCE_LIMITS.maxTotalUncompressedBytes + ); +} + +function readOptionalPersistedSummaryCount( + record: Record, + key: string +): number | undefined | null { + return key in record ? readPersistedSummaryCount(record[key]) : undefined; +} + +function readPersistedPublicText(value: unknown, maximumLength: number): string | null { + if (typeof value !== "string" || value.length === 0 || value.length > maximumLength) { + return null; + } + return redactText(value, maximumLength); +} + +function isOptionalPersistedAnalysisError(value: unknown): boolean { + return value === undefined || (typeof value === "string" && value.length <= 240); +} +function unavailablePersistedShareSummary(encrypted: boolean): ShareSummary { return { schemaVersion: 2, source: "unavailable", From 03c2fae0984d9fe312de489c59c4293691e680ed Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:35:21 +0800 Subject: [PATCH 066/181] fix(share): require credentials on loopback by default --- apps/share-server/README.md | 7 ++- apps/share-server/src/index.test.ts | 94 +++++++++++++++++++++++++++-- apps/share-server/src/index.ts | 49 +++++++++++---- docs/ENTERPRISE_ADMIN.md | 5 ++ 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 6e534c6..47a31bf 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -14,7 +14,7 @@ It provides: ```bash cd apps/share-server -pnpm dev +WEBBLACKBOX_SHARE_API_KEYS="local-dev:admin" pnpm dev ``` By default the server listens on `http://127.0.0.1:8787`. @@ -23,10 +23,11 @@ By default the server listens on `http://127.0.0.1:8787`. Set these environment variables for production-like deployments: -- `WEBBLACKBOX_SHARE_API_KEY`: API key for `/api/share/*` and `/share/*` routes. If unset, protected routes are limited to loopback clients (`127.0.0.1` / `::1`). When set, clients can authenticate with either: +- `WEBBLACKBOX_SHARE_API_KEY`: legacy single admin API key for `/api/share/*` and `/share/*` routes. Protected routes require a configured credential by default, including for direct loopback clients. Clients can authenticate with either: - `x-webblackbox-api-key: `, or - `authorization: Bearer ` - `WEBBLACKBOX_SHARE_API_KEYS`: semicolon-separated scoped keys for rotation and least privilege. Format: `secret:scope,scope;next-secret:scope`. Supported scopes are `upload`, `read`, `list`, `revoke`, and `admin`. `admin` covers all scopes. Unknown or empty scopes and duplicate secrets fail startup instead of falling back to `admin`. A key without `:scope` retains the legacy explicit-admin behavior. Keep an old key and a new key configured during rotation, then remove the old key after clients are updated. +- `WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK`: development-only opt-out, default `false`. When explicitly enabled, protected routes may be used without a credential only when the actual socket peer, bind host, and public origin are loopback. Startup fails if proxy/forwarded-client configuration is present or either endpoint is non-loopback. Never enable this behind a reverse proxy; use scoped credentials instead. - `WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY`: optional browser bootstrap for `GET /share/:id?key=`. Keep this disabled in production unless the key is short-lived; when enabled, the server redirects to a clean URL and uses a short HttpOnly read-session cookie for page links. - `WEBBLACKBOX_SHARE_BIND_HOST`: bind host for the HTTP server (default `127.0.0.1`). - `WEBBLACKBOX_SHARE_PUBLIC_ORIGIN`: canonical external origin used for share URLs, same-origin CORS, and secure-cookie policy. It is required when binding to a non-loopback host, must be HTTPS unless it names a loopback host, and must not include credentials, a path, query, or fragment. A non-loopback public origin also requires an API credential, including when a local reverse proxy fronts a loopback bind. Request `Host` and `X-Forwarded-Proto` headers are never used as public-origin authority. @@ -71,7 +72,7 @@ Headers: - `x-webblackbox-filename: ` - `x-webblackbox-share-summary: ` - `x-webblackbox-share-ttl-ms: ` -- `x-webblackbox-api-key: ` +- `x-webblackbox-api-key: ` Body: diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 42da92b..4eb7fe1 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1556,6 +1556,90 @@ describe("share-server", () => { ).rejects.toThrow(/contains an invalid IP address/); }); + it("denies protected routes from direct loopback clients when no credential is configured", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_API_KEY: "", + WEBBLACKBOX_SHARE_API_KEYS: "" + }); + const requests = [ + ["GET", "/api/share/list"], + ["POST", "/api/share/upload"], + ["GET", "/api/share/example/meta"], + ["GET", "/api/share/example/archive"], + ["GET", "/share/example"], + ["POST", "/api/share/example/revoke"] + ] as const; + + for (const [method, pathname] of requests) { + const response = await fetch(`${server.baseUrl}${pathname}`, { method }); + expect(response.status, `${method} ${pathname}`).toBe(401); + } + }); + + it("allows explicit development-only unauthenticated access from a loopback peer", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_API_KEY: "", + WEBBLACKBOX_SHARE_API_KEYS: "", + WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK: "true" + }); + + const response = await fetch(`${server.baseUrl}/api/share/list`); + + expect(response.status).toBe(200); + expect(server.logs.join("")).toContain( + "WARNING: unauthenticated loopback development access is enabled" + ); + }); + + it("rejects the unauthenticated loopback development opt-out in proxy modes", async () => { + const baseOverrides = { + WEBBLACKBOX_SHARE_API_KEY: "", + WEBBLACKBOX_SHARE_API_KEYS: "", + WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK: "true" + }; + + await expect( + startShareServer({ + ...baseOverrides, + WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true" + }) + ).rejects.toThrow(/must not be enabled with proxy or forwarded-client configuration/); + + await expect( + startShareServer({ + ...baseOverrides, + WEBBLACKBOX_TRUSTED_PROXY_IPS: "203.0.113.10" + }) + ).rejects.toThrow(/must not be enabled with proxy or forwarded-client configuration/); + }); + + it("rejects the unauthenticated loopback development opt-out for public endpoints", async () => { + const baseOverrides = { + WEBBLACKBOX_SHARE_API_KEY: "", + WEBBLACKBOX_SHARE_API_KEYS: "", + WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK: "true", + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "https://shares.example.test" + }; + + await expect(startShareServer(baseOverrides)).rejects.toThrow( + /requires both a loopback bind host and loopback public origin/ + ); + + await expect( + startShareServer({ + ...baseOverrides, + WEBBLACKBOX_SHARE_BIND_HOST: "0.0.0.0" + }) + ).rejects.toThrow(/requires both a loopback bind host and loopback public origin/); + + await expect( + startShareServer({ + ...baseOverrides, + WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "https://127.attacker.example" + }) + ).rejects.toThrow(/requires both a loopback bind host and loopback public origin/); + }); + it("derives forwarded clients from the first untrusted hop next to trusted proxies", async () => { const server = await startShareServer({ WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true" @@ -1627,9 +1711,13 @@ async function startShareServer( ...process.env, PORT: String(port), WEBBLACKBOX_SHARE_API_KEY: apiKey, + WEBBLACKBOX_SHARE_API_KEYS: "", + WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK: "false", WEBBLACKBOX_SHARE_BIND_HOST: "127.0.0.1", WEBBLACKBOX_SHARE_PUBLIC_ORIGIN: "", WEBBLACKBOX_SHARE_DATA_DIR: dataDir, + WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "false", + WEBBLACKBOX_TRUSTED_PROXY_IPS: "", ...envOverrides }, stdio: ["ignore", "pipe", "pipe"] @@ -1688,15 +1776,13 @@ async function waitForShareServer(server: RunningShareServer): Promise { } try { - const response = await fetch(`${server.baseUrl}/api/share/list`, { + await fetch(`${server.baseUrl}/api/share/list`, { headers: { "x-webblackbox-api-key": apiKey } }); - if (response.ok) { - return; - } + return; } catch { // retry until the process binds the port } diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index c65d026..f65aa6a 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -45,7 +45,7 @@ type ShareRecord = { type ShareAuditAction = "upload" | "list" | "metadata" | "download" | "page" | "revoke"; type ShareAuditOutcome = "ok" | "not-found" | "expired" | "revoked" | "blocked" | "error"; -type ShareAuthorizationSource = "loopback" | "token" | "query" | "read-session"; +type ShareAuthorizationSource = "unauthenticated-loopback-dev" | "token" | "query" | "read-session"; type ShareAuthorizationResult = { authorized: boolean; source?: ShareAuthorizationSource; @@ -184,6 +184,9 @@ const TRUST_X_FORWARDED_FOR = parseBooleanFlag(process.env.WEBBLACKBOX_TRUST_X_F const TRUSTED_PROXY_ADDRESSES = parseTrustedProxyAddresses( process.env.WEBBLACKBOX_TRUSTED_PROXY_IPS ); +const ALLOW_UNAUTHENTICATED_LOOPBACK = parseBooleanFlag( + process.env.WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK +); const ALLOW_QUERY_API_KEY = parseBooleanFlag(process.env.WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY); const ALLOW_PLAINTEXT_SHARE_UPLOADS = parseBooleanFlag( process.env.WEBBLACKBOX_SHARE_ALLOW_PLAINTEXT_UPLOADS @@ -261,6 +264,7 @@ async function startShareServer(): Promise { const port = parsePort(process.env.PORT); const host = parseBindHost(process.env.WEBBLACKBOX_SHARE_BIND_HOST); sharePublicOrigin = resolvePublicOrigin(process.env.WEBBLACKBOX_SHARE_PUBLIC_ORIGIN, host, port); + validateAuthorizationConfiguration(host, sharePublicOrigin); if (SHARE_API_CREDENTIALS.length === 0 && !isLoopbackHost(new URL(sharePublicOrigin).hostname)) { throw new Error( "A scoped WEBBLACKBOX_SHARE_API_KEY or WEBBLACKBOX_SHARE_API_KEYS credential is required for a non-loopback public origin." @@ -302,6 +306,11 @@ async function startShareServer(): Promise { console.info(`[share-server] listening on http://${host}:${port}`); console.info(`[share-server] public origin: ${sharePublicOrigin}`); console.info(`[share-server] data root: ${DATA_ROOT}`); + if (ALLOW_UNAUTHENTICATED_LOOPBACK) { + console.warn( + "[share-server] WARNING: unauthenticated loopback development access is enabled." + ); + } }); } @@ -3007,7 +3016,11 @@ function isLoopbackHost(host: string): boolean { .trim() .toLowerCase() .replace(/^\[|\]$/g, ""); - return normalized === "localhost" || normalized === "::1" || normalized.startsWith("127."); + return ( + normalized === "localhost" || + normalized === "::1" || + (isIP(normalized) === 4 && normalized.startsWith("127.")) + ); } function formatHostForUrl(host: string): string { @@ -3015,6 +3028,24 @@ function formatHostForUrl(host: string): string { return normalized.includes(":") ? `[${normalized}]` : normalized; } +function validateAuthorizationConfiguration(bindHost: string, publicOrigin: string): void { + if (!ALLOW_UNAUTHENTICATED_LOOPBACK) { + return; + } + + if (!isLoopbackHost(bindHost) || !isLoopbackHost(new URL(publicOrigin).hostname)) { + throw new Error( + "WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK is development-only and requires both a loopback bind host and loopback public origin." + ); + } + + if (TRUST_X_FORWARDED_FOR || TRUSTED_PROXY_ADDRESSES.size > 0) { + throw new Error( + "WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK must not be enabled with proxy or forwarded-client configuration." + ); + } +} + function normalizeAllowedOrigin(rawValue: string | undefined): string { const resolved = (rawValue ?? "same-origin").trim(); @@ -3411,15 +3442,11 @@ function authorizeRequest( pathname: string, requiredScope: ShareApiScope ): ShareAuthorizationResult { - if (SHARE_API_CREDENTIALS.length === 0) { - return isLoopbackRequest(request) - ? { - authorized: true, - source: "loopback" - } - : { - authorized: false - }; + if (ALLOW_UNAUTHENTICATED_LOOPBACK && isLoopbackRequest(request)) { + return { + authorized: true, + source: "unauthenticated-loopback-dev" + }; } const headerToken = readAuthTokenFromRequest(request); diff --git a/docs/ENTERPRISE_ADMIN.md b/docs/ENTERPRISE_ADMIN.md index 41dad44..a478124 100644 --- a/docs/ENTERPRISE_ADMIN.md +++ b/docs/ENTERPRISE_ADMIN.md @@ -42,6 +42,11 @@ WEBBLACKBOX_SHARE_MAX_TTL_MS=2592000000 WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS=2592000000 ``` +Protected Share routes require scoped credentials even when the server is bound directly to +loopback. Do not enable `WEBBLACKBOX_SHARE_ALLOW_UNAUTHENTICATED_LOOPBACK` in a self-hosted or +reverse-proxied deployment; it is a development-only opt-out and is incompatible with proxy or +forwarded-client configuration. + Rotate share API keys by deploying old and new scoped keys together, moving clients to the new key, confirming audit activity, then removing the old key. ## Audit Logs From 47ee0bc9605db8c50fec5cb112ad4e1da8b3c99c Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:39:23 +0800 Subject: [PATCH 067/181] fix(share): reserve concurrent upload capacity --- apps/share-server/src/index.test.ts | 32 +++++++++++++++++++++++++ apps/share-server/src/index.ts | 37 ++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 4eb7fe1..021644d 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -956,6 +956,38 @@ describe("share-server", () => { await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toHaveLength(2); }); + it("reserves record capacity across concurrent upload inspections", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_MAX_RECORDS: "1", + WEBBLACKBOX_SHARE_MAX_CONCURRENT_INSPECTIONS: "2" + }); + const archive = await createEncryptedEnvelopeArchive(); + const headers = { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-webblackbox-share-summary": encodeURIComponent( + JSON.stringify(buildPassedShareSummary(archive)) + ) + }; + + const responses = await Promise.all([ + fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers, + body: Buffer.from(archive) + }), + fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers, + body: Buffer.from(archive) + }) + ]); + + expect(responses.map(({ status }) => status).sort()).toEqual([201, 507]); + await expect(readdir(resolve(server.dataDir, "records"))).resolves.toHaveLength(1); + await expect(readdir(resolve(server.dataDir, "archives"))).resolves.toHaveLength(1); + }); + it("keeps existing shares operable when the upload admission limit is lowered", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_MAX_RECORDS: "4" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index f65aa6a..fb10642 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -252,8 +252,10 @@ const shareReadSessions = new Map(); const verifiedArchiveCache = new Map(); let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; +let reservedShareRecordSlots = 0; let sharePublicOrigin = DEFAULT_BASE_URL; let auditWriteQueue = Promise.resolve(); +let shareRecordAdmissionQueue = Promise.resolve(); void startShareServer().catch((error) => { console.error("[share-server] startup failed", error); @@ -461,8 +463,10 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): return; } + let reservedRecordSlot = false; try { - if (!(await hasShareRecordCapacity())) { + reservedRecordSlot = await tryReserveShareRecordSlot(); + if (!reservedRecordSlot) { closeRequestAfterResponse(request, response); await writeShareAuditEvent(request, { action: "upload", @@ -480,6 +484,9 @@ async function handleUpload(request: IncomingMessage, response: ServerResponse): await handleUploadWithinInspectionSlot(request, response); } finally { + if (reservedRecordSlot) { + reservedShareRecordSlots -= 1; + } activeUploadInspections -= 1; } } @@ -2091,16 +2098,28 @@ async function loadAllRecords(): Promise { return records; } -async function hasShareRecordCapacity(): Promise { - const initialCount = await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT); - if (initialCount < SHARE_RECORD_ADMISSION_LIMIT) { - return true; - } +async function tryReserveShareRecordSlot(): Promise { + const reservation = shareRecordAdmissionQueue.then(async () => { + const initialCount = await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT); + if (initialCount + reservedShareRecordSlots < SHARE_RECORD_ADMISSION_LIMIT) { + reservedShareRecordSlots += 1; + return true; + } - await pruneExpiredShareRecords(Date.now()); - return ( - (await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT)) < SHARE_RECORD_ADMISSION_LIMIT + await pruneExpiredShareRecords(Date.now()); + const retainedCount = await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT); + if (retainedCount + reservedShareRecordSlots >= SHARE_RECORD_ADMISSION_LIMIT) { + return false; + } + + reservedShareRecordSlots += 1; + return true; + }); + shareRecordAdmissionQueue = reservation.then( + () => undefined, + () => undefined ); + return reservation; } async function countShareRecordFilesUpTo(stopAt: number): Promise { From 8ca4291e80e0cb484de2aaaf4ec5b4f441f55616 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:39:05 +0800 Subject: [PATCH 068/181] fix(share): drain audits during graceful shutdown --- apps/share-server/README.md | 1 + apps/share-server/src/index.test.ts | 171 ++++++++++++++++++++++- apps/share-server/src/index.ts | 207 +++++++++++++++++++++++----- 3 files changed, 343 insertions(+), 36 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 47a31bf..bfa5b11 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -42,6 +42,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). Set it to `0` to prune as soon as a share expires. TTL and retention values must be safe integers whose combined timestamp remains representable; invalid startup configuration is rejected. - `WEBBLACKBOX_SHARE_MAX_RECORDS`: upload admission limit for retained record files (default `10000`, with a non-relaxable hard ceiling of `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. Lowering this value blocks new uploads at the new limit but does not prevent startup, listing, metadata access, or revocation for existing valid records within the hard ceiling. - `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. +- `WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS`: maximum time to drain in-flight requests and queued audit events after `SIGINT` or `SIGTERM` (default `10000`, hard ceiling `60000`). A timeout forces remaining connections closed and terminates the process. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). - `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: enables proxy-aware client addresses for rate limiting and redacted audit hashes only; authorization never trusts forwarded headers. Forwarded chains are used only when the socket peer is loopback or listed in `WEBBLACKBOX_TRUSTED_PROXY_IPS`, and the first untrusted hop is selected from right to left. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 021644d..9c45230 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1,7 +1,8 @@ -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; import { createRequire } from "node:module"; -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; import { request as createHttpRequest } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; @@ -1399,6 +1400,76 @@ describe("share-server", () => { expect(metadataResponse.status).toBe(200); expect(server.child.exitCode).toBeNull(); expect(server.logs.join("")).toContain("[share-server] audit append failed"); + await expect(terminateShareServer(server)).resolves.toEqual({ code: 0, signal: null }); + }); + + it("drains an accepted operation audit before exiting on SIGTERM", async () => { + const server = await startShareServer(); + const uploadPayload = await uploadEncryptedFixture(server); + const metadataResponse = await fetch( + `${server.baseUrl}/api/share/${uploadPayload.shareId}/meta`, + { + headers: { + "x-webblackbox-api-key": apiKey + } + } + ); + + expect(metadataResponse.status).toBe(200); + await metadataResponse.arrayBuffer(); + + const exit = await terminateShareServer(server); + const auditLog = await readFile(resolve(server.dataDir, "audit/share-access.jsonl"), "utf8"); + const auditEvents = auditLog + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { action: string; shareId?: string }); + + expect(exit).toEqual({ code: 0, signal: null }); + expect(auditEvents).toContainEqual( + expect.objectContaining({ + action: "metadata", + shareId: uploadPayload.shareId + }) + ); + expect(server.logs.join("")).toContain("[share-server] shutdown drain complete"); + }); + + it("bounds shutdown when the audit sink cannot drain", async () => { + if (process.platform === "win32") { + return; + } + + const server = await startShareServer({ + WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS: "150" + }); + const uploadPayload = await uploadEncryptedFixture(server); + const auditPath = resolve(server.dataDir, "audit/share-access.jsonl"); + await waitForAuditEvent(server, "upload", uploadPayload.shareId); + await rm(auditPath, { force: true }); + const auditReader = await createFullNamedPipe(auditPath); + + try { + const metadataResponse = await fetch( + `${server.baseUrl}/api/share/${uploadPayload.shareId}/meta`, + { + headers: { + "x-webblackbox-api-key": apiKey + } + } + ); + expect(metadataResponse.status).toBe(200); + await metadataResponse.arrayBuffer(); + await new Promise((resolvePromise) => setTimeout(resolvePromise, 50)); + + const startedAt = Date.now(); + const exit = await terminateShareServer(server); + + expect(exit.signal === "SIGKILL" || exit.code === 128 + 9).toBe(true); + expect(Date.now() - startedAt).toBeLessThan(2_000); + } finally { + await auditReader.close(); + } }); it("enforces scoped API keys", async () => { @@ -1731,6 +1802,102 @@ describe("share-server", () => { }); }); +async function terminateShareServer( + server: RunningShareServer +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (server.child.exitCode !== null || server.child.signalCode !== null) { + return { + code: server.child.exitCode, + signal: server.child.signalCode + }; + } + + return await new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + server.child.kill("SIGKILL"); + reject(new Error(`share-server did not exit after SIGTERM: ${server.logs.join("")}`)); + }, 2_500); + server.child.once("exit", (code, signal) => { + clearTimeout(timeout); + resolvePromise({ code, signal }); + }); + + if (!server.child.kill("SIGTERM")) { + clearTimeout(timeout); + reject(new Error("Failed to send SIGTERM to share-server.")); + } + }); +} + +async function waitForAuditEvent( + server: RunningShareServer, + action: string, + shareId: string +): Promise { + const auditPath = resolve(server.dataDir, "audit/share-access.jsonl"); + const deadline = Date.now() + 2_000; + + while (Date.now() < deadline) { + try { + const lines = (await readFile(auditPath, "utf8")).trim().split("\n"); + const found = lines.some((line) => { + const event = JSON.parse(line) as { action?: string; shareId?: string }; + return event.action === action && event.shareId === shareId; + }); + if (found) { + return; + } + } catch { + // Audit creation may still be in flight after the HTTP response completes. + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } + + throw new Error(`Timed out waiting for ${action} audit event for ${shareId}.`); +} + +async function createFullNamedPipe(path: string): Promise>> { + const fifoResult = spawnSync("mkfifo", [path]); + if (fifoResult.status !== 0) { + throw new Error(`Failed to create audit FIFO: ${String(fifoResult.stderr)}`); + } + + const reader = await open(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + const writer = await open(path, fsConstants.O_WRONLY | fsConstants.O_NONBLOCK); + const chunk = Buffer.alloc(4 * 1024, 0x78); + + try { + for (let written = 0; written < 16 * 1024 * 1024; ) { + try { + const result = await writer.write(chunk); + if (result.bytesWritten <= 0) { + break; + } + written += result.bytesWritten; + } catch (error) { + if ( + error instanceof Error && + "code" in error && + ((error as Error & { code?: string }).code === "EAGAIN" || + (error as Error & { code?: string }).code === "EWOULDBLOCK") + ) { + return reader; + } + throw error; + } + } + } catch (error) { + await reader.close(); + throw error; + } finally { + await writer.close(); + } + + await reader.close(); + throw new Error("Failed to fill audit FIFO before the safety limit."); +} + async function startShareServer( envOverrides: Record = {}, existingDataDir?: string diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index fb10642..4007853 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -11,7 +11,7 @@ import { stat, type FileHandle } from "node:fs/promises"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { isIP } from "node:net"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -244,6 +244,11 @@ const MAX_SHARE_AUDIT_LOG_BYTES = Math.min( parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES, 16 * 1024 * 1024), 64 * 1024 * 1024 ); +const MAX_SHARE_SHUTDOWN_TIMEOUT_MS = 60_000; +const SHARE_SHUTDOWN_TIMEOUT_MS = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS, 10_000), + MAX_SHARE_SHUTDOWN_TIMEOUT_MS +); const MAX_SHARE_READ_SESSIONS = 4096; const RATE_LIMIT_CLEANUP_INTERVAL = 64; const MAX_TRACKED_RATE_BUCKETS = 4096; @@ -276,33 +281,28 @@ async function startShareServer(): Promise { await reconcileStorageLayout(); await pruneExpiredShareRecords(Date.now()); + const activeRequests = new Set>(); + let acceptingRequests = true; const server = createServer((request, response) => { - void routeRequest(request, response).catch(async (error) => { - console.warn("[share-server] request failed", error); - - const auditContext = resolveShareAuditRequest(request); - if (auditContext) { - await writeShareAuditEvent(request, { - ...auditContext, - outcome: "error", - details: { - reason: "request-failed" - } - }); - } - - if (response.headersSent || response.writableEnded) { - if (!response.writableEnded) { - response.destroy(error instanceof Error ? error : undefined); - } - return; - } - - respondJson(response, 500, { - error: "Internal server error." + if (!acceptingRequests) { + response.setHeader("connection", "close"); + respondJson(response, 503, { + error: "Share server is shutting down." }); - }); + return; + } + + const requestTask = handleRequest(request, response); + activeRequests.add(requestTask); + void requestTask.then( + () => activeRequests.delete(requestTask), + () => activeRequests.delete(requestTask) + ); + }); + const removeSignalHandlers = installGracefulShutdownHandlers(server, activeRequests, () => { + acceptingRequests = false; }); + server.once("close", removeSignalHandlers); server.listen(port, host, () => { console.info(`[share-server] listening on http://${host}:${port}`); @@ -316,6 +316,140 @@ async function startShareServer(): Promise { }); } +async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise { + try { + await routeRequest(request, response); + } catch (error) { + console.warn("[share-server] request failed", error); + + const auditContext = resolveShareAuditRequest(request); + if (auditContext) { + await writeShareAuditEvent(request, { + ...auditContext, + outcome: "error", + details: { + reason: "request-failed" + } + }); + } + + if (response.headersSent || response.writableEnded) { + if (!response.writableEnded) { + response.destroy(error instanceof Error ? error : undefined); + } + return; + } + + respondJson(response, 500, { + error: "Internal server error." + }); + } +} + +function installGracefulShutdownHandlers( + server: Server, + activeRequests: Set>, + stopAcceptingRequests: () => void +): () => void { + let shutdownStarted = false; + const handleSigterm = () => beginShutdown("SIGTERM"); + const handleSigint = () => beginShutdown("SIGINT"); + const removeHandlers = () => { + process.off("SIGTERM", handleSigterm); + process.off("SIGINT", handleSigint); + }; + const beginShutdown = (signal: "SIGINT" | "SIGTERM") => { + if (shutdownStarted) { + return; + } + + shutdownStarted = true; + stopAcceptingRequests(); + removeHandlers(); + void shutdownShareServer(server, activeRequests, signal).then( + (drained) => { + if (drained) { + process.exit(0); + } + + process.kill(process.pid, "SIGKILL"); + }, + (error) => { + console.error("[share-server] shutdown failed", error); + process.exit(1); + } + ); + }; + + process.once("SIGTERM", handleSigterm); + process.once("SIGINT", handleSigint); + return removeHandlers; +} + +async function shutdownShareServer( + server: Server, + activeRequests: Set>, + signal: "SIGINT" | "SIGTERM" +): Promise { + console.info(`[share-server] received ${signal}; draining requests and audit events`); + const closePromise = closeHttpServer(server); + const drainPromise = (async () => { + await waitForActiveRequests(activeRequests); + await auditWriteQueue; + await closePromise; + })(); + const drained = await waitWithinTimeout(drainPromise, SHARE_SHUTDOWN_TIMEOUT_MS); + + if (!drained) { + server.closeAllConnections(); + console.warn( + `[share-server] shutdown drain timed out after ${SHARE_SHUTDOWN_TIMEOUT_MS}ms; forcing exit` + ); + return false; + } + + console.info("[share-server] shutdown drain complete"); + return true; +} + +async function waitForActiveRequests(activeRequests: Set>): Promise { + while (activeRequests.size > 0) { + await Promise.allSettled([...activeRequests]); + } +} + +function closeHttpServer(server: Server): Promise { + if (!server.listening) { + return Promise.resolve(); + } + + const closed = new Promise((resolvePromise) => { + server.close((error) => { + if (error) { + console.warn("[share-server] HTTP close failed", error); + } + resolvePromise(); + }); + }); + server.closeIdleConnections(); + return closed; +} + +async function waitWithinTimeout(promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolvePromise) => { + timeout = setTimeout(() => resolvePromise(false), timeoutMs); + }); + + try { + return await Promise.race([promise.then(() => true), timedOut]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + async function routeRequest(request: IncomingMessage, response: ServerResponse): Promise { const requestUrl = new URL(request.url ?? "/", sharePublicOrigin); applyCorsHeaders(response, request); @@ -363,7 +497,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): return; } issueShareReadSessionCookie(response, readShareId); - await writeShareAuditEvent(request, { + const auditPromise = writeShareAuditEvent(request, { action: "page", shareId: readShareId, outcome: "ok", @@ -372,6 +506,7 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): } }); redirectToUrlWithoutQueryKey(response, requestUrl); + await auditPromise; return; } @@ -776,7 +911,7 @@ async function processUploadWithinInspectionSlot( await syncDirectoryBestEffort(ARCHIVES_DIR); await writeRecord(record); upload.committed = true; - await writeShareAuditEvent(request, { + const auditPromise = writeShareAuditEvent(request, { action: "upload", shareId: id, outcome: "ok", @@ -785,7 +920,6 @@ async function processUploadWithinInspectionSlot( ttlMs } }); - respondJson(response, 201, { shareId: id, shareUrl, @@ -794,6 +928,7 @@ async function processUploadWithinInspectionSlot( sizeBytes, summary }); + await auditPromise; } async function handleList( @@ -825,7 +960,7 @@ async function handleList( .slice(pagination.offset, endOffset) .map((record) => buildPublicShareMetadata(record)); - await writeShareAuditEvent(request, { + const auditPromise = writeShareAuditEvent(request, { action: "list", outcome: "ok" }); @@ -836,6 +971,7 @@ async function handleList( limit: pagination.limit, nextOffset: endOffset < records.length ? endOffset : null }); + await auditPromise; } async function handleGetMetadata( @@ -849,12 +985,13 @@ async function handleGetMetadata( return; } - respondJson(response, 200, buildPublicShareMetadata(record)); - await writeShareAuditEvent(request, { + const auditPromise = writeShareAuditEvent(request, { action: "metadata", shareId: id, outcome: "ok" }); + respondJson(response, 200, buildPublicShareMetadata(record)); + await auditPromise; } async function handleDownloadArchive( @@ -1007,12 +1144,13 @@ async function handleSharePage( `; - respondHtml(response, 200, page); - await writeShareAuditEvent(request, { + const auditPromise = writeShareAuditEvent(request, { action: "page", shareId: id, outcome: "ok" }); + respondHtml(response, 200, page); + await auditPromise; } async function handleRevokeShare( @@ -1033,7 +1171,7 @@ async function handleRevokeShare( }; await writeRecord(revokedRecord); - await writeShareAuditEvent(request, { + const auditPromise = writeShareAuditEvent(request, { action: "revoke", shareId: id, outcome: "ok" @@ -1042,6 +1180,7 @@ async function handleRevokeShare( shareId: id, revokedAt }); + await auditPromise; } async function readAvailableShareRecord( From a57d82de495bf6a9202fef13819756a7fc4135e3 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:42:51 +0800 Subject: [PATCH 069/181] fix(share): canonicalize proxy IP addresses --- apps/share-server/src/index.test.ts | 31 +++++++++++++++++++++++++++++ apps/share-server/src/index.ts | 17 +++++++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 9c45230..9c152b0 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1770,6 +1770,37 @@ describe("share-server", () => { expect(events.at(-1)?.clientHash).not.toBe(spoofedHash); }); + it.each([ + ["expanded IPv6", "2001:0db8:0:0:0:0:0:1", "2001:db8::1"], + ["IPv4-mapped IPv6", "0:0:0:0:0:ffff:c000:0201", "192.0.2.1"] + ])( + "canonicalizes %s trusted proxy addresses across equivalent spellings", + async (_label, configuredProxy, forwardedProxy) => { + const server = await startShareServer({ + WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true", + WEBBLACKBOX_TRUSTED_PROXY_IPS: configuredProxy + }); + const clientAddress = "198.51.100.99"; + const response = await fetch(`${server.baseUrl}/api/share/list`, { + headers: { + "x-webblackbox-api-key": apiKey, + "x-forwarded-for": `${clientAddress}, ${forwardedProxy}` + } + }); + expect(response.status).toBe(200); + + const auditLog = await readFile(resolve(server.dataDir, "audit/share-access.jsonl"), "utf8"); + const events = auditLog + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { action: string; clientHash: string }); + const expectedHash = createHash("sha256") + .update(`webblackbox-share-audit:ip:${clientAddress}`) + .digest("hex"); + expect(events.at(-1)).toMatchObject({ action: "list", clientHash: expectedHash }); + } + ); + it("supports opt-in query API key bootstrap without propagating the key", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY: "true" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 4007853..eaa618b 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -12,7 +12,7 @@ import { type FileHandle } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import { isIP } from "node:net"; +import { isIP, SocketAddress } from "node:net"; import { join, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; @@ -3777,8 +3777,19 @@ function resolvePeerAddress(request: IncomingMessage): string | null { function normalizeIpAddress(value: string): string | null { const trimmed = value.trim().toLowerCase(); - const ipv4Mapped = trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed; - return isIP(ipv4Mapped) > 0 ? ipv4Mapped : null; + const family = isIP(trimmed); + if (family === 0) { + return null; + } + + const socketAddress = SocketAddress.parse(family === 6 ? `[${trimmed}]:0` : `${trimmed}:0`); + if (!socketAddress) { + return null; + } + + const canonical = socketAddress.address.toLowerCase(); + const mappedIpv4 = canonical.startsWith("::ffff:") ? canonical.slice("::ffff:".length) : null; + return mappedIpv4 && isIP(mappedIpv4) === 4 ? mappedIpv4 : canonical; } function isTrustedProxyAddress(address: string): boolean { From 977fd0586a7e83efb0ba362537631a6fd7d94fac Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:44:46 +0800 Subject: [PATCH 070/181] fix(share): require explicit trusted proxies --- apps/share-server/README.md | 4 ++-- apps/share-server/src/index.test.ts | 35 +++++++++++++++++++++++++++-- apps/share-server/src/index.ts | 2 +- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index bfa5b11..ec02f83 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -45,8 +45,8 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS`: maximum time to drain in-flight requests and queued audit events after `SIGINT` or `SIGTERM` (default `10000`, hard ceiling `60000`). A timeout forces remaining connections closed and terminates the process. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). -- `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: enables proxy-aware client addresses for rate limiting and redacted audit hashes only; authorization never trusts forwarded headers. Forwarded chains are used only when the socket peer is loopback or listed in `WEBBLACKBOX_TRUSTED_PROXY_IPS`, and the first untrusted hop is selected from right to left. -- `WEBBLACKBOX_TRUSTED_PROXY_IPS`: comma-separated exact proxy IPs allowed to extend the trusted forwarded chain. The edge proxy must append or overwrite `X-Forwarded-For`; invalid IP configuration fails startup. Loopback proxy peers are trusted automatically when forwarded support is enabled. +- `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: enables proxy-aware client addresses for rate limiting and redacted audit hashes only; authorization never trusts forwarded headers. Forwarded chains are used only when the socket peer is explicitly listed in `WEBBLACKBOX_TRUSTED_PROXY_IPS`, and the first untrusted hop is selected from right to left. +- `WEBBLACKBOX_TRUSTED_PROXY_IPS`: comma-separated exact proxy IPs allowed to extend the trusted forwarded chain. The edge proxy must append or overwrite `X-Forwarded-For`; invalid IP configuration fails startup. Loopback proxies are not trusted implicitly: list `127.0.0.1` or `::1` explicitly when the proxy connects from that address. For production, prefer scoped keys over a single admin key: diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 9c152b0..a8a0009 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1745,7 +1745,8 @@ describe("share-server", () => { it("derives forwarded clients from the first untrusted hop next to trusted proxies", async () => { const server = await startShareServer({ - WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true" + WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true", + WEBBLACKBOX_TRUSTED_PROXY_IPS: "127.0.0.1" }); const response = await fetch(`${server.baseUrl}/api/share/list`, { headers: { @@ -1778,7 +1779,7 @@ describe("share-server", () => { async (_label, configuredProxy, forwardedProxy) => { const server = await startShareServer({ WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true", - WEBBLACKBOX_TRUSTED_PROXY_IPS: configuredProxy + WEBBLACKBOX_TRUSTED_PROXY_IPS: `127.0.0.1,${configuredProxy}` }); const clientAddress = "198.51.100.99"; const response = await fetch(`${server.baseUrl}/api/share/list`, { @@ -1801,6 +1802,36 @@ describe("share-server", () => { } ); + it.each([ + ["an unlisted loopback peer", "203.0.113.10", "198.51.100.1", "198.51.100.2"], + ["a malformed forwarded chain", "127.0.0.1", "not-an-ip", "also-not-an-ip"] + ])( + "does not split upload rate limits through %s", + async (_label, trustedProxies, firstForwardedFor, secondForwardedFor) => { + const server = await startShareServer({ + WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true", + WEBBLACKBOX_TRUSTED_PROXY_IPS: trustedProxies, + WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX: "1" + }); + const upload = (forwardedFor: string) => + fetch(`${server.baseUrl}/api/share/upload`, { + method: "POST", + headers: { + "content-type": "application/octet-stream", + "x-webblackbox-api-key": apiKey, + "x-forwarded-for": forwardedFor + }, + body: Buffer.from("not-an-archive") + }); + + const firstResponse = await upload(firstForwardedFor); + const secondResponse = await upload(secondForwardedFor); + + expect(firstResponse.status).toBe(400); + expect(secondResponse.status).toBe(429); + } + ); + it("supports opt-in query API key bootstrap without propagating the key", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_ALLOW_QUERY_API_KEY: "true" diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index eaa618b..7289603 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -3793,7 +3793,7 @@ function normalizeIpAddress(value: string): string | null { } function isTrustedProxyAddress(address: string): boolean { - return isLoopbackAddress(address) || TRUSTED_PROXY_ADDRESSES.has(address); + return TRUSTED_PROXY_ADDRESSES.has(address); } function isLoopbackAddress(address: string): boolean { From 595be995fde352b0ac4963d24793c3fbe220c981 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:44:31 +0800 Subject: [PATCH 071/181] fix(share): bound audit write backpressure --- apps/share-server/README.md | 8 +- apps/share-server/src/audit-writer.test.ts | 138 ++++++++++++++ apps/share-server/src/audit-writer.ts | 209 +++++++++++++++++++++ apps/share-server/src/index.test.ts | 1 + apps/share-server/src/index.ts | 98 ++++++++-- 5 files changed, 439 insertions(+), 15 deletions(-) create mode 100644 apps/share-server/src/audit-writer.test.ts create mode 100644 apps/share-server/src/audit-writer.ts diff --git a/apps/share-server/README.md b/apps/share-server/README.md index ec02f83..3d85dc8 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -43,6 +43,10 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_MAX_RECORDS`: upload admission limit for retained record files (default `10000`, with a non-relaxable hard ceiling of `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. Lowering this value blocks new uploads at the new limit but does not prevent startup, listing, metadata access, or revocation for existing valid records within the hard ceiling. - `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. - `WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS`: maximum time to drain in-flight requests and queued audit events after `SIGINT` or `SIGTERM` (default `10000`, hard ceiling `60000`). A timeout forces remaining connections closed and terminates the process. +- `WEBBLACKBOX_SHARE_MAX_AUDIT_QUEUE_ENTRIES`: maximum in-process audit appends, including the active append (default `128`, hard ceiling `1024`). Events above this capacity are dropped with a rate-limited operational warning instead of extending the request backlog. +- `WEBBLACKBOX_SHARE_AUDIT_QUEUE_TIMEOUT_MS`: maximum time an admitted audit event may wait to start (default `1000`, hard ceiling `30000`). Stale events are dropped rather than written out of order long after their request. +- `WEBBLACKBOX_SHARE_AUDIT_WRITE_TIMEOUT_MS`: maximum time a request waits for one audit append (default `2000`, hard ceiling `30000`). Timing out degrades audit logging but does not change an already committed Share operation into an HTTP failure. +- `WEBBLACKBOX_SHARE_AUDIT_RETRY_COOLDOWN_MS`: delay before one recovery probe is allowed after an audit append error or timeout (default `5000`, hard ceiling `300000`). No probe is attempted while the timed-out filesystem operation remains unsettled, preventing repeated hung writes from accumulating. - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_MAX`: max uploads per client in each window (default `10`). - `WEBBLACKBOX_UPLOAD_RATE_LIMIT_WINDOW_MS`: upload rate limit window in ms (default `60000`). - `WEBBLACKBOX_TRUST_X_FORWARDED_FOR`: enables proxy-aware client addresses for rate limiting and redacted audit hashes only; authorization never trusts forwarded headers. Forwarded chains are used only when the socket peer is explicitly listed in `WEBBLACKBOX_TRUSTED_PROXY_IPS`, and the first untrusted hop is selected from right to left. @@ -143,5 +147,5 @@ Each share writes: New storage directories are mode `0700`. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. -Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. -Audit append failures are reported through operational logs but do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. +Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized through a capacity- and time-bounded queue and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. +Audit queue overflow, expiry, append timeout, and append errors are reported through rate-limited operational warnings. A failed or timed-out sink opens a circuit: queued events are dropped, and after the retry cooldown one recovery probe is admitted only after the physical append has settled. These audit degradations do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/audit-writer.test.ts b/apps/share-server/src/audit-writer.test.ts new file mode 100644 index 0000000..b3d282d --- /dev/null +++ b/apps/share-server/src/audit-writer.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; + +import { BoundedAuditWriter } from "./audit-writer.js"; + +describe("BoundedAuditWriter", () => { + it("serializes slow writes and rejects work above its hard queue capacity", async () => { + const pendingWrites: Array<() => void> = []; + let activeWrites = 0; + let maximumActiveWrites = 0; + const writer = new BoundedAuditWriter({ + append: () => + new Promise((resolve) => { + activeWrites += 1; + maximumActiveWrites = Math.max(maximumActiveWrites, activeWrites); + pendingWrites.push(() => { + activeWrites -= 1; + resolve(); + }); + }), + maxQueuedEntries: 2, + queueWaitTimeoutMs: 1_000, + writeTimeoutMs: 1_000, + retryCooldownMs: 20 + }); + + const first = writer.write("first\n"); + const second = writer.write("second\n"); + await waitFor(() => pendingWrites.length === 1); + + await expect(writer.write("overflow\n")).resolves.toEqual({ + status: "dropped", + reason: "queue-capacity" + }); + + pendingWrites.shift()?.(); + await waitFor(() => pendingWrites.length === 1); + pendingWrites.shift()?.(); + + await expect(first).resolves.toEqual({ status: "written", recovered: false }); + await expect(second).resolves.toEqual({ status: "written", recovered: false }); + expect(maximumActiveWrites).toBe(1); + }); + + it("expires queued events instead of starting stale writes", async () => { + let appendCalls = 0; + const writer = new BoundedAuditWriter({ + append: async () => { + appendCalls += 1; + await delay(40); + }, + maxQueuedEntries: 2, + queueWaitTimeoutMs: 10, + writeTimeoutMs: 100, + retryCooldownMs: 20 + }); + + const first = writer.write("first\n"); + const stale = writer.write("stale\n"); + + await expect(first).resolves.toEqual({ status: "written", recovered: false }); + await expect(stale).resolves.toEqual({ + status: "dropped", + reason: "queue-timeout" + }); + expect(appendCalls).toBe(1); + }); + + it("bounds hung writes, drops followers, and recovers with one probe", async () => { + let releaseHungWrite: (() => void) | undefined; + let appendCalls = 0; + const writer = new BoundedAuditWriter({ + append: (_line, signal) => { + appendCalls += 1; + if (appendCalls > 1) { + return Promise.resolve(); + } + return new Promise((resolve) => { + releaseHungWrite = resolve; + signal.addEventListener("abort", () => undefined, { once: true }); + }); + }, + maxQueuedEntries: 3, + queueWaitTimeoutMs: 100, + writeTimeoutMs: 20, + retryCooldownMs: 20 + }); + + const hung = writer.write("hung\n"); + const follower = writer.write("follower\n"); + const anotherFollower = writer.write("another-follower\n"); + + await expect(hung).resolves.toEqual({ + status: "failed", + reason: "write-timeout" + }); + await expect(follower).resolves.toEqual({ + status: "dropped", + reason: "circuit-open" + }); + await expect(anotherFollower).resolves.toEqual({ + status: "dropped", + reason: "circuit-open" + }); + + await delay(25); + await expect(writer.write("blocked-while-physical-write-hangs\n")).resolves.toEqual({ + status: "dropped", + reason: "circuit-open" + }); + expect(appendCalls).toBe(1); + + releaseHungWrite?.(); + await delay(25); + await expect(writer.write("recovery-probe\n")).resolves.toEqual({ + status: "written", + recovered: true + }); + await expect(writer.write("after-recovery\n")).resolves.toEqual({ + status: "written", + recovered: false + }); + expect(appendCalls).toBe(3); + }); +}); + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 1_000; + while (!predicate()) { + if (Date.now() >= deadline) { + throw new Error("Timed out waiting for test condition."); + } + await delay(1); + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/share-server/src/audit-writer.ts b/apps/share-server/src/audit-writer.ts new file mode 100644 index 0000000..3c72a09 --- /dev/null +++ b/apps/share-server/src/audit-writer.ts @@ -0,0 +1,209 @@ +export type AuditWriteResult = + | { + status: "written"; + recovered: boolean; + } + | { + status: "dropped"; + reason: "queue-capacity" | "queue-timeout" | "circuit-open"; + } + | { + status: "failed"; + reason: "write-error" | "write-timeout"; + error?: unknown; + }; + +export type BoundedAuditWriterOptions = { + append: (line: string, signal: AbortSignal) => Promise; + maxQueuedEntries: number; + queueWaitTimeoutMs: number; + writeTimeoutMs: number; + retryCooldownMs: number; +}; + +type CircuitState = "closed" | "open" | "half-open"; + +/** + * Serializes audit appends without allowing a slow filesystem to create an + * unbounded request backlog. A timed-out physical append remains counted until + * it actually settles, so an uninterruptible filesystem operation cannot cause + * repeated probes to accumulate behind it. + */ +export class BoundedAuditWriter { + private readonly append: BoundedAuditWriterOptions["append"]; + private readonly maxQueuedEntries: number; + private readonly queueWaitTimeoutMs: number; + private readonly writeTimeoutMs: number; + private readonly retryCooldownMs: number; + private tail: Promise = Promise.resolve(); + private queuedEntries = 0; + private activePhysicalWrites = 0; + private circuitState: CircuitState = "closed"; + private circuitRetryAt = 0; + + constructor(options: BoundedAuditWriterOptions) { + assertPositiveInteger("maxQueuedEntries", options.maxQueuedEntries); + assertPositiveInteger("queueWaitTimeoutMs", options.queueWaitTimeoutMs); + assertPositiveInteger("writeTimeoutMs", options.writeTimeoutMs); + assertPositiveInteger("retryCooldownMs", options.retryCooldownMs); + + this.append = options.append; + this.maxQueuedEntries = options.maxQueuedEntries; + this.queueWaitTimeoutMs = options.queueWaitTimeoutMs; + this.writeTimeoutMs = options.writeTimeoutMs; + this.retryCooldownMs = options.retryCooldownMs; + } + + write(line: string): Promise { + const enqueuedAt = performance.now(); + const probe = this.tryReserveCircuitProbe(enqueuedAt); + + if (probe === null) { + return Promise.resolve({ + status: "dropped", + reason: "circuit-open" + }); + } + + if (this.queuedEntries >= this.maxQueuedEntries) { + if (probe) { + this.reopenCircuit(enqueuedAt); + } + return Promise.resolve({ + status: "dropped", + reason: "queue-capacity" + }); + } + + this.queuedEntries += 1; + const operation = this.tail + .then(() => this.runQueuedWrite(line, enqueuedAt, probe)) + .catch((error: unknown): AuditWriteResult => { + this.reopenCircuit(performance.now()); + return { + status: "failed", + reason: "write-error", + error + }; + }); + + this.tail = operation.then(() => undefined); + + return operation.finally(() => { + this.queuedEntries -= 1; + }); + } + + async drain(): Promise { + await this.tail; + } + + private tryReserveCircuitProbe(now: number): boolean | null { + if (this.circuitState === "closed") { + return false; + } + + if ( + this.circuitState === "half-open" || + now < this.circuitRetryAt || + this.activePhysicalWrites > 0 || + this.queuedEntries > 0 + ) { + return null; + } + + this.circuitState = "half-open"; + return true; + } + + private async runQueuedWrite( + line: string, + enqueuedAt: number, + probe: boolean + ): Promise { + const now = performance.now(); + + if (!probe && this.circuitState !== "closed") { + return { + status: "dropped", + reason: "circuit-open" + }; + } + + if (now - enqueuedAt >= this.queueWaitTimeoutMs) { + if (probe) { + this.reopenCircuit(now); + } + return { + status: "dropped", + reason: "queue-timeout" + }; + } + + return this.runPhysicalWrite(line, probe); + } + + private async runPhysicalWrite(line: string, probe: boolean): Promise { + const abortController = new AbortController(); + this.activePhysicalWrites += 1; + + const physicalWrite = Promise.resolve() + .then(() => this.append(line, abortController.signal)) + .then( + () => ({ status: "written" as const }), + (error: unknown) => ({ status: "failed" as const, error }) + ) + .finally(() => { + this.activePhysicalWrites -= 1; + }); + + let timeout: ReturnType | undefined; + const timedWrite = new Promise<{ status: "timeout" }>((resolve) => { + timeout = setTimeout(() => resolve({ status: "timeout" }), this.writeTimeoutMs); + }); + const result = await Promise.race([physicalWrite, timedWrite]); + + if (timeout !== undefined) { + clearTimeout(timeout); + } + + if (result.status === "timeout") { + abortController.abort(new Error("Share audit append timed out.")); + this.reopenCircuit(performance.now()); + return { + status: "failed", + reason: "write-timeout" + }; + } + + if (result.status === "failed") { + this.reopenCircuit(performance.now()); + return { + status: "failed", + reason: "write-error", + error: result.error + }; + } + + const recovered = probe; + if (probe) { + this.circuitState = "closed"; + this.circuitRetryAt = 0; + } + return { + status: "written", + recovered + }; + } + + private reopenCircuit(now: number): void { + this.circuitState = "open"; + this.circuitRetryAt = now + this.retryCooldownMs; + } +} + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer.`); + } +} diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index a8a0009..d5a91d6 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1336,6 +1336,7 @@ describe("share-server", () => { }); expect(archiveResponse.status).toBe(410); + await waitForAuditEvent(server, "download", uploadPayload.shareId); const auditLog = await readFile(resolve(server.dataDir, "audit/share-access.jsonl"), "utf8"); const auditEvents = auditLog .trim() diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 7289603..0496214 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -29,6 +29,7 @@ import { } from "@webblackbox/player-sdk"; import { exportManifestSchema, type ExportManifest } from "@webblackbox/protocol"; +import { BoundedAuditWriter, type AuditWriteResult } from "./audit-writer.js"; import { parseShareApiCredentials, type ShareApiScope } from "./auth-config.js"; type ShareRecord = { @@ -249,9 +250,26 @@ const SHARE_SHUTDOWN_TIMEOUT_MS = Math.min( parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS, 10_000), MAX_SHARE_SHUTDOWN_TIMEOUT_MS ); +const MAX_SHARE_AUDIT_QUEUE_ENTRIES = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_AUDIT_QUEUE_ENTRIES, 128), + 1_024 +); +const SHARE_AUDIT_QUEUE_TIMEOUT_MS = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_AUDIT_QUEUE_TIMEOUT_MS, 1_000), + 30_000 +); +const SHARE_AUDIT_WRITE_TIMEOUT_MS = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_AUDIT_WRITE_TIMEOUT_MS, 2_000), + 30_000 +); +const SHARE_AUDIT_RETRY_COOLDOWN_MS = Math.min( + parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_AUDIT_RETRY_COOLDOWN_MS, 5_000), + 300_000 +); const MAX_SHARE_READ_SESSIONS = 4096; const RATE_LIMIT_CLEANUP_INTERVAL = 64; const MAX_TRACKED_RATE_BUCKETS = 4096; +const AUDIT_DEGRADATION_WARNING_INTERVAL_MS = 5_000; const uploadRateWindows = new Map(); const shareReadSessions = new Map(); const verifiedArchiveCache = new Map(); @@ -259,8 +277,16 @@ let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; let reservedShareRecordSlots = 0; let sharePublicOrigin = DEFAULT_BASE_URL; -let auditWriteQueue = Promise.resolve(); let shareRecordAdmissionQueue = Promise.resolve(); +let lastAuditDegradationWarningAt = 0; +let suppressedAuditDegradationWarnings = 0; +const auditWriter = new BoundedAuditWriter({ + append: appendShareAuditLine, + maxQueuedEntries: MAX_SHARE_AUDIT_QUEUE_ENTRIES, + queueWaitTimeoutMs: SHARE_AUDIT_QUEUE_TIMEOUT_MS, + writeTimeoutMs: SHARE_AUDIT_WRITE_TIMEOUT_MS, + retryCooldownMs: SHARE_AUDIT_RETRY_COOLDOWN_MS +}); void startShareServer().catch((error) => { console.error("[share-server] startup failed", error); @@ -395,7 +421,7 @@ async function shutdownShareServer( const closePromise = closeHttpServer(server); const drainPromise = (async () => { await waitForActiveRequests(activeRequests); - await auditWriteQueue; + await auditWriter.drain(); await closePromise; })(); const drained = await waitWithinTimeout(drainPromise, SHARE_SHUTDOWN_TIMEOUT_MS); @@ -3032,26 +3058,25 @@ async function writeShareAuditEvent( details: input.details }; const line = `${JSON.stringify(event)}\n`; - const writeOperation = auditWriteQueue.then(() => appendShareAuditLine(line)); - auditWriteQueue = writeOperation.catch(() => undefined); + const result = await auditWriter.write(line); - try { - await writeOperation; - } catch (error) { - console.warn("[share-server] audit append failed", { - action: input.action, - shareId: input.shareId, - error: redactText(error instanceof Error ? error.message : String(error), 240) - }); + if (result.status === "written") { + if (result.recovered) { + console.info("[share-server] audit sink recovered"); + } + return; } + + reportAuditWriteDegradation(result, input.action, input.shareId); } -async function appendShareAuditLine(line: string): Promise { +async function appendShareAuditLine(line: string, signal: AbortSignal): Promise { const lineBytes = Buffer.byteLength(line, "utf8"); if (lineBytes > MAX_SHARE_AUDIT_LOG_BYTES) { throw new Error("Share audit event exceeds the configured log size ceiling."); } + throwIfAuditWriteAborted(signal); let currentBytes = 0; try { const current = await stat(SHARE_AUDIT_LOG_PATH); @@ -3062,8 +3087,10 @@ async function appendShareAuditLine(line: string): Promise { } } + throwIfAuditWriteAborted(signal); if (currentBytes > 0 && currentBytes + lineBytes > MAX_SHARE_AUDIT_LOG_BYTES) { await rm(SHARE_AUDIT_ROTATED_LOG_PATH, { force: true }); + throwIfAuditWriteAborted(signal); try { await rename(SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH); } catch (error) { @@ -3073,12 +3100,57 @@ async function appendShareAuditLine(line: string): Promise { } } + throwIfAuditWriteAborted(signal); await appendFile(SHARE_AUDIT_LOG_PATH, line, { encoding: "utf8", mode: 0o600 }); } +function reportAuditWriteDegradation( + result: Exclude, + action: ShareAuditAction, + shareId?: string +): void { + const now = Date.now(); + if (now - lastAuditDegradationWarningAt < AUDIT_DEGRADATION_WARNING_INTERVAL_MS) { + suppressedAuditDegradationWarnings += 1; + return; + } + + const suppressedWarnings = suppressedAuditDegradationWarnings; + lastAuditDegradationWarningAt = now; + suppressedAuditDegradationWarnings = 0; + const details: Record = { + action, + shareId, + reason: result.reason, + suppressedWarnings: suppressedWarnings || undefined + }; + + if (result.status === "failed" && result.error !== undefined) { + details.error = redactText( + result.error instanceof Error ? result.error.message : String(result.error), + 240 + ); + } + + console.warn( + result.status === "failed" + ? "[share-server] audit append failed" + : "[share-server] audit event dropped", + details + ); +} + +function throwIfAuditWriteAborted(signal: AbortSignal): void { + if (!signal.aborted) { + return; + } + + throw signal.reason instanceof Error ? signal.reason : new Error("Share audit append aborted."); +} + function isFileNotFoundError(error: unknown): boolean { return ( error instanceof Error && From fc3fe18833089a077f482e4dc0c95c06af560bd0 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:51:08 +0800 Subject: [PATCH 072/181] fix(share): migrate legacy storage permissions --- apps/share-server/README.md | 2 +- apps/share-server/src/index.test.ts | 174 ++++++++++-- apps/share-server/src/index.ts | 421 +++++++++++++++++++++++----- 3 files changed, 516 insertions(+), 81 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 3d85dc8..e588dcb 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -145,7 +145,7 @@ Each share writes: - `records/.json` (redacted public summary only) - `audit/share-access.jsonl` (action, outcome, share id, timestamp, and client hash only) -New storage directories are mode `0700`. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. +On POSIX systems, storage directories are enforced as mode `0700`; committed archives, record JSON, and the active/rotated audit logs are enforced as mode `0600`. Startup migrates legacy `0755`/`0644` installations through no-follow file handles and refuses managed directory or committed-file symlinks and path replacements instead of changing an external target. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized through a capacity- and time-bounded queue and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. Audit queue overflow, expiry, append timeout, and append errors are reported through rate-limited operational warnings. A failed or timed-out sink opens a circuit: queued events are dropped, and after the retry cooldown one recovery probe is admitted only after the physical append has settled. These audit degradations do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index d5a91d6..1c1c07f 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -2,7 +2,18 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { createRequire } from "node:module"; -import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rm, + stat, + symlink, + writeFile +} from "node:fs/promises"; import { request as createHttpRequest } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; @@ -1124,6 +1135,100 @@ describe("share-server", () => { expect(metadataResponse.status).toBe(200); }); + it("migrates legacy storage directories and committed files to private modes on restart", async () => { + if (process.platform === "win32") { + return; + } + + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + await stopShareServer(server, false); + + const archivePath = resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`); + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const activeAuditPath = resolve(server.dataDir, "audit", "share-access.jsonl"); + const rotatedAuditPath = resolve(server.dataDir, "audit", "share-access.1.jsonl"); + const directoryPaths = [ + server.dataDir, + resolve(server.dataDir, "archives"), + resolve(server.dataDir, "records"), + resolve(server.dataDir, "audit") + ]; + const committedFilePaths = [archivePath, recordPath, activeAuditPath, rotatedAuditPath]; + + await writeFile(rotatedAuditPath, '{"legacy":true}\n'); + await Promise.all(directoryPaths.map((directoryPath) => chmod(directoryPath, 0o755))); + await Promise.all(committedFilePaths.map((filePath) => chmod(filePath, 0o644))); + + const restarted = await startShareServer({}, server.dataDir); + + for (const directoryPath of directoryPaths) { + expect((await stat(directoryPath)).mode & 0o777).toBe(0o700); + } + for (const filePath of committedFilePaths) { + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + } + + const metadataResponse = await fetch(`${restarted.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(metadataResponse.status).toBe(200); + }); + + it("refuses a managed storage directory symlink without changing its external target", async () => { + if (process.platform === "win32") { + return; + } + + const dataDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-symlink-root-")); + const outsideDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-outside-")); + const outsideSentinel = resolve(outsideDir, "sentinel.txt"); + + try { + await writeFile(outsideSentinel, "outside", { mode: 0o644 }); + await chmod(outsideDir, 0o755); + await chmod(outsideSentinel, 0o644); + await symlink(outsideDir, resolve(dataDir, "records"), "dir"); + + await expect(startShareServer({}, dataDir)).rejects.toThrow( + /Unsafe share storage path .*records/ + ); + expect((await stat(outsideDir)).mode & 0o777).toBe(0o755); + expect((await stat(outsideSentinel)).mode & 0o777).toBe(0o644); + await expect(readFile(outsideSentinel, "utf8")).resolves.toBe("outside"); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + + it("refuses a committed file symlink without changing its external target", async () => { + if (process.platform === "win32") { + return; + } + + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const archivePath = resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`); + const outsideDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-outside-file-")); + const outsideFile = resolve(outsideDir, "outside.webblackbox"); + + try { + await stopShareServer(server, false); + await writeFile(outsideFile, "outside", { mode: 0o644 }); + await chmod(outsideFile, 0o644); + await rm(archivePath); + await symlink(outsideFile, archivePath, "file"); + + await expect(startShareServer({}, server.dataDir)).rejects.toThrow( + /Unsafe share storage path .*webblackbox/ + ); + expect((await stat(outsideFile)).mode & 0o777).toBe(0o644); + await expect(readFile(outsideFile, "utf8")).resolves.toBe("outside"); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + it.each(["truncated", "same-size replacement"] as const)( "removes a %s persisted archive during restart reconciliation", async (corruption) => { @@ -1436,7 +1541,7 @@ describe("share-server", () => { expect(server.logs.join("")).toContain("[share-server] shutdown drain complete"); }); - it("bounds shutdown when the audit sink cannot drain", async () => { + it("exits within the shutdown bound when the audit sink is unsafe", async () => { if (process.platform === "win32") { return; } @@ -1466,8 +1571,9 @@ describe("share-server", () => { const startedAt = Date.now(); const exit = await terminateShareServer(server); - expect(exit.signal === "SIGKILL" || exit.code === 128 + 9).toBe(true); + expect(exit).toEqual({ code: 0, signal: null }); expect(Date.now() - startedAt).toBeLessThan(2_000); + expect(server.logs.join("")).toContain("[share-server] audit append failed"); } finally { await auditReader.close(); } @@ -1757,19 +1863,23 @@ describe("share-server", () => { }); expect(response.status).toBe(200); - const auditLog = await readFile(resolve(server.dataDir, "audit", "share-access.jsonl"), "utf8"); - const events = auditLog - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { action: string; clientHash: string }); const expectedHash = createHash("sha256") .update("webblackbox-share-audit:ip:203.0.113.10") .digest("hex"); const spoofedHash = createHash("sha256") .update("webblackbox-share-audit:ip:198.51.100.99") .digest("hex"); - expect(events.at(-1)).toMatchObject({ action: "list", clientHash: expectedHash }); - expect(events.at(-1)?.clientHash).not.toBe(spoofedHash); + await waitForAuditClientHash(server, "list", expectedHash); + const refreshedAuditLog = await readFile( + resolve(server.dataDir, "audit", "share-access.jsonl"), + "utf8" + ); + const refreshedEvents = refreshedAuditLog + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { action: string; clientHash: string }); + expect(refreshedEvents.at(-1)).toMatchObject({ action: "list", clientHash: expectedHash }); + expect(refreshedEvents.at(-1)?.clientHash).not.toBe(spoofedHash); }); it.each([ @@ -1791,15 +1901,19 @@ describe("share-server", () => { }); expect(response.status).toBe(200); - const auditLog = await readFile(resolve(server.dataDir, "audit/share-access.jsonl"), "utf8"); - const events = auditLog - .trim() - .split("\n") - .map((line) => JSON.parse(line) as { action: string; clientHash: string }); const expectedHash = createHash("sha256") .update(`webblackbox-share-audit:ip:${clientAddress}`) .digest("hex"); - expect(events.at(-1)).toMatchObject({ action: "list", clientHash: expectedHash }); + await waitForAuditClientHash(server, "list", expectedHash); + const refreshedAuditLog = await readFile( + resolve(server.dataDir, "audit/share-access.jsonl"), + "utf8" + ); + const refreshedEvents = refreshedAuditLog + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { action: string; clientHash: string }); + expect(refreshedEvents.at(-1)).toMatchObject({ action: "list", clientHash: expectedHash }); } ); @@ -1920,6 +2034,34 @@ async function waitForAuditEvent( throw new Error(`Timed out waiting for ${action} audit event for ${shareId}.`); } +async function waitForAuditClientHash( + server: RunningShareServer, + action: string, + clientHash: string +): Promise { + const auditPath = resolve(server.dataDir, "audit/share-access.jsonl"); + const deadline = Date.now() + 2_000; + + while (Date.now() < deadline) { + try { + const lines = (await readFile(auditPath, "utf8")).trim().split("\n"); + const found = lines.some((line) => { + const event = JSON.parse(line) as { action?: string; clientHash?: string }; + return event.action === action && event.clientHash === clientHash; + }); + if (found) { + return; + } + } catch { + // Audit creation may still be in flight after the HTTP response completes. + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } + + throw new Error(`Timed out waiting for ${action} audit event with client hash ${clientHash}.`); +} + async function createFullNamedPipe(path: string): Promise>> { const fifoResult = spawnSync("mkfifo", [path]); if (fifoResult.status !== 0) { diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 0496214..218bbbd 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,16 +1,6 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; -import type { BigIntStats } from "node:fs"; -import { - appendFile, - lstat, - mkdir, - open, - opendir, - rename, - rm, - stat, - type FileHandle -} from "node:fs/promises"; +import { constants as fsConstants, type BigIntStats } from "node:fs"; +import { lstat, mkdir, open, opendir, rename, rm, type FileHandle } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { isIP, SocketAddress } from "node:net"; import { join, resolve } from "node:path"; @@ -273,6 +263,7 @@ const AUDIT_DEGRADATION_WARNING_INTERVAL_MS = 5_000; const uploadRateWindows = new Map(); const shareReadSessions = new Map(); const verifiedArchiveCache = new Map(); +const storageDirectoryIdentities = new Map(); let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; let reservedShareRecordSlots = 0; @@ -304,6 +295,7 @@ async function startShareServer(): Promise { ); } await ensureStorageLayout(); + await migrateLegacyStoragePermissions(); await reconcileStorageLayout(); await pruneExpiredShareRecords(Date.now()); @@ -1119,6 +1111,7 @@ class ClientAbortedDownloadError extends Error { } class ArchiveIntegrityError extends Error {} +class UnsafeStoragePathError extends Error {} async function handleSharePage( request: IncomingMessage, @@ -1219,38 +1212,37 @@ async function readAvailableShareRecord( const record = await readRecord(id); if (!record) { - respondShareUnavailable(response, action, "not-found"); await writeShareAuditEvent(request, { action, shareId: id, outcome: "not-found" }); + respondShareUnavailable(response, action, "not-found"); return null; } if (isShareExpired(record, Date.now())) { - respondShareUnavailable(response, action, "expired"); await writeShareAuditEvent(request, { action, shareId: id, outcome: "expired" }); + respondShareUnavailable(response, action, "expired"); return null; } if (!allowRevoked && record.revokedAt) { - respondShareUnavailable(response, action, "revoked"); await writeShareAuditEvent(request, { action, shareId: id, outcome: "revoked" }); + respondShareUnavailable(response, action, "revoked"); return null; } if (!(await hasValidStoredArchive(record))) { await removeStoredShare(id); - respondShareUnavailable(response, action, "not-found"); await writeShareAuditEvent(request, { action, shareId: id, @@ -1259,6 +1251,7 @@ async function readAvailableShareRecord( reason: "archive-integrity" } }); + respondShareUnavailable(response, action, "not-found"); return null; } @@ -1972,25 +1965,209 @@ function roundTo(value: number, digits: number): number { } async function ensureStorageLayout(): Promise { + storageDirectoryIdentities.clear(); await mkdir(DATA_ROOT, { recursive: true, mode: 0o700 }); - await mkdir(ARCHIVES_DIR, { - recursive: true, - mode: 0o700 - }); - await mkdir(RECORDS_DIR, { - recursive: true, - mode: 0o700 - }); - await mkdir(AUDIT_DIR, { - recursive: true, - mode: 0o700 + const dataRootIdentity = await hardenStorageDirectory(DATA_ROOT); + storageDirectoryIdentities.set(DATA_ROOT, dataRootIdentity); + + for (const directoryPath of [ARCHIVES_DIR, RECORDS_DIR, AUDIT_DIR]) { + await assertStoragePathIdentity(DATA_ROOT, dataRootIdentity, "directory"); + try { + await mkdir(directoryPath, { mode: 0o700 }); + } catch (error) { + if (!hasFileSystemErrorCode(error, "EEXIST")) { + throw error; + } + } + await assertStoragePathIdentity(DATA_ROOT, dataRootIdentity, "directory"); + const directoryIdentity = await hardenStorageDirectory(directoryPath); + storageDirectoryIdentities.set(directoryPath, directoryIdentity); + } + + await assertManagedStorageLayout(); +} + +async function migrateLegacyStoragePermissions(): Promise { + await assertManagedStorageLayout(); + await hardenCommittedStorageFiles(ARCHIVES_DIR, (name) => name.endsWith(".webblackbox")); + await hardenCommittedStorageFiles(RECORDS_DIR, (name) => name.endsWith(".json")); + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + for (const auditPath of [SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH]) { + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + await hardenStorageFileIfPresent(auditPath); + } + await assertManagedStorageLayout(); +} + +async function hardenCommittedStorageFiles( + directoryPath: string, + isCommittedName: (name: string) => boolean +): Promise { + const directoryIdentity = getManagedStorageDirectoryIdentity(directoryPath); + await assertStoragePathIdentity(directoryPath, directoryIdentity, "directory"); + const directory = await opendir(directoryPath); + let directoryEntries = 0; + + for await (const entry of directory) { + directoryEntries += 1; + assertShareRecordDirectoryEntryBudget(directoryEntries); + if (!entry.name.startsWith(".") && isCommittedName(entry.name)) { + await assertStoragePathIdentity(directoryPath, directoryIdentity, "directory"); + await hardenStorageFileIfPresent(join(directoryPath, entry.name)); + } + } + + await assertStoragePathIdentity(directoryPath, directoryIdentity, "directory"); +} + +async function assertManagedStorageLayout(): Promise { + for (const directoryPath of [DATA_ROOT, ARCHIVES_DIR, RECORDS_DIR, AUDIT_DIR]) { + await assertStoragePathIdentity( + directoryPath, + getManagedStorageDirectoryIdentity(directoryPath), + "directory" + ); + } +} + +function getManagedStorageDirectoryIdentity(directoryPath: string): BigIntStats { + const identity = storageDirectoryIdentities.get(directoryPath); + if (!identity) { + throw unsafeStoragePathError(directoryPath, "has not been securely initialized"); + } + return identity; +} + +async function hardenStorageDirectory(directoryPath: string): Promise { + let handle: FileHandle | null = null; + + try { + handle = await open( + directoryPath, + fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW + ); + const openedStat = await handle.stat({ bigint: true }); + if (!openedStat.isDirectory()) { + throw unsafeStoragePathError(directoryPath, "expected a regular directory"); + } + + if (process.platform !== "win32") { + await handle.chmod(0o700); + } + const hardenedStat = await handle.stat({ bigint: true }); + if ( + !hardenedStat.isDirectory() || + (process.platform !== "win32" && (hardenedStat.mode & 0o777n) !== 0o700n) + ) { + throw unsafeStoragePathError(directoryPath, "could not enforce mode 0700"); + } + + await assertStoragePathIdentity(directoryPath, hardenedStat, "directory"); + return hardenedStat; + } catch (error) { + if (error instanceof UnsafeStoragePathError) { + throw error; + } + throw unsafeStoragePathError(directoryPath, "refusing to follow or replace this path", error); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function hardenStorageFileIfPresent(filePath: string): Promise { + let pathStat: BigIntStats; + try { + pathStat = await lstat(filePath, { bigint: true }); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw error; + } + + if (!pathStat.isFile()) { + throw unsafeStoragePathError(filePath, "expected a regular file"); + } + + let handle: FileHandle | null = null; + try { + handle = await open(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const openedStat = await handle.stat({ bigint: true }); + if ( + !openedStat.isFile() || + openedStat.nlink !== 1n || + !isSameFileSystemObject(pathStat, openedStat) + ) { + throw unsafeStoragePathError(filePath, "changed while permissions were being migrated"); + } + + if (process.platform !== "win32") { + await handle.chmod(0o600); + } + const hardenedStat = await handle.stat({ bigint: true }); + if ( + !hardenedStat.isFile() || + hardenedStat.nlink !== 1n || + (process.platform !== "win32" && (hardenedStat.mode & 0o777n) !== 0o600n) + ) { + throw unsafeStoragePathError(filePath, "could not enforce mode 0600"); + } + + await assertStoragePathIdentity(filePath, hardenedStat, "file"); + } catch (error) { + if (error instanceof UnsafeStoragePathError) { + throw error; + } + throw unsafeStoragePathError(filePath, "refusing to follow or replace this path", error); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function assertStoragePathIdentity( + storagePath: string, + expectedStat: BigIntStats, + expectedKind: "directory" | "file" +): Promise { + let currentStat: BigIntStats; + try { + currentStat = await lstat(storagePath, { bigint: true }); + } catch (error) { + throw unsafeStoragePathError(storagePath, "disappeared during a storage operation", error); + } + + const hasExpectedKind = + expectedKind === "directory" ? currentStat.isDirectory() : currentStat.isFile(); + const hasSafeLinkCount = + expectedKind === "directory" || (currentStat.nlink === 1n && expectedStat.nlink === 1n); + const expectedMode = expectedKind === "directory" ? 0o700n : 0o600n; + const hasExpectedMode = + process.platform === "win32" || (currentStat.mode & 0o777n) === expectedMode; + if ( + !hasExpectedKind || + !hasSafeLinkCount || + !hasExpectedMode || + !isSameFileSystemObject(currentStat, expectedStat) + ) { + throw unsafeStoragePathError(storagePath, "changed during a storage operation"); + } +} + +function unsafeStoragePathError( + storagePath: string, + reason: string, + cause?: unknown +): UnsafeStoragePathError { + return new UnsafeStoragePathError(`Unsafe share storage path "${storagePath}": ${reason}.`, { + cause }); } async function reconcileStorageLayout(): Promise { + await assertManagedStorageLayout(); const archiveIds = await collectStoredIds(ARCHIVES_DIR, ".webblackbox", ".upload"); const recordIds = await collectStoredIds(RECORDS_DIR, ".json", ".record"); @@ -2008,9 +2185,16 @@ async function reconcileStorageLayout(): Promise { for (const id of archiveIds) { if (!recordIds.has(id)) { + await assertStoragePathIdentity( + ARCHIVES_DIR, + getManagedStorageDirectoryIdentity(ARCHIVES_DIR), + "directory" + ); await rm(archivePathForId(id), { force: true }); } } + + await assertManagedStorageLayout(); } async function filterRecordsWithValidArchives(records: ShareRecord[]): Promise { @@ -2052,21 +2236,36 @@ async function openVerifiedArchive(record: ShareRecord): Promise { verifiedArchiveCache.delete(id); + await Promise.all([ + assertStoragePathIdentity( + RECORDS_DIR, + getManagedStorageDirectoryIdentity(RECORDS_DIR), + "directory" + ), + assertStoragePathIdentity( + ARCHIVES_DIR, + getManagedStorageDirectoryIdentity(ARCHIVES_DIR), + "directory" + ) + ]); await Promise.all([ rm(recordPathForId(id), { force: true }), rm(archivePathForId(id), { force: true }) @@ -2178,6 +2391,8 @@ async function collectStoredIds( committedSuffix: string, temporarySuffix: string ): Promise> { + const directoryIdentity = getManagedStorageDirectoryIdentity(directoryPath); + await assertStoragePathIdentity(directoryPath, directoryIdentity, "directory"); const directory = await opendir(directoryPath); const ids = new Set(); let directoryEntries = 0; @@ -2190,6 +2405,7 @@ async function collectStoredIds( } if (entry.name.startsWith(".") && entry.name.endsWith(temporarySuffix)) { + await assertStoragePathIdentity(directoryPath, directoryIdentity, "directory"); await rm(join(directoryPath, entry.name), { force: true }); continue; } @@ -2204,6 +2420,7 @@ async function collectStoredIds( } } + await assertStoragePathIdentity(directoryPath, directoryIdentity, "directory"); return ids; } @@ -2214,18 +2431,36 @@ async function readRecord(id: string): Promise { let handle: Awaited> | null = null; try { - handle = await open(recordPathForId(id), "r"); - const recordStat = await handle.stat(); + const recordDirectoryIdentity = getManagedStorageDirectoryIdentity(RECORDS_DIR); + await assertStoragePathIdentity(RECORDS_DIR, recordDirectoryIdentity, "directory"); + const recordPath = recordPathForId(id); + handle = await open(recordPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + let recordStat = await handle.stat({ bigint: true }); if ( !recordStat.isFile() || - !Number.isSafeInteger(recordStat.size) || - recordStat.size <= 0 || - recordStat.size > MAX_SHARE_RECORD_BYTES + recordStat.nlink !== 1n || + recordStat.size <= 0n || + recordStat.size > BigInt(MAX_SHARE_RECORD_BYTES) ) { return null; } + if (process.platform !== "win32" && (recordStat.mode & 0o777n) !== 0o600n) { + await handle.chmod(0o600); + recordStat = await handle.stat({ bigint: true }); + if ( + !recordStat.isFile() || + recordStat.nlink !== 1n || + recordStat.size <= 0n || + recordStat.size > BigInt(MAX_SHARE_RECORD_BYTES) || + (recordStat.mode & 0o777n) !== 0o600n + ) { + return null; + } + } - const raw = await readFileHandleExactly(handle, recordStat.size); + const raw = await readFileHandleExactly(handle, Number(recordStat.size)); + await assertStoragePathIdentity(recordPath, recordStat, "file"); + await assertStoragePathIdentity(RECORDS_DIR, recordDirectoryIdentity, "directory"); const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)) as unknown; return normalizePersistedShareRecord(parsed, id); } catch { @@ -2315,6 +2550,8 @@ function assertShareRecordDirectoryEntryBudget(entries: number): void { } async function writeRecord(record: ShareRecord): Promise { + const recordDirectoryIdentity = getManagedStorageDirectoryIdentity(RECORDS_DIR); + await assertStoragePathIdentity(RECORDS_DIR, recordDirectoryIdentity, "directory"); const payload = Buffer.from(JSON.stringify(record, null, 2), "utf8"); if (payload.byteLength > MAX_SHARE_RECORD_BYTES) { throw new Error(`Share record exceeds ${MAX_SHARE_RECORD_BYTES} bytes.`); @@ -2333,6 +2570,7 @@ async function writeRecord(record: ShareRecord): Promise { await handle.sync(); await handle.close(); handle = null; + await assertStoragePathIdentity(RECORDS_DIR, recordDirectoryIdentity, "directory"); await rename(temporaryPath, recordPathForId(record.id)); committed = true; await syncDirectoryBestEffort(RECORDS_DIR); @@ -3077,34 +3315,87 @@ async function appendShareAuditLine(line: string, signal: AbortSignal): Promise< } throwIfAuditWriteAborted(signal); - let currentBytes = 0; + let currentLog = await openAuditLogForAppend(); try { - const current = await stat(SHARE_AUDIT_LOG_PATH); - currentBytes = current.isFile() ? current.size : 0; - } catch (error) { - if (!isFileNotFoundError(error)) { - throw error; + if ( + currentLog.stat.size > 0n && + currentLog.stat.size + BigInt(lineBytes) > BigInt(MAX_SHARE_AUDIT_LOG_BYTES) + ) { + await currentLog.handle?.close(); + currentLog = { handle: null, stat: currentLog.stat }; + throwIfAuditWriteAborted(signal); + await assertStoragePathIdentity( + AUDIT_DIR, + getManagedStorageDirectoryIdentity(AUDIT_DIR), + "directory" + ); + await rm(SHARE_AUDIT_ROTATED_LOG_PATH, { force: true }); + throwIfAuditWriteAborted(signal); + try { + await rename(SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH); + } catch (error) { + if (!isFileNotFoundError(error)) { + throw error; + } + } + await hardenStorageFileIfPresent(SHARE_AUDIT_ROTATED_LOG_PATH); + throwIfAuditWriteAborted(signal); + currentLog = await openAuditLogForAppend(); } - } - throwIfAuditWriteAborted(signal); - if (currentBytes > 0 && currentBytes + lineBytes > MAX_SHARE_AUDIT_LOG_BYTES) { - await rm(SHARE_AUDIT_ROTATED_LOG_PATH, { force: true }); - throwIfAuditWriteAborted(signal); - try { - await rename(SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH); - } catch (error) { - if (!isFileNotFoundError(error)) { - throw error; - } + if (!currentLog.handle) { + throw new Error("Share audit log handle is unavailable."); } + throwIfAuditWriteAborted(signal); + await currentLog.handle.appendFile(line, { encoding: "utf8" }); + await assertStoragePathIdentity(SHARE_AUDIT_LOG_PATH, currentLog.stat, "file"); + } finally { + await currentLog.handle?.close().catch(() => undefined); } +} - throwIfAuditWriteAborted(signal); - await appendFile(SHARE_AUDIT_LOG_PATH, line, { - encoding: "utf8", - mode: 0o600 - }); +async function openAuditLogForAppend(): Promise<{ + handle: FileHandle | null; + stat: BigIntStats; +}> { + let handle: FileHandle | null = null; + try { + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + handle = await open( + SHARE_AUDIT_LOG_PATH, + fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW, + 0o600 + ); + const openedStat = await handle.stat({ bigint: true }); + if (!openedStat.isFile() || openedStat.nlink !== 1n) { + throw unsafeStoragePathError(SHARE_AUDIT_LOG_PATH, "expected a regular audit log"); + } + + if (process.platform !== "win32") { + await handle.chmod(0o600); + } + const hardenedStat = await handle.stat({ bigint: true }); + if ( + hardenedStat.nlink !== 1n || + (process.platform !== "win32" && (hardenedStat.mode & 0o777n) !== 0o600n) + ) { + throw unsafeStoragePathError(SHARE_AUDIT_LOG_PATH, "could not enforce mode 0600"); + } + await assertStoragePathIdentity(SHARE_AUDIT_LOG_PATH, hardenedStat, "file"); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + return { handle, stat: hardenedStat }; + } catch (error) { + await handle?.close().catch(() => undefined); + if (error instanceof UnsafeStoragePathError) { + throw error; + } + throw unsafeStoragePathError( + SHARE_AUDIT_LOG_PATH, + "refusing to follow or replace this path", + error + ); + } } function reportAuditWriteDegradation( @@ -3152,10 +3443,12 @@ function throwIfAuditWriteAborted(signal: AbortSignal): void { } function isFileNotFoundError(error: unknown): boolean { + return hasFileSystemErrorCode(error, "ENOENT"); +} + +function hasFileSystemErrorCode(error: unknown, code: string): boolean { return ( - error instanceof Error && - "code" in error && - (error as Error & { code?: unknown }).code === "ENOENT" + error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code ); } From 4f90e324d88da5be027231d1cf32d42622370027 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 03:55:40 +0800 Subject: [PATCH 073/181] fix(share): bound indexed listing pagination --- apps/share-server/README.md | 5 +- apps/share-server/src/index.test.ts | 135 +++++++++- apps/share-server/src/index.ts | 376 +++++++++++++++++++++------- 3 files changed, 418 insertions(+), 98 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index e588dcb..7be4f9d 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -123,7 +123,8 @@ Response: ### Metadata and archive -- `GET /api/share/list?offset=0&limit=100` (strict offset pagination; maximum page size `200`, with `total` and `nextOffset` in the response) +- `GET /api/share/list?limit=100` (maximum page size `200`; follow the opaque `nextCursor` response value with `?cursor=&limit=100` for stable pagination) +- `GET /api/share/list?offset=0&limit=100` remains available for compatibility and returns `nextOffset`, but offset pages can shift when shares are inserted or removed between requests - `GET /api/share/:id/meta` - `GET /api/share/:id/archive` - `GET /share/:id` @@ -147,5 +148,7 @@ Each share writes: On POSIX systems, storage directories are enforced as mode `0700`; committed archives, record JSON, and the active/rotated audit logs are enforced as mode `0600`. Startup migrates legacy `0755`/`0644` installations through no-follow file handles and refuses managed directory or committed-file symlinks and path replacements instead of changing an external target. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. +Startup also builds a compact, hard-bounded index (at most `10,000` records). List requests select at most one page from that index and only then load and verify those records and archives, so a request never retains every record payload in memory. Expiry pruning walks the compact expiry index one due record at a time. The index is updated after durable uploads, revocations, removals, and startup reconciliation; page entries still receive full record and archive-integrity validation before they are returned. + Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized through a capacity- and time-bounded queue and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. Audit queue overflow, expiry, append timeout, and append errors are reported through rate-limited operational warnings. A failed or timed-out sink opens a circuit: queued events are dropped, and after the retry cooldown one recovery probe is admitted only after the physical append has settled. These audit degradations do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 1c1c07f..2437d56 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -911,7 +911,10 @@ describe("share-server", () => { await writeFile(resolve(server.dataDir, "archives", `${id}.webblackbox`), sourceArchive); } - const firstResponse = await fetch(`${server.baseUrl}/api/share/list?limit=2&offset=0`, { + await stopShareServer(server, false); + const restarted = await startShareServer({}, server.dataDir); + + const firstResponse = await fetch(`${restarted.baseUrl}/api/share/list?limit=2&offset=0`, { headers: { "x-webblackbox-api-key": apiKey } }); const first = (await firstResponse.json()) as { @@ -920,11 +923,13 @@ describe("share-server", () => { offset: number; limit: number; nextOffset: number | null; + nextCursor: string | null; }; expect(first).toMatchObject({ total: 3, offset: 0, limit: 2, nextOffset: 2 }); expect(first.items).toHaveLength(2); + expect(first.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/); - const secondResponse = await fetch(`${server.baseUrl}/api/share/list?limit=2&offset=2`, { + const secondResponse = await fetch(`${restarted.baseUrl}/api/share/list?limit=2&offset=2`, { headers: { "x-webblackbox-api-key": apiKey } }); const second = (await secondResponse.json()) as { @@ -934,10 +939,134 @@ describe("share-server", () => { expect(second.items).toHaveLength(1); expect(second.nextOffset).toBeNull(); - const invalidResponse = await fetch(`${server.baseUrl}/api/share/list?limit=201`, { + const invalidResponse = await fetch(`${restarted.baseUrl}/api/share/list?limit=201`, { headers: { "x-webblackbox-api-key": apiKey } }); expect(invalidResponse.status).toBe(400); + + const conflictingResponse = await fetch( + `${restarted.baseUrl}/api/share/list?cursor=${first.nextCursor}&offset=0`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + expect(conflictingResponse.status).toBe(400); + }); + + it("keeps cursor pagination stable across insertions and removals", async () => { + const server = await startShareServer(); + const initialUploads = [ + await uploadEncryptedFixture(server), + await uploadEncryptedFixture(server), + await uploadEncryptedFixture(server), + await uploadEncryptedFixture(server) + ]; + + const firstResponse = await fetch(`${server.baseUrl}/api/share/list?limit=2`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const first = (await firstResponse.json()) as { + items: Array<{ id: string }>; + nextCursor: string | null; + }; + expect(firstResponse.status).toBe(200); + expect(first.items).toHaveLength(2); + expect(first.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/); + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 5)); + const inserted = await uploadEncryptedFixture(server); + const removedId = first.items[0]?.id; + if (!removedId) { + throw new Error("Expected a first-page share to remove."); + } + await rm(resolve(server.dataDir, "archives", `${removedId}.webblackbox`)); + const removalResponse = await fetch(`${server.baseUrl}/api/share/${removedId}/archive`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + expect(removalResponse.status).toBe(404); + + const secondResponse = await fetch( + `${server.baseUrl}/api/share/list?limit=2&cursor=${first.nextCursor}`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + const second = (await secondResponse.json()) as { + items: Array<{ id: string }>; + nextCursor: string | null; + }; + const firstIds = first.items.map(({ id }) => id); + const secondIds = second.items.map(({ id }) => id); + const originalIdsAfterCursor = initialUploads + .map(({ shareId }) => shareId) + .filter((id) => !firstIds.includes(id)); + + expect(secondResponse.status).toBe(200); + expect(secondIds.sort()).toEqual(originalIdsAfterCursor.sort()); + expect(secondIds.some((id) => firstIds.includes(id))).toBe(false); + expect(secondIds).not.toContain(inserted.shareId); + expect(second.nextCursor).toBeNull(); + }); + + it("loads and validates at most one page of large persisted records per list request", async () => { + const server = await startShareServer(); + const upload = await uploadEncryptedFixture(server); + const sourceRecord = JSON.parse( + await readFile(resolve(server.dataDir, "records", `${upload.shareId}.json`), "utf8") + ) as Record; + const sourceArchive = await readFile( + resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`) + ); + const createdAt = Number(sourceRecord.createdAt); + const cloneIds = Array.from( + { length: 12 }, + (_, index) => `${String(index).padStart(2, "0")}${"c".repeat(30)}` + ); + + for (const [index, id] of cloneIds.entries()) { + await writeFile( + resolve(server.dataDir, "records", `${id}.json`), + JSON.stringify({ + ...sourceRecord, + id, + createdAt: createdAt - index - 1, + padding: "x".repeat(220 * 1024) + }) + ); + await writeFile(resolve(server.dataDir, "archives", `${id}.webblackbox`), sourceArchive); + } + + await stopShareServer(server, false); + const restarted = await startShareServer({}, server.dataDir); + + for (const id of cloneIds.slice(1)) { + await writeFile(resolve(restarted.dataDir, "records", `${id}.json`), " ".repeat(220 * 1024)); + } + + const firstResponse = await fetch(`${restarted.baseUrl}/api/share/list?limit=2`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const first = (await firstResponse.json()) as { + items: Array<{ id: string }>; + total: number; + nextCursor: string | null; + }; + expect(firstResponse.status).toBe(200); + expect(first).toMatchObject({ total: 13 }); + expect(first.items.map(({ id }) => id)).toEqual([upload.shareId, cloneIds[0]]); + expect(first.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/); + await expect(readdir(resolve(restarted.dataDir, "records"))).resolves.toHaveLength(13); + + const secondResponse = await fetch( + `${restarted.baseUrl}/api/share/list?limit=2&cursor=${first.nextCursor}`, + { headers: { "x-webblackbox-api-key": apiKey } } + ); + const second = (await secondResponse.json()) as { + items: Array<{ id: string }>; + total: number; + nextCursor: string | null; + }; + expect(secondResponse.status).toBe(200); + expect(second.items).toEqual([]); + expect(second.total).toBe(11); + expect(second.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/); + await expect(readdir(resolve(restarted.dataDir, "records"))).resolves.toHaveLength(11); }); it("rejects uploads before buffering when retained record capacity is exhausted", async () => { diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 218bbbd..0852180 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -34,6 +34,27 @@ type ShareRecord = { summary: ShareSummary; }; +type ShareRecordIndexEntry = Readonly<{ + id: string; + createdAt: number; + expiresAt: number; + retentionDeadline: number; +}>; + +type ShareListCursor = Pick; + +type ShareListPagination = + | { + mode: "cursor"; + cursor: ShareListCursor; + limit: number; + } + | { + mode: "offset"; + offset: number; + limit: number; + }; + type ShareAuditAction = "upload" | "list" | "metadata" | "download" | "page" | "revoke"; type ShareAuditOutcome = "ok" | "not-found" | "expired" | "revoked" | "blocked" | "error"; type ShareAuthorizationSource = "unauthenticated-loopback-dev" | "token" | "query" | "read-session"; @@ -231,6 +252,7 @@ const MAX_VERIFIED_ARCHIVE_CACHE_ENTRIES = 10_000; const ARCHIVE_CHECKSUM_BUFFER_BYTES = 64 * 1024; const DEFAULT_SHARE_LIST_LIMIT = 100; const MAX_SHARE_LIST_LIMIT = 200; +const MAX_SHARE_LIST_CURSOR_BYTES = 256; const MAX_SHARE_AUDIT_LOG_BYTES = Math.min( parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES, 16 * 1024 * 1024), 64 * 1024 * 1024 @@ -264,6 +286,9 @@ const uploadRateWindows = new Map(); const verifiedArchiveCache = new Map(); const storageDirectoryIdentities = new Map(); +const shareRecordIndexById = new Map(); +const shareRecordListOrder: ShareRecordIndexEntry[] = []; +const shareRecordExpiryOrder: ShareRecordIndexEntry[] = []; let rateLimitCleanupCounter = 0; let activeUploadInspections = 0; let reservedShareRecordSlots = 0; @@ -964,30 +989,47 @@ async function handleList( } }); respondJson(response, 400, { - error: `List pagination requires integer offset >= 0 and limit between 1 and ${MAX_SHARE_LIST_LIMIT}.` + error: `List pagination requires either a valid cursor or an integer offset >= 0, plus a limit between 1 and ${MAX_SHARE_LIST_LIMIT}.` }); return; } await pruneExpiredShareRecords(Date.now()); - const records = (await filterRecordsWithValidArchives(await loadAllRecords())).sort( - (left, right) => right.createdAt - left.createdAt || left.id.localeCompare(right.id) - ); - const endOffset = Math.min(records.length, pagination.offset + pagination.limit); - const items = records - .slice(pagination.offset, endOffset) - .map((record) => buildPublicShareMetadata(record)); + const candidates = selectShareRecordIndexPage(pagination); + const records: ShareRecord[] = []; + + for (const candidate of candidates) { + const record = await readRecord(candidate.id); + if ( + !record || + record.createdAt !== candidate.createdAt || + record.expiresAt !== candidate.expiresAt || + !(await hasValidStoredArchive(record)) + ) { + await removeStoredShare(candidate.id); + continue; + } + + records.push(record); + } + + const lastCandidate = candidates.at(-1); + const hasMore = lastCandidate ? hasShareRecordIndexEntryAfter(lastCandidate) : false; + const nextCursor = hasMore && lastCandidate ? encodeShareListCursor(lastCandidate) : null; + const nextOffset = + pagination.mode === "offset" && hasMore ? pagination.offset + candidates.length : null; const auditPromise = writeShareAuditEvent(request, { action: "list", outcome: "ok" }); respondJson(response, 200, { - items, - total: records.length, - offset: pagination.offset, + items: records.map((record) => buildPublicShareMetadata(record)), + total: shareRecordIndexById.size, + offset: pagination.mode === "offset" ? pagination.offset : null, limit: pagination.limit, - nextOffset: endOffset < records.length ? endOffset : null + nextOffset, + nextCursor }); await auditPromise; } @@ -2168,13 +2210,19 @@ function unsafeStoragePathError( async function reconcileStorageLayout(): Promise { await assertManagedStorageLayout(); + resetShareRecordIndex(); const archiveIds = await collectStoredIds(ARCHIVES_DIR, ".webblackbox", ".upload"); const recordIds = await collectStoredIds(RECORDS_DIR, ".json", ".record"); + const indexEntries: ShareRecordIndexEntry[] = []; + if (recordIds.size > MAX_SHARE_RECORD_FILES) { + throw new Error(`Share record hard limit exceeded (${MAX_SHARE_RECORD_FILES}).`); + } for (const id of recordIds) { if (archiveIds.has(id)) { const record = await readRecord(id); if (record && (await hasValidStoredArchive(record))) { + indexEntries.push(createShareRecordIndexEntry(record)); continue; } } @@ -2193,25 +2241,10 @@ async function reconcileStorageLayout(): Promise { await rm(archivePathForId(id), { force: true }); } } - + initializeShareRecordIndex(indexEntries); await assertManagedStorageLayout(); } -async function filterRecordsWithValidArchives(records: ShareRecord[]): Promise { - const verifiedRecords: ShareRecord[] = []; - - for (const record of records) { - if (await hasValidStoredArchive(record)) { - verifiedRecords.push(record); - continue; - } - - await removeStoredShare(record.id); - } - - return verifiedRecords; -} - async function hasValidStoredArchive(record: ShareRecord): Promise { let verifiedArchive: VerifiedArchiveFile | null = null; @@ -2384,6 +2417,7 @@ async function removeStoredShare(id: string): Promise { rm(recordPathForId(id), { force: true }), rm(archivePathForId(id), { force: true }) ]); + removeShareRecordIndexEntry(id); } async function collectStoredIds( @@ -2470,44 +2504,16 @@ async function readRecord(id: string): Promise { } } -async function loadAllRecords(): Promise { - const directory = await opendir(RECORDS_DIR); - const records: ShareRecord[] = []; - let directoryEntries = 0; - let recordFiles = 0; - - for await (const entry of directory) { - directoryEntries += 1; - assertShareRecordDirectoryEntryBudget(directoryEntries); - if (!entry.isFile() || !entry.name.endsWith(".json")) { - continue; - } - - recordFiles += 1; - if (recordFiles > MAX_SHARE_RECORD_FILES) { - throw new Error(`Share record hard limit exceeded (${MAX_SHARE_RECORD_FILES}).`); - } - - const id = entry.name.slice(0, -".json".length); - const record = SHARE_ID_PATTERN.test(id) ? await readRecord(id) : null; - if (record) { - records.push(record); - } - } - - return records; -} - async function tryReserveShareRecordSlot(): Promise { const reservation = shareRecordAdmissionQueue.then(async () => { - const initialCount = await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT); + const initialCount = shareRecordIndexById.size; if (initialCount + reservedShareRecordSlots < SHARE_RECORD_ADMISSION_LIMIT) { reservedShareRecordSlots += 1; return true; } await pruneExpiredShareRecords(Date.now()); - const retainedCount = await countShareRecordFilesUpTo(SHARE_RECORD_ADMISSION_LIMIT); + const retainedCount = shareRecordIndexById.size; if (retainedCount + reservedShareRecordSlots >= SHARE_RECORD_ADMISSION_LIMIT) { return false; } @@ -2522,34 +2528,156 @@ async function tryReserveShareRecordSlot(): Promise { return reservation; } -async function countShareRecordFilesUpTo(stopAt: number): Promise { - const directory = await opendir(RECORDS_DIR); - let directoryEntries = 0; - let recordFiles = 0; +function assertShareRecordDirectoryEntryBudget(entries: number): void { + if (entries > MAX_SHARE_RECORD_DIRECTORY_ENTRIES) { + throw new Error( + `Share record directory entry limit exceeded (${MAX_SHARE_RECORD_DIRECTORY_ENTRIES}).` + ); + } +} - for await (const entry of directory) { - directoryEntries += 1; - assertShareRecordDirectoryEntryBudget(directoryEntries); - if (entry.isFile() && entry.name.endsWith(".json")) { - recordFiles += 1; - if (recordFiles >= stopAt) { - return recordFiles; - } +function resetShareRecordIndex(): void { + shareRecordIndexById.clear(); + shareRecordListOrder.length = 0; + shareRecordExpiryOrder.length = 0; +} + +function initializeShareRecordIndex(entries: ShareRecordIndexEntry[]): void { + if (entries.length > MAX_SHARE_RECORD_FILES) { + throw new Error(`Share record hard limit exceeded (${MAX_SHARE_RECORD_FILES}).`); + } + + resetShareRecordIndex(); + for (const entry of entries) { + if (shareRecordIndexById.has(entry.id)) { + throw new Error(`Duplicate share record index entry: ${entry.id}`); } + shareRecordIndexById.set(entry.id, entry); } + shareRecordListOrder.push(...entries); + shareRecordListOrder.sort(compareShareRecordListEntries); + shareRecordExpiryOrder.push(...entries); + shareRecordExpiryOrder.sort(compareShareRecordExpiryEntries); +} - return recordFiles; +function assertShareRecordIndexCapacity(id: string): void { + if (!shareRecordIndexById.has(id) && shareRecordIndexById.size >= MAX_SHARE_RECORD_FILES) { + throw new Error(`Share record hard limit exceeded (${MAX_SHARE_RECORD_FILES}).`); + } } -function assertShareRecordDirectoryEntryBudget(entries: number): void { - if (entries > MAX_SHARE_RECORD_DIRECTORY_ENTRIES) { - throw new Error( - `Share record directory entry limit exceeded (${MAX_SHARE_RECORD_DIRECTORY_ENTRIES}).` - ); +function upsertShareRecordIndex(record: ShareRecord): void { + const entry = createShareRecordIndexEntry(record); + removeShareRecordIndexEntry(record.id); + assertShareRecordIndexCapacity(record.id); + shareRecordIndexById.set(entry.id, entry); + insertSortedIndexEntry(shareRecordListOrder, entry, compareShareRecordListEntries); + insertSortedIndexEntry(shareRecordExpiryOrder, entry, compareShareRecordExpiryEntries); +} + +function createShareRecordIndexEntry(record: ShareRecord): ShareRecordIndexEntry { + const retentionDeadline = addSafeIntegers(record.expiresAt, SHARE_RETAIN_EXPIRED_MS); + if (retentionDeadline === null) { + throw new Error("Share record retention deadline exceeds the safe integer range."); + } + + return { + id: record.id, + createdAt: record.createdAt, + expiresAt: record.expiresAt, + retentionDeadline + }; +} + +function removeShareRecordIndexEntry(id: string): void { + const entry = shareRecordIndexById.get(id); + if (!entry) { + return; } + + shareRecordIndexById.delete(id); + removeSortedIndexEntry(shareRecordListOrder, entry, compareShareRecordListEntries); + removeSortedIndexEntry(shareRecordExpiryOrder, entry, compareShareRecordExpiryEntries); +} + +function compareShareRecordListEntries(left: ShareListCursor, right: ShareListCursor): number { + return right.createdAt - left.createdAt || compareShareIds(left.id, right.id); +} + +function compareShareRecordExpiryEntries( + left: ShareRecordIndexEntry, + right: ShareRecordIndexEntry +): number { + return left.retentionDeadline - right.retentionDeadline || compareShareIds(left.id, right.id); +} + +function compareShareIds(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function insertSortedIndexEntry( + entries: T[], + entry: T, + compare: (left: T, right: T) => number +): void { + entries.splice(findSortedIndex(entries, entry, compare, false), 0, entry); +} + +function removeSortedIndexEntry( + entries: T[], + entry: T, + compare: (left: T, right: T) => number +): void { + const index = findSortedIndex(entries, entry, compare, false); + if (entries[index] === entry) { + entries.splice(index, 1); + } +} + +function findSortedIndex( + entries: T[], + target: U, + compare: (left: T, right: U) => number, + afterEqual: boolean +): number { + let low = 0; + let high = entries.length; + + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + const comparison = compare(entries[middle] as T, target); + if (comparison < 0 || (afterEqual && comparison === 0)) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; +} + +function selectShareRecordIndexPage(pagination: ShareListPagination): ShareRecordIndexEntry[] { + const start = + pagination.mode === "cursor" + ? findSortedIndex( + shareRecordListOrder, + pagination.cursor, + compareShareRecordListEntries, + true + ) + : pagination.offset; + return shareRecordListOrder.slice(start, start + pagination.limit); +} + +function hasShareRecordIndexEntryAfter(cursor: ShareListCursor): boolean { + return ( + findSortedIndex(shareRecordListOrder, cursor, compareShareRecordListEntries, true) < + shareRecordListOrder.length + ); } async function writeRecord(record: ShareRecord): Promise { + assertShareRecordIndexCapacity(record.id); const recordDirectoryIdentity = getManagedStorageDirectoryIdentity(RECORDS_DIR); await assertStoragePathIdentity(RECORDS_DIR, recordDirectoryIdentity, "directory"); const payload = Buffer.from(JSON.stringify(record, null, 2), "utf8"); @@ -2574,6 +2702,7 @@ async function writeRecord(record: ShareRecord): Promise { await rename(temporaryPath, recordPathForId(record.id)); committed = true; await syncDirectoryBestEffort(RECORDS_DIR); + upsertShareRecordIndex(record); } finally { await handle?.close(); if (!committed) { @@ -3230,20 +3359,84 @@ function resolveShareTtlMs(request: IncomingMessage): number { return Math.min(SHARE_MAX_TTL_MS, Math.max(1_000, requestedTtl)); } -function parseShareListPagination(requestUrl: URL): { offset: number; limit: number } | null { - const offset = parseBoundedQueryInteger( - requestUrl.searchParams.get("offset"), - 0, - 0, - MAX_SHARE_RECORD_FILES - ); +function parseShareListPagination(requestUrl: URL): ShareListPagination | null { + if ( + requestUrl.searchParams.getAll("cursor").length > 1 || + requestUrl.searchParams.getAll("offset").length > 1 || + requestUrl.searchParams.getAll("limit").length > 1 + ) { + return null; + } + const limit = parseBoundedQueryInteger( requestUrl.searchParams.get("limit"), DEFAULT_SHARE_LIST_LIMIT, 1, MAX_SHARE_LIST_LIMIT ); - return offset === null || limit === null ? null : { offset, limit }; + if (limit === null) { + return null; + } + + const rawCursor = requestUrl.searchParams.get("cursor"); + if (rawCursor !== null) { + if (requestUrl.searchParams.has("offset")) { + return null; + } + const cursor = decodeShareListCursor(rawCursor); + return cursor ? { mode: "cursor", cursor, limit } : null; + } + + const offset = parseBoundedQueryInteger( + requestUrl.searchParams.get("offset"), + 0, + 0, + MAX_SHARE_RECORD_FILES + ); + return offset === null ? null : { mode: "offset", offset, limit }; +} + +function encodeShareListCursor(cursor: ShareListCursor): string { + return Buffer.from( + JSON.stringify({ version: 1, createdAt: cursor.createdAt, id: cursor.id }), + "utf8" + ).toString("base64url"); +} + +function decodeShareListCursor(raw: string): ShareListCursor | null { + if ( + raw.length === 0 || + raw.length > MAX_SHARE_LIST_CURSOR_BYTES * 2 || + !/^[A-Za-z0-9_-]+$/.test(raw) + ) { + return null; + } + + try { + const bytes = Buffer.from(raw, "base64url"); + if ( + bytes.byteLength === 0 || + bytes.byteLength > MAX_SHARE_LIST_CURSOR_BYTES || + bytes.toString("base64url") !== raw + ) { + return null; + } + + const cursor = asRecord(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))); + const createdAt = readSafeNonNegativeInteger(cursor.createdAt); + if ( + cursor.version !== 1 || + createdAt === null || + typeof cursor.id !== "string" || + !SHARE_ID_PATTERN.test(cursor.id) + ) { + return null; + } + + return { createdAt, id: cursor.id }; + } catch { + return null; + } } function parseBoundedQueryInteger( @@ -3263,17 +3456,12 @@ function parseBoundedQueryInteger( } async function pruneExpiredShareRecords(now: number): Promise { - const records = await loadAllRecords(); - const retentionMs = Math.max(0, SHARE_RETAIN_EXPIRED_MS); - - for (const record of records) { - const retentionDeadline = resolveShareExpiresAt(record) + retentionMs; - - if (retentionDeadline > now) { - continue; + while ((shareRecordExpiryOrder[0]?.retentionDeadline ?? Number.POSITIVE_INFINITY) <= now) { + const expired = shareRecordExpiryOrder[0]; + if (!expired) { + return; } - - await removeStoredShare(record.id); + await removeStoredShare(expired.id); } } From 47d3512c96d387c6fec5f191c59b00b934dcbc3d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:05:44 +0800 Subject: [PATCH 074/181] fix(deps): patch Hono static middleware bypass --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 3a6a6d2..9219635 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "pnpm": { "overrides": { - "@hono/node-server": "1.19.10", + "@hono/node-server": "1.19.14", "express-rate-limit": "8.5.2", "fast-uri": "3.1.2", "flatted@<3.4.2": "3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7902a18..3372c79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - '@hono/node-server': 1.19.10 + '@hono/node-server': 1.19.14 express-rate-limit: 8.5.2 fast-uri: 3.1.2 flatted@<3.4.2: 3.4.2 @@ -595,8 +595,8 @@ packages: '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} - '@hono/node-server@1.19.10': - resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: hono: 4.12.29 @@ -3336,7 +3336,7 @@ snapshots: '@shikijs/types': 3.23.0 '@shikijs/vscode-textmate': 10.0.2 - '@hono/node-server@1.19.10(hono@4.12.29)': + '@hono/node-server@1.19.14(hono@4.12.29)': dependencies: hono: 4.12.29 @@ -3390,7 +3390,7 @@ snapshots: '@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)': dependencies: - '@hono/node-server': 1.19.10(hono@4.12.29) + '@hono/node-server': 1.19.14(hono@4.12.29) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 From f2446dacf9990a6686f44ba8b56c850c7eea3a9d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:08:18 +0800 Subject: [PATCH 075/181] fix(share): reconcile audit logs at startup --- apps/share-server/README.md | 4 +- apps/share-server/src/index.test.ts | 65 ++++++++++++++++++ apps/share-server/src/index.ts | 100 +++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 7be4f9d..899eef8 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -41,7 +41,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_MAX_TTL_MS`: maximum accepted share lifetime in ms (default `2592000000`, 30 days; minimum `1000`). It must be greater than or equal to the default TTL. - `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). Set it to `0` to prune as soon as a share expires. TTL and retention values must be safe integers whose combined timestamp remains representable; invalid startup configuration is rejected. - `WEBBLACKBOX_SHARE_MAX_RECORDS`: upload admission limit for retained record files (default `10000`, with a non-relaxable hard ceiling of `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. Lowering this value blocks new uploads at the new limit but does not prevent startup, listing, metadata access, or revocation for existing valid records within the hard ceiling. -- `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, hard ceiling `67108864`). One previous segment is retained. +- `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, minimum `512`, hard ceiling `67108864`). Values outside that range are rejected at startup. One previous segment is retained. - `WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS`: maximum time to drain in-flight requests and queued audit events after `SIGINT` or `SIGTERM` (default `10000`, hard ceiling `60000`). A timeout forces remaining connections closed and terminates the process. - `WEBBLACKBOX_SHARE_MAX_AUDIT_QUEUE_ENTRIES`: maximum in-process audit appends, including the active append (default `128`, hard ceiling `1024`). Events above this capacity are dropped with a rate-limited operational warning instead of extending the request backlog. - `WEBBLACKBOX_SHARE_AUDIT_QUEUE_TIMEOUT_MS`: maximum time an admitted audit event may wait to start (default `1000`, hard ceiling `30000`). Stale events are dropped rather than written out of order long after their request. @@ -148,6 +148,8 @@ Each share writes: On POSIX systems, storage directories are enforced as mode `0700`; committed archives, record JSON, and the active/rotated audit logs are enforced as mode `0600`. Startup migrates legacy `0755`/`0644` installations through no-follow file handles and refuses managed directory or committed-file symlinks and path replacements instead of changing an external target. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. +Startup also reconciles both `audit/share-access.jsonl` and `audit/share-access.1.jsonl` before accepting requests. The server refuses to start if either file exceeds the configured audit-log ceiling or if a non-empty file does not end on a complete JSONL line. It never byte-truncates an audit file because that can turn a partial record into a misleading line on the next append. Move or delete the whole reported file, preserving it separately if required by your audit-retention policy, and then restart. Lowering the configured ceiling can therefore require an explicit operator rotation or removal of legacy logs. + Startup also builds a compact, hard-bounded index (at most `10,000` records). List requests select at most one page from that index and only then load and verify those records and archives, so a request never retains every record payload in memory. Expiry pruning walks the compact expiry index one due record at a time. The index is updated after durable uploads, revocations, removals, and startup reconciliation; page entries still receive full record and archive-integrity validation before they are returned. Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized through a capacity- and time-bounded queue and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 2437d56..ba4719e 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1304,6 +1304,71 @@ describe("share-server", () => { expect(metadataResponse.status).toBe(200); }); + it.each(["share-access.jsonl", "share-access.1.jsonl"])( + "refuses to start with an oversized legacy audit log at %s", + async (auditFileName) => { + const server = await startShareServer(); + await stopShareServer(server, false); + + const auditDirectory = resolve(server.dataDir, "audit"); + const auditPath = resolve(auditDirectory, auditFileName); + const legacyLine = `${JSON.stringify({ legacy: true, value: "x".repeat(96) })}\n`; + const oversizedLog = legacyLine.repeat(8); + expect(Buffer.byteLength(oversizedLog)).toBeGreaterThan(512); + await writeFile(auditPath, oversizedLog); + + const otherAuditPath = resolve( + auditDirectory, + auditFileName === "share-access.jsonl" ? "share-access.1.jsonl" : "share-access.jsonl" + ); + const otherAuditLog = `${JSON.stringify({ legacy: "retained" })}\n`; + await writeFile(otherAuditPath, otherAuditLog); + if (process.platform !== "win32") { + await Promise.all([chmod(auditPath, 0o644), chmod(otherAuditPath, 0o644)]); + } + + await expect( + startShareServer( + { + WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES: "512" + }, + server.dataDir + ) + ).rejects.toThrow(new RegExp(`${escapeRegExp(auditFileName)}.*exceeds`)); + + await expect(readFile(auditPath, "utf8")).resolves.toBe(oversizedLog); + await expect(readFile(otherAuditPath, "utf8")).resolves.toBe(otherAuditLog); + if (process.platform !== "win32") { + expect((await stat(auditPath)).mode & 0o777).toBe(0o600); + expect((await stat(otherAuditPath)).mode & 0o777).toBe(0o600); + } + } + ); + + it("refuses to append after an incomplete legacy audit JSONL record", async () => { + const server = await startShareServer(); + await stopShareServer(server, false); + + const auditPath = resolve(server.dataDir, "audit", "share-access.jsonl"); + const incompleteLog = '{"legacy":true'; + await writeFile(auditPath, incompleteLog); + + await expect(startShareServer({}, server.dataDir)).rejects.toThrow( + /does not end at a complete JSONL record/ + ); + await expect(readFile(auditPath, "utf8")).resolves.toBe(incompleteLog); + }); + + it("rejects an audit log ceiling too small to retain an event", async () => { + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES: "1" + }) + ).rejects.toThrow( + /WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES must be an integer between 512 and 67108864/ + ); + }); + it("refuses a managed storage directory symlink without changing its external target", async () => { if (process.platform === "win32") { return; diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index 0852180..e24289a 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -253,9 +253,14 @@ const ARCHIVE_CHECKSUM_BUFFER_BYTES = 64 * 1024; const DEFAULT_SHARE_LIST_LIMIT = 100; const MAX_SHARE_LIST_LIMIT = 200; const MAX_SHARE_LIST_CURSOR_BYTES = 256; -const MAX_SHARE_AUDIT_LOG_BYTES = Math.min( - parsePositiveInteger(process.env.WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES, 16 * 1024 * 1024), - 64 * 1024 * 1024 +const MIN_SHARE_AUDIT_LOG_BYTES = 512; +const HARD_MAX_SHARE_AUDIT_LOG_BYTES = 64 * 1024 * 1024; +const MAX_SHARE_AUDIT_LOG_BYTES = parseConfiguredInteger( + "WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES", + process.env.WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES, + 16 * 1024 * 1024, + MIN_SHARE_AUDIT_LOG_BYTES, + HARD_MAX_SHARE_AUDIT_LOG_BYTES ); const MAX_SHARE_SHUTDOWN_TIMEOUT_MS = 60_000; const SHARE_SHUTDOWN_TIMEOUT_MS = Math.min( @@ -321,6 +326,7 @@ async function startShareServer(): Promise { } await ensureStorageLayout(); await migrateLegacyStoragePermissions(); + await reconcileAuditLogLayout(); await reconcileStorageLayout(); await pruneExpiredShareRecords(Date.now()); @@ -2044,6 +2050,71 @@ async function migrateLegacyStoragePermissions(): Promise { await assertManagedStorageLayout(); } +async function reconcileAuditLogLayout(): Promise { + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + + for (const auditPath of [SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH]) { + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + await validateAuditLogForStartup(auditPath, auditDirectoryIdentity); + } + + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); +} + +async function validateAuditLogForStartup( + auditPath: string, + auditDirectoryIdentity: BigIntStats +): Promise { + let handle: FileHandle; + try { + handle = await open(auditPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + } catch (error) { + if (isFileNotFoundError(error)) { + return; + } + throw unsafeStoragePathError(auditPath, "refusing to follow or replace this path", error); + } + + try { + const openedStat = await handle.stat({ bigint: true }); + if ( + !openedStat.isFile() || + openedStat.nlink !== 1n || + (process.platform !== "win32" && (openedStat.mode & 0o777n) !== 0o600n) + ) { + throw unsafeStoragePathError(auditPath, "expected a private regular audit log"); + } + + if (openedStat.size > BigInt(MAX_SHARE_AUDIT_LOG_BYTES)) { + throw new Error( + `Share audit log "${auditPath}" exceeds WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES (${MAX_SHARE_AUDIT_LOG_BYTES}). Move or delete the whole file before restarting; byte truncation can create an invalid JSONL record.` + ); + } + + if (openedStat.size > 0n) { + const finalByte = Buffer.allocUnsafe(1); + const { bytesRead } = await handle.read(finalByte, 0, 1, Number(openedStat.size - 1n)); + if (bytesRead !== 1 || finalByte[0] !== 0x0a) { + throw new Error( + `Share audit log "${auditPath}" does not end at a complete JSONL record. Move or delete the whole file before restarting; byte truncation is not repaired automatically.` + ); + } + } + + const verifiedStat = await handle.stat({ bigint: true }); + if ( + verifiedStat.size !== openedStat.size || + !isSameFileSystemObject(verifiedStat, openedStat) + ) { + throw unsafeStoragePathError(auditPath, "changed during startup reconciliation"); + } + await assertStoragePathIdentity(auditPath, verifiedStat, "file"); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + } finally { + await handle.close().catch(() => undefined); + } +} + async function hardenCommittedStorageFiles( directoryPath: string, isCommittedName: (name: string) => boolean @@ -4403,6 +4474,29 @@ function parsePositiveInteger(value: string | undefined, fallback: number): numb return Math.max(1, Math.floor(parsed)); } +function parseConfiguredInteger( + name: string, + value: string | undefined, + fallback: number, + minimum: number, + maximum: number +): number { + if (value === undefined || value.trim() === "") { + return fallback; + } + + const normalized = value.trim(); + if (!/^\d+$/.test(normalized)) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); + } + + const parsed = Number(normalized); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be an integer between ${minimum} and ${maximum}.`); + } + return parsed; +} + function parseRateLimitWindowMs(value: string | undefined, fallback: number): number { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { From 9be6c9964a4f9875369707c91c74dd12918497be Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:09:47 +0800 Subject: [PATCH 076/181] fix(dev-deps): upgrade vulnerable Turborepo --- package.json | 2 +- pnpm-lock.yaml | 65 ++++---------------------------------------------- 2 files changed, 6 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index 9219635..868cc9c 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "prettier": "^3.6.2", "tsup": "^8.5.1", "tsx": "^4.20.6", - "turbo": "^2.6.1", + "turbo": "2.10.4", "typedoc": "^0.28.20", "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3372c79..8386b7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,8 +63,8 @@ importers: specifier: ^4.20.6 version: 4.21.0 turbo: - specifier: ^2.6.1 - version: 2.8.7 + specifier: 2.10.4 + version: 2.10.4 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@5.9.3) @@ -2683,38 +2683,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.8.7: - resolution: {integrity: sha512-Xr4TO/oDDwoozbDtBvunb66g//WK8uHRygl72vUthuwzmiw48pil4IuoG/QbMHd9RE8aBnVmzC0WZEWk/WWt3A==} - cpu: [x64] - os: [darwin] - - turbo-darwin-arm64@2.8.7: - resolution: {integrity: sha512-p8Xbmb9kZEY/NoshQUcFmQdO80s2PCGoLYj5DbpxjZr3diknipXxzOK7pcmT7l2gNHaMCpFVWLkiFY9nO3EU5w==} - cpu: [arm64] - os: [darwin] - - turbo-linux-64@2.8.7: - resolution: {integrity: sha512-nwfEPAH3m5y/nJeYly3j1YJNYU2EG5+2ysZUxvBNM+VBV2LjQaLxB9CsEIpIOKuWKCjnFHKIADTSDPZ3D12J5Q==} - cpu: [x64] - os: [linux] - - turbo-linux-arm64@2.8.7: - resolution: {integrity: sha512-mgA/M6xiJzyxtXV70TtWGDPh+I6acOKmeQGtOzbFQZYEf794pu5jax26bCk5skAp1gqZu3vacPr6jhYHoHU9IQ==} - cpu: [arm64] - os: [linux] - - turbo-windows-64@2.8.7: - resolution: {integrity: sha512-sHTYMaXuCcyHnGUQgfUUt7S8407TWoP14zc/4N2tsM0wZNK6V9h4H2t5jQPtqKEb6Fg8313kygdDgEwuM4vsHg==} - cpu: [x64] - os: [win32] - - turbo-windows-arm64@2.8.7: - resolution: {integrity: sha512-WyGiOI2Zp3AhuzVagzQN+T+iq0fWx0oGxDfAWT3ZiLEd4U0cDUkwUZDKVGb3rKqPjDL6lWnuxKKu73ge5xtovQ==} - cpu: [arm64] - os: [win32] - - turbo@2.8.7: - resolution: {integrity: sha512-RBLh5caMAu1kFdTK1jgH2gH/z+jFsvX5rGbhgJ9nlIAWXSvxlzwId05uDlBA1+pBd3wO/UaKYzaQZQBXDd7kcA==} + turbo@2.10.4: + resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} hasBin: true type-check@0.4.0: @@ -5486,32 +5456,7 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.8.7: - optional: true - - turbo-darwin-arm64@2.8.7: - optional: true - - turbo-linux-64@2.8.7: - optional: true - - turbo-linux-arm64@2.8.7: - optional: true - - turbo-windows-64@2.8.7: - optional: true - - turbo-windows-arm64@2.8.7: - optional: true - - turbo@2.8.7: - optionalDependencies: - turbo-darwin-64: 2.8.7 - turbo-darwin-arm64: 2.8.7 - turbo-linux-64: 2.8.7 - turbo-linux-arm64: 2.8.7 - turbo-windows-64: 2.8.7 - turbo-windows-arm64: 2.8.7 + turbo@2.10.4: {} type-check@0.4.0: dependencies: From 7e696c5d3cf8849dcbdc6e2f4b7b5b86360a4f8a Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:10:22 +0800 Subject: [PATCH 077/181] fix(dev-deps): patch esbuild path traversal --- package.json | 1 + pnpm-lock.yaml | 229 +++++++++++++++++++++++++------------------------ 2 files changed, 116 insertions(+), 114 deletions(-) diff --git a/package.json b/package.json index 868cc9c..7e6cd26 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "pnpm": { "overrides": { "@hono/node-server": "1.19.14", + "esbuild": "0.28.1", "express-rate-limit": "8.5.2", "fast-uri": "3.1.2", "flatted@<3.4.2": "3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8386b7a..ab675a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,7 @@ settings: overrides: '@hono/node-server': 1.19.14 + esbuild: 0.28.1 express-rate-limit: 8.5.2 fast-uri: 3.1.2 flatted@<3.4.2: 3.4.2 @@ -398,158 +399,158 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.3': - resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.3': - resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.3': - resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.3': - resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.3': - resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.3': - resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': - resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.3': - resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.3': - resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.3': - resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.3': - resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.3': - resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.3': - resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.3': - resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.3': - resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.3': - resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.3': - resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': - resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.3': - resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': - resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.3': - resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.3': - resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.3': - resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.3': - resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.3': - resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1122,7 +1123,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: 0.28.1 bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -1421,8 +1422,8 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} - esbuild@0.27.3: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -3174,82 +3175,82 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.27.3': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.3': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.3': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.3': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.3': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.3': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.3': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.3': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.3': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.3': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.3': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.3': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.3': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.3': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.3': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.3': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.3': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.3': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.3': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.3': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.3': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.3': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.3': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.3': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.3': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.3': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': @@ -3848,9 +3849,9 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - bundle-require@5.1.0(esbuild@0.27.3): + bundle-require@5.1.0(esbuild@0.28.1): dependencies: - esbuild: 0.27.3 + esbuild: 0.28.1 load-tsconfig: 0.2.5 bytes@3.1.2: {} @@ -4116,34 +4117,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.27.3: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.3 - '@esbuild/android-arm': 0.27.3 - '@esbuild/android-arm64': 0.27.3 - '@esbuild/android-x64': 0.27.3 - '@esbuild/darwin-arm64': 0.27.3 - '@esbuild/darwin-x64': 0.27.3 - '@esbuild/freebsd-arm64': 0.27.3 - '@esbuild/freebsd-x64': 0.27.3 - '@esbuild/linux-arm': 0.27.3 - '@esbuild/linux-arm64': 0.27.3 - '@esbuild/linux-ia32': 0.27.3 - '@esbuild/linux-loong64': 0.27.3 - '@esbuild/linux-mips64el': 0.27.3 - '@esbuild/linux-ppc64': 0.27.3 - '@esbuild/linux-riscv64': 0.27.3 - '@esbuild/linux-s390x': 0.27.3 - '@esbuild/linux-x64': 0.27.3 - '@esbuild/netbsd-arm64': 0.27.3 - '@esbuild/netbsd-x64': 0.27.3 - '@esbuild/openbsd-arm64': 0.27.3 - '@esbuild/openbsd-x64': 0.27.3 - '@esbuild/openharmony-arm64': 0.27.3 - '@esbuild/sunos-x64': 0.27.3 - '@esbuild/win32-arm64': 0.27.3 - '@esbuild/win32-ia32': 0.27.3 - '@esbuild/win32-x64': 0.27.3 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escape-html@1.0.3: {} @@ -5423,12 +5424,12 @@ snapshots: tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0): dependencies: - bundle-require: 5.1.0(esbuild@0.27.3) + bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.27.3 + esbuild: 0.28.1 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -5451,7 +5452,7 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.3 + esbuild: 0.28.1 get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3 @@ -5514,7 +5515,7 @@ snapshots: vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0): dependencies: - esbuild: 0.27.3 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 postcss: 8.5.6 From e8a4810b450657a795659d62ada3a5a7e4b15950 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:10:42 +0800 Subject: [PATCH 078/181] fix(dev-deps): patch PostCSS style injection --- package.json | 1 + pnpm-lock.yaml | 33 +++++++++++++++++---------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 7e6cd26..f16633b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "path-to-regexp": "8.4.2", "picomatch@<2.3.2": "2.3.2", "picomatch@>=4.0.0 <4.0.4": "4.0.5", + "postcss@<8.5.10": "8.5.16", "rollup@>=4.0.0 <4.59.0": "4.62.2", "vite@>=7.0.0 <7.3.5": "7.3.5", "ws@>=8.0.0 <8.21.0": "8.21.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab675a8..6228511 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ overrides: path-to-regexp: 8.4.2 picomatch@<2.3.2: 2.3.2 picomatch@>=4.0.0 <4.0.4: 4.0.5 + postcss@<8.5.10: 8.5.16 rollup@>=4.0.0 <4.59.0: 4.62.2 vite@>=7.0.0 <7.3.5: 7.3.5 ws@>=8.0.0 <8.21.0: 8.21.0 @@ -59,7 +60,7 @@ importers: version: 3.8.1 tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) tsx: specifier: ^4.20.6 version: 4.21.0 @@ -2111,8 +2112,8 @@ packages: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2274,7 +2275,7 @@ packages: engines: {node: '>= 18'} peerDependencies: jiti: '>=1.21.0' - postcss: '>=8.0.9' + postcss: 8.5.16 tsx: ^4.8.1 yaml: ^2.4.2 peerDependenciesMeta: @@ -2287,8 +2288,8 @@ packages: yaml: optional: true - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2667,7 +2668,7 @@ packages: peerDependencies: '@microsoft/api-extractor': ^7.36.0 '@swc/core': ^1 - postcss: ^8.4.12 + postcss: 8.5.16 typescript: '>=4.5.0' peerDependenciesMeta: '@microsoft/api-extractor': @@ -4889,7 +4890,7 @@ snapshots: nano-spawn@2.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.15: {} natural-compare@1.4.0: {} @@ -5024,18 +5025,18 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 - postcss: 8.5.6 + postcss: 8.5.16 tsx: 4.21.0 yaml: 2.9.0 - postcss@8.5.6: + postcss@8.5.16: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.15 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5422,7 +5423,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 @@ -5433,7 +5434,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.16)(tsx@4.21.0)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.2 source-map: 0.7.6 @@ -5442,7 +5443,7 @@ snapshots: tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.16 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -5518,7 +5519,7 @@ snapshots: esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - postcss: 8.5.6 + postcss: 8.5.16 rollup: 4.62.2 tinyglobby: 0.2.15 optionalDependencies: From ffa898f1c1038d5d8a24f60ca44692f39c132b30 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:11:11 +0800 Subject: [PATCH 079/181] fix(dev-deps): patch AJV regex denial of service --- package.json | 2 ++ pnpm-lock.yaml | 27 +++++++++------------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index f16633b..2e6553b 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "pnpm": { "overrides": { "@hono/node-server": "1.19.14", + "ajv@<7": "6.15.0", + "ajv@>=7 <8.18.0": "8.20.0", "esbuild": "0.28.1", "express-rate-limit": "8.5.2", "fast-uri": "3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6228511..9040933 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,8 @@ settings: overrides: '@hono/node-server': 1.19.14 + ajv@<7: 6.15.0 + ajv@>=7 <8.18.0: 8.20.0 esbuild: 0.28.1 express-rate-limit: 8.5.2 fast-uri: 3.1.2 @@ -1004,16 +1006,13 @@ packages: ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: - ajv: ^8.0.0 + ajv: 8.20.0 peerDependenciesMeta: ajv: optional: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -3116,7 +3115,7 @@ snapshots: '@commitlint/config-validator@20.4.4': dependencies: '@commitlint/types': 20.4.4 - ajv: 8.17.1 + ajv: 8.20.0 optional: true '@commitlint/execute-rule@20.0.0': @@ -3279,7 +3278,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 @@ -3728,21 +3727,13 @@ snapshots: optionalDependencies: ajv: 8.20.0 - ajv@6.12.6: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.2 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - optional: true - ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3 @@ -4176,7 +4167,7 @@ snapshots: '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 '@types/estree': 1.0.8 - ajv: 6.12.6 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 From b08224417c3bd2cefc4b0ce5e521760cacb3bccc Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:11:39 +0800 Subject: [PATCH 080/181] fix(dev-deps): patch brace expansion exhaustion --- package.json | 1 + pnpm-lock.yaml | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 2e6553b..2097da4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@hono/node-server": "1.19.14", "ajv@<7": "6.15.0", "ajv@>=7 <8.18.0": "8.20.0", + "brace-expansion@<1.1.13": "1.1.16", "esbuild": "0.28.1", "express-rate-limit": "8.5.2", "fast-uri": "3.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9040933..a2ecc2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ overrides: '@hono/node-server': 1.19.14 ajv@<7: 6.15.0 ajv@>=7 <8.18.0: 8.20.0 + brace-expansion@<1.1.13: 1.1.16 esbuild: 0.28.1 express-rate-limit: 8.5.2 fast-uri: 3.1.2 @@ -1105,8 +1106,8 @@ packages: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} @@ -3823,7 +3824,7 @@ snapshots: transitivePeerDependencies: - supports-color - brace-expansion@1.1.12: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -4852,7 +4853,7 @@ snapshots: minimatch@3.1.4: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.16 minimatch@9.0.7: dependencies: From 41e66ca2b053d30eabd582fe8b88e336af029d05 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:12:19 +0800 Subject: [PATCH 081/181] fix(dev-deps): patch YAML nesting exhaustion --- package.json | 3 ++- pnpm-lock.yaml | 14 ++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 2097da4..c198ba8 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "postcss@<8.5.10": "8.5.16", "rollup@>=4.0.0 <4.59.0": "4.62.2", "vite@>=7.0.0 <7.3.5": "7.3.5", - "ws@>=8.0.0 <8.21.0": "8.21.0" + "ws@>=8.0.0 <8.21.0": "8.21.0", + "yaml@>=2.0.0 <2.8.3": "2.9.0" } }, "scripts": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2ecc2f..fcac0b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,7 @@ overrides: rollup@>=4.0.0 <4.59.0: 4.62.2 vite@>=7.0.0 <7.3.5: 7.3.5 ws@>=8.0.0 <8.21.0: 8.21.0 + yaml@>=2.0.0 <2.8.3: 2.9.0 importers: @@ -2277,7 +2278,7 @@ packages: jiti: '>=1.21.0' postcss: 8.5.16 tsx: ^4.8.1 - yaml: ^2.4.2 + yaml: 2.9.0 peerDependenciesMeta: jiti: optional: true @@ -2766,7 +2767,7 @@ packages: sugarss: ^5.0.0 terser: ^5.16.0 tsx: ^4.8.1 - yaml: ^2.4.2 + yaml: 2.9.0 peerDependenciesMeta: '@types/node': optional: true @@ -2904,11 +2905,6 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} - engines: {node: '>= 14.6'} - hasBin: true - yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -4739,7 +4735,7 @@ snapshots: nano-spawn: 2.0.0 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.8.2 + yaml: 2.9.0 listr2@9.0.5: dependencies: @@ -5606,8 +5602,6 @@ snapshots: xmlchars@2.2.0: {} - yaml@2.8.2: {} - yaml@2.9.0: {} yocto-queue@0.1.0: {} From 07957369002a6911b227eb2e03554c3fec3ee000 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:12:48 +0800 Subject: [PATCH 082/181] fix(dev-deps): patch JS-YAML merge exhaustion --- package.json | 2 ++ pnpm-lock.yaml | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index c198ba8..bbf278e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "fast-uri": "3.1.2", "flatted@<3.4.2": "3.4.2", "hono": "4.12.29", + "js-yaml@<4": "3.15.0", + "js-yaml@>=4 <4.2.0": "4.3.0", "minimatch@<3.1.4": "3.1.4", "minimatch@>=9.0.0 <9.0.7": "9.0.7", "path-to-regexp": "8.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcac0b7..6a35797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,8 @@ overrides: fast-uri: 3.1.2 flatted@<3.4.2: 3.4.2 hono: 4.12.29 + js-yaml@<4: 3.15.0 + js-yaml@>=4 <4.2.0: 4.3.0 minimatch@<3.1.4: 3.1.4 minimatch@>=9.0.0 <9.0.7: 9.0.7 path-to-regexp: 8.4.2 @@ -1893,12 +1895,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsdom@26.1.0: @@ -3074,7 +3076,7 @@ snapshots: '@changesets/parse@0.4.3': dependencies: '@changesets/types': 6.1.0 - js-yaml: 4.1.1 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -3281,7 +3283,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.4 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3988,7 +3990,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -4640,12 +4642,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.0: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -5081,7 +5083,7 @@ snapshots: read-yaml-file@1.1.0: dependencies: graceful-fs: 4.2.11 - js-yaml: 3.14.2 + js-yaml: 3.15.0 pify: 4.0.1 strip-bom: 3.0.0 From ce26d30af189f6f4434c4358206329db8c58247d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:23:14 +0800 Subject: [PATCH 083/181] fix(tooling): pin Turbo platform binaries --- package.json | 10 +++++- pnpm-lock.yaml | 84 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bbf278e..6743b11 100644 --- a/package.json +++ b/package.json @@ -88,11 +88,19 @@ "prettier": "^3.6.2", "tsup": "^8.5.1", "tsx": "^4.20.6", - "turbo": "2.10.4", + "turbo": "2.9.18", "typedoc": "^0.28.20", "typescript": "^5.9.3", "typescript-eslint": "^8.46.4", "vite": "7.3.5", "vitest": "^4.1.10" + }, + "optionalDependencies": { + "@turbo/darwin-64": "2.9.18", + "@turbo/darwin-arm64": "2.9.18", + "@turbo/linux-64": "2.9.18", + "@turbo/linux-arm64": "2.9.18", + "@turbo/windows-64": "2.9.18", + "@turbo/windows-arm64": "2.9.18" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a35797..b94fd22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,8 +71,8 @@ importers: specifier: ^4.20.6 version: 4.21.0 turbo: - specifier: 2.10.4 - version: 2.10.4 + specifier: 2.9.18 + version: 2.9.18 typedoc: specifier: ^0.28.20 version: 0.28.20(typescript@5.9.3) @@ -88,6 +88,25 @@ importers: vitest: specifier: ^4.1.10 version: 4.1.10(@types/node@24.10.13)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.5(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.9.0)) + optionalDependencies: + '@turbo/darwin-64': + specifier: 2.9.18 + version: 2.9.18 + '@turbo/darwin-arm64': + specifier: 2.9.18 + version: 2.9.18 + '@turbo/linux-64': + specifier: 2.9.18 + version: 2.9.18 + '@turbo/linux-arm64': + specifier: 2.9.18 + version: 2.9.18 + '@turbo/windows-64': + specifier: 2.9.18 + version: 2.9.18 + '@turbo/windows-arm64': + specifier: 2.9.18 + version: 2.9.18 apps/extension: dependencies: @@ -851,6 +870,36 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@turbo/darwin-64@2.9.18': + resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.18': + resolution: {integrity: sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.18': + resolution: {integrity: sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.18': + resolution: {integrity: sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.18': + resolution: {integrity: sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.18': + resolution: {integrity: sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA==} + cpu: [arm64] + os: [win32] + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -2688,8 +2737,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.10.4: - resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} + turbo@2.9.18: + resolution: {integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==} hasBin: true type-check@0.4.0: @@ -3526,6 +3575,24 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@turbo/darwin-64@2.9.18': + optional: true + + '@turbo/darwin-arm64@2.9.18': + optional: true + + '@turbo/linux-64@2.9.18': + optional: true + + '@turbo/linux-arm64@2.9.18': + optional: true + + '@turbo/windows-64@2.9.18': + optional: true + + '@turbo/windows-arm64@2.9.18': + optional: true + '@types/aria-query@5.0.4': {} '@types/chai@5.2.3': @@ -5448,7 +5515,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.10.4: {} + turbo@2.9.18: + optionalDependencies: + '@turbo/darwin-64': 2.9.18 + '@turbo/darwin-arm64': 2.9.18 + '@turbo/linux-64': 2.9.18 + '@turbo/linux-arm64': 2.9.18 + '@turbo/windows-64': 2.9.18 + '@turbo/windows-arm64': 2.9.18 type-check@0.4.0: dependencies: From c532655807b64363956c6318f625a1cd799d7d42 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:10:52 +0800 Subject: [PATCH 084/181] fix(share): enforce a single data writer --- apps/share-server/README.md | 2 + apps/share-server/src/index.test.ts | 161 +++++++- apps/share-server/src/index.ts | 125 ++++-- apps/share-server/src/storage-lock.ts | 539 ++++++++++++++++++++++++++ 4 files changed, 792 insertions(+), 35 deletions(-) create mode 100644 apps/share-server/src/storage-lock.ts diff --git a/apps/share-server/README.md b/apps/share-server/README.md index 899eef8..e9bc2e6 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -140,6 +140,8 @@ The server stores data under: - `WEBBLACKBOX_SHARE_DATA_DIR` env var (if provided), otherwise - `.webblackbox-share-data/` in the current working directory +Run exactly one Share server process for each data directory. Startup atomically acquires a mode-`0600`, no-follow process lock bound to the directory identity before it creates, migrates, reconciles, prunes, or rotates managed data. A second live process fails startup without touching that directory. Graceful shutdown releases the lock; after a crash, a later process removes it only when its same-host PID is confirmed absent. A lock attributed to another host or with invalid path, ownership, or directory-binding metadata is refused rather than reclaimed, so a data directory must not be shared by concurrently running hosts. + Each share writes: - `archives/.webblackbox` diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index ba4719e..c8fcc4f 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -37,6 +37,7 @@ type RunningShareServer = { child: ChildProcess; dataDir: string; logs: string[]; + exit: { code: number | null; signal: NodeJS.Signals | null } | null; }; let runningServers: RunningShareServer[] = []; @@ -1264,6 +1265,114 @@ describe("share-server", () => { expect(metadataResponse.status).toBe(200); }); + it("rejects a second writer without touching the first process storage", async () => { + const first = await startShareServer(); + const archiveTemporaryPath = resolve(first.dataDir, "archives", ".first-writer.upload"); + const recordTemporaryPath = resolve(first.dataDir, "records", ".first-writer.record"); + await writeFile(archiveTemporaryPath, "first-writer-upload"); + await writeFile(recordTemporaryPath, "first-writer-record"); + + const second = await spawnShareServer({}, first.dataDir); + const secondExit = await waitForShareServerExit(second); + + expect(secondExit).toEqual({ code: 1, signal: null }); + expect(second.logs.join("")).toContain("is already in use by a running share-server process"); + await expect(readFile(archiveTemporaryPath, "utf8")).resolves.toBe("first-writer-upload"); + await expect(readFile(recordTemporaryPath, "utf8")).resolves.toBe("first-writer-record"); + + const upload = await uploadEncryptedFixture(first); + const metadataResponse = await fetch(`${first.baseUrl}/api/share/${upload.shareId}/meta`, { + headers: { + "x-webblackbox-api-key": apiKey + } + }); + expect(metadataResponse.status).toBe(200); + + await stopShareServer(second, false); + await stopShareServer(first, false); + const lockPath = resolve(first.dataDir, ".share-server.lock"); + await expect(stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect( + (await readdir(first.dataDir)).filter((name) => name.startsWith(".share-server.lock")) + ).toEqual([]); + + const restarted = await startShareServer({}, first.dataDir); + const restartedMetadataResponse = await fetch( + `${restarted.baseUrl}/api/share/${upload.shareId}/meta`, + { + headers: { + "x-webblackbox-api-key": apiKey + } + } + ); + expect(restartedMetadataResponse.status).toBe(200); + }); + + it("recovers a complete stale writer lock after a crashed process exits", async () => { + const crashed = await startShareServer(); + const lockPath = resolve(crashed.dataDir, ".share-server.lock"); + const originalLock = JSON.parse(await readFile(lockPath, "utf8")) as { + nonce: string; + ownerFile: string; + pid: number; + }; + const originalLockStat = await stat(lockPath); + if (process.platform !== "win32") { + expect(originalLockStat.mode & 0o777).toBe(0o600); + } + expect(originalLockStat.nlink).toBe(2); + + process.kill(originalLock.pid, "SIGKILL"); + await waitForLocalProcessExit(originalLock.pid); + await waitForShareServerExit(crashed); + await stopShareServer(crashed, false); + await expect(stat(lockPath)).resolves.toBeDefined(); + + const restarted = await startShareServer({}, crashed.dataDir); + const replacementLock = JSON.parse(await readFile(lockPath, "utf8")) as { + nonce: string; + ownerFile: string; + pid: number; + }; + expect(replacementLock.pid).not.toBe(originalLock.pid); + expect(() => process.kill(replacementLock.pid, 0)).not.toThrow(); + expect(replacementLock.nonce).not.toBe(originalLock.nonce); + await expect(stat(resolve(crashed.dataDir, originalLock.ownerFile))).rejects.toMatchObject({ + code: "ENOENT" + }); + + const listResponse = await fetch(`${restarted.baseUrl}/api/share/list`, { + headers: { + "x-webblackbox-api-key": apiKey + } + }); + expect(listResponse.status).toBe(200); + }); + + it("refuses a process-lock symlink without changing its target", async () => { + const initialized = await startShareServer(); + await stopShareServer(initialized, false); + const externalDirectory = await mkdtemp(resolve(tmpdir(), "webblackbox-share-lock-target-")); + const externalTarget = resolve(externalDirectory, "external-lock-target"); + + try { + await writeFile(externalTarget, "external-target-must-not-change"); + await symlink(externalTarget, resolve(initialized.dataDir, ".share-server.lock")); + + const refused = await spawnShareServer({}, initialized.dataDir); + const refusedExit = await waitForShareServerExit(refused); + expect(refusedExit).toEqual({ code: 1, signal: null }); + expect(refused.logs.join("")).toContain("Unsafe share data directory lock path"); + await expect(readFile(externalTarget, "utf8")).resolves.toBe( + "external-target-must-not-change" + ); + await stopShareServer(refused, false); + } finally { + await rm(initialized.dataDir, { recursive: true, force: true }); + await rm(externalDirectory, { recursive: true, force: true }); + } + }); + it("migrates legacy storage directories and committed files to private modes on restart", async () => { if (process.platform === "win32") { return; @@ -2300,6 +2409,15 @@ async function createFullNamedPipe(path: string): Promise = {}, existingDataDir?: string +): Promise { + const server = await spawnShareServer(envOverrides, existingDataDir); + await waitForShareServer(server); + return server; +} + +async function spawnShareServer( + envOverrides: Record = {}, + existingDataDir?: string ): Promise { const port = await reservePort(); const dataDir = existingDataDir ?? (await mkdtemp(resolve(tmpdir(), "webblackbox-share-test-"))); @@ -2324,9 +2442,13 @@ async function startShareServer( baseUrl: `http://127.0.0.1:${port}`, child, dataDir, - logs: [] + logs: [], + exit: null }; + child.once("exit", (code, signal) => { + server.exit = { code, signal }; + }); child.stdout?.on("data", (chunk) => { server.logs.push(String(chunk)); }); @@ -2334,11 +2456,44 @@ async function startShareServer( server.logs.push(String(chunk)); }); runningServers.push(server); - - await waitForShareServer(server); return server; } +async function waitForShareServerExit( + server: RunningShareServer, + timeoutMs = 5_000 +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + if (server.exit) { + return server.exit; + } + + return await new Promise((resolvePromise, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`share-server did not exit: ${server.logs.join("")}`)); + }, timeoutMs); + server.child.once("exit", (code, signal) => { + clearTimeout(timeout); + resolvePromise({ code, signal }); + }); + }); +} + +async function waitForLocalProcessExit(pid: number, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ESRCH") { + return; + } + throw error; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } + throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms.`); +} + async function stopShareServer(server: RunningShareServer, removeData = true): Promise { if (server.child.exitCode === null && !server.child.killed) { server.child.kill("SIGTERM"); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index e24289a..ed53256 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -21,6 +21,7 @@ import { exportManifestSchema, type ExportManifest } from "@webblackbox/protocol import { BoundedAuditWriter, type AuditWriteResult } from "./audit-writer.js"; import { parseShareApiCredentials, type ShareApiScope } from "./auth-config.js"; +import { acquireShareDataRootLock, type ShareDataRootLock } from "./storage-lock.js"; type ShareRecord = { id: string; @@ -324,36 +325,54 @@ async function startShareServer(): Promise { "A scoped WEBBLACKBOX_SHARE_API_KEY or WEBBLACKBOX_SHARE_API_KEYS credential is required for a non-loopback public origin." ); } - await ensureStorageLayout(); - await migrateLegacyStoragePermissions(); - await reconcileAuditLogLayout(); - await reconcileStorageLayout(); - await pruneExpiredShareRecords(Date.now()); + const dataRootIdentity = await initializeDataRoot(); + const dataRootLock = await acquireShareDataRootLock(DATA_ROOT, dataRootIdentity); + let server: Server | null = null; - const activeRequests = new Set>(); - let acceptingRequests = true; - const server = createServer((request, response) => { - if (!acceptingRequests) { - response.setHeader("connection", "close"); - respondJson(response, 503, { - error: "Share server is shutting down." - }); - return; - } + try { + await ensureStorageLayout(dataRootIdentity); + await migrateLegacyStoragePermissions(); + await reconcileAuditLogLayout(); + await reconcileStorageLayout(); + await pruneExpiredShareRecords(Date.now()); - const requestTask = handleRequest(request, response); - activeRequests.add(requestTask); - void requestTask.then( - () => activeRequests.delete(requestTask), - () => activeRequests.delete(requestTask) + const activeRequests = new Set>(); + let acceptingRequests = true; + server = createServer((request, response) => { + if (!acceptingRequests) { + response.setHeader("connection", "close"); + respondJson(response, 503, { + error: "Share server is shutting down." + }); + return; + } + + const requestTask = handleRequest(request, response); + activeRequests.add(requestTask); + void requestTask.then( + () => activeRequests.delete(requestTask), + () => activeRequests.delete(requestTask) + ); + }); + await listenForShareRequests(server, port, host); + const shutdownHandlers = installGracefulShutdownHandlers( + server, + activeRequests, + () => { + acceptingRequests = false; + }, + dataRootLock ); - }); - const removeSignalHandlers = installGracefulShutdownHandlers(server, activeRequests, () => { - acceptingRequests = false; - }); - server.once("close", removeSignalHandlers); + server.once("close", () => { + shutdownHandlers.remove(); + if (!shutdownHandlers.hasStarted()) { + void finalizeNormallyClosedShareServer(activeRequests, dataRootLock).catch((error) => { + console.error("[share-server] normal close cleanup failed", error); + process.exitCode = 1; + }); + } + }); - server.listen(port, host, () => { console.info(`[share-server] listening on http://${host}:${port}`); console.info(`[share-server] public origin: ${sharePublicOrigin}`); console.info(`[share-server] data root: ${DATA_ROOT}`); @@ -362,6 +381,29 @@ async function startShareServer(): Promise { "[share-server] WARNING: unauthenticated loopback development access is enabled." ); } + } catch (error) { + if (server) { + server.closeAllConnections(); + await closeHttpServer(server); + } + await dataRootLock.release(); + throw error; + } +} + +function listenForShareRequests(server: Server, port: number, host: string): Promise { + return new Promise((resolvePromise, reject) => { + const handleError = (error: Error) => { + server.off("listening", handleListening); + reject(error); + }; + const handleListening = () => { + server.off("error", handleError); + resolvePromise(); + }; + server.once("error", handleError); + server.once("listening", handleListening); + server.listen(port, host); }); } @@ -398,8 +440,9 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) function installGracefulShutdownHandlers( server: Server, activeRequests: Set>, - stopAcceptingRequests: () => void -): () => void { + stopAcceptingRequests: () => void, + dataRootLock: ShareDataRootLock +): { remove: () => void; hasStarted: () => boolean } { let shutdownStarted = false; const handleSigterm = () => beginShutdown("SIGTERM"); const handleSigint = () => beginShutdown("SIGINT"); @@ -415,7 +458,7 @@ function installGracefulShutdownHandlers( shutdownStarted = true; stopAcceptingRequests(); removeHandlers(); - void shutdownShareServer(server, activeRequests, signal).then( + void shutdownShareServer(server, activeRequests, signal, dataRootLock).then( (drained) => { if (drained) { process.exit(0); @@ -432,13 +475,17 @@ function installGracefulShutdownHandlers( process.once("SIGTERM", handleSigterm); process.once("SIGINT", handleSigint); - return removeHandlers; + return { + remove: removeHandlers, + hasStarted: () => shutdownStarted + }; } async function shutdownShareServer( server: Server, activeRequests: Set>, - signal: "SIGINT" | "SIGTERM" + signal: "SIGINT" | "SIGTERM", + dataRootLock: ShareDataRootLock ): Promise { console.info(`[share-server] received ${signal}; draining requests and audit events`); const closePromise = closeHttpServer(server); @@ -457,10 +504,20 @@ async function shutdownShareServer( return false; } + await dataRootLock.release(); console.info("[share-server] shutdown drain complete"); return true; } +async function finalizeNormallyClosedShareServer( + activeRequests: Set>, + dataRootLock: ShareDataRootLock +): Promise { + await waitForActiveRequests(activeRequests); + await auditWriter.drain(); + await dataRootLock.release(); +} + async function waitForActiveRequests(activeRequests: Set>): Promise { while (activeRequests.size > 0) { await Promise.allSettled([...activeRequests]); @@ -2012,7 +2069,7 @@ function roundTo(value: number, digits: number): number { return Math.round(value * factor) / factor; } -async function ensureStorageLayout(): Promise { +async function initializeDataRoot(): Promise { storageDirectoryIdentities.clear(); await mkdir(DATA_ROOT, { recursive: true, @@ -2020,7 +2077,11 @@ async function ensureStorageLayout(): Promise { }); const dataRootIdentity = await hardenStorageDirectory(DATA_ROOT); storageDirectoryIdentities.set(DATA_ROOT, dataRootIdentity); + return dataRootIdentity; +} +async function ensureStorageLayout(dataRootIdentity: BigIntStats): Promise { + await assertStoragePathIdentity(DATA_ROOT, dataRootIdentity, "directory"); for (const directoryPath of [ARCHIVES_DIR, RECORDS_DIR, AUDIT_DIR]) { await assertStoragePathIdentity(DATA_ROOT, dataRootIdentity, "directory"); try { diff --git a/apps/share-server/src/storage-lock.ts b/apps/share-server/src/storage-lock.ts new file mode 100644 index 0000000..5a50730 --- /dev/null +++ b/apps/share-server/src/storage-lock.ts @@ -0,0 +1,539 @@ +import { randomUUID } from "node:crypto"; +import { constants as fsConstants, type BigIntStats } from "node:fs"; +import { hostname } from "node:os"; +import { join, resolve } from "node:path"; +import { link, lstat, open, unlink, type FileHandle } from "node:fs/promises"; + +const LOCK_FILE_NAME = ".share-server.lock"; +const LOCK_SCHEMA_VERSION = 1; +const MAX_LOCK_FILE_BYTES = 4 * 1024; +const MAX_ACQUIRE_ATTEMPTS = 8; +const LOCK_NONCE_PATTERN = /^[a-f0-9]{32}$/; + +type ShareDataRootLockRecord = Readonly<{ + schemaVersion: 1; + pid: number; + nonce: string; + hostname: string; + createdAt: number; + dataRoot: string; + dataRootDevice: string; + dataRootInode: string; + ownerFile: string; +}>; + +type InspectedShareDataRootLock = Readonly<{ + handle: FileHandle; + stat: BigIntStats; + record: ShareDataRootLockRecord; + ownerPath: string; +}>; + +export class ShareDataRootLockedError extends Error { + constructor(message: string) { + super(message); + this.name = "ShareDataRootLockedError"; + } +} + +export class ShareDataRootLock { + private released = false; + + constructor( + private readonly dataRoot: string, + private readonly dataRootIdentity: BigIntStats, + private readonly lockPath: string, + private readonly ownerPath: string, + private readonly handle: FileHandle, + private readonly lockIdentity: BigIntStats + ) {} + + async release(): Promise { + if (this.released) { + return; + } + + let publicLockRemoved = false; + try { + await assertDataRootIdentity(this.dataRoot, this.dataRootIdentity); + const currentHandleStat = await this.handle.stat({ bigint: true }); + assertSafePublishedLockStat(currentHandleStat); + assertSameFileSystemObject(currentHandleStat, this.lockIdentity, this.lockPath); + await assertLockLinkIdentity(this.lockPath, currentHandleStat); + await assertLockLinkIdentity(this.ownerPath, currentHandleStat); + await assertDataRootIdentity(this.dataRoot, this.dataRootIdentity); + + await unlink(this.lockPath); + publicLockRemoved = true; + + await assertLockLinkIdentity(this.ownerPath, currentHandleStat); + await unlink(this.ownerPath); + await syncDirectoryBestEffort(this.dataRoot); + } finally { + if (publicLockRemoved) { + this.released = true; + await this.handle.close().catch(() => undefined); + } + } + } +} + +export async function acquireShareDataRootLock( + dataRootPath: string, + dataRootIdentity: BigIntStats +): Promise { + const dataRoot = resolve(dataRootPath); + const lockPath = join(dataRoot, LOCK_FILE_NAME); + await assertDataRootIdentity(dataRoot, dataRootIdentity); + + for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt += 1) { + const published = await tryPublishLock(dataRoot, dataRootIdentity, lockPath); + if (published) { + return published; + } + + const existing = await inspectPublishedLock(dataRoot, dataRootIdentity, lockPath); + if (!existing) { + continue; + } + + try { + assertLockRecordBoundToDataRoot(existing.record, dataRoot, dataRootIdentity); + if (existing.record.hostname !== hostname()) { + throw new ShareDataRootLockedError( + `Share data directory "${dataRoot}" is locked by host "${existing.record.hostname}"; ` + + "remote process liveness cannot be verified safely." + ); + } + if (isLocalProcessAlive(existing.record.pid)) { + throw new ShareDataRootLockedError( + `Share data directory "${dataRoot}" is already in use by a running ` + + `share-server process (pid ${existing.record.pid}).` + ); + } + + const recovered = await recoverStalePublishedLock( + dataRoot, + dataRootIdentity, + lockPath, + existing + ); + if (!recovered) { + continue; + } + } finally { + await existing.handle.close().catch(() => undefined); + } + } + + throw new ShareDataRootLockedError( + `Share data directory "${dataRoot}" lock changed repeatedly during startup.` + ); +} + +async function tryPublishLock( + dataRoot: string, + dataRootIdentity: BigIntStats, + lockPath: string +): Promise { + const nonce = randomUUID().replaceAll("-", ""); + const ownerFile = ownerFileName(process.pid, nonce); + const ownerPath = join(dataRoot, ownerFile); + const record: ShareDataRootLockRecord = { + schemaVersion: LOCK_SCHEMA_VERSION, + pid: process.pid, + nonce, + hostname: hostname(), + createdAt: Date.now(), + dataRoot, + dataRootDevice: dataRootIdentity.dev.toString(), + dataRootInode: dataRootIdentity.ino.toString(), + ownerFile + }; + const payload = Buffer.from(`${JSON.stringify(record)}\n`, "utf8"); + if (payload.byteLength > MAX_LOCK_FILE_BYTES) { + throw new Error("Share data directory lock metadata exceeds its safety limit."); + } + + let handle: FileHandle | null = null; + let ownerIdentity: BigIntStats | null = null; + let published = false; + try { + await assertDataRootIdentity(dataRoot, dataRootIdentity); + handle = await open( + ownerPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, + 0o600 + ); + await writeFileHandleFully(handle, payload); + if (process.platform !== "win32") { + await handle.chmod(0o600); + } + await handle.sync(); + ownerIdentity = await handle.stat({ bigint: true }); + assertSafeOwnerLockStat(ownerIdentity, payload.byteLength, ownerPath); + await assertLockLinkIdentity(ownerPath, ownerIdentity); + await assertDataRootIdentity(dataRoot, dataRootIdentity); + + try { + await link(ownerPath, lockPath); + } catch (error) { + if (hasFileSystemErrorCode(error, "EEXIST")) { + return null; + } + throw error; + } + + published = true; + const publishedStat = await handle.stat({ bigint: true }); + assertSafePublishedLockStat(publishedStat); + assertSameFileSystemObject(publishedStat, ownerIdentity, lockPath); + await assertLockLinkIdentity(ownerPath, publishedStat); + await assertLockLinkIdentity(lockPath, publishedStat); + await assertDataRootIdentity(dataRoot, dataRootIdentity); + await syncDirectoryBestEffort(dataRoot); + + const result = new ShareDataRootLock( + dataRoot, + dataRootIdentity, + lockPath, + ownerPath, + handle, + publishedStat + ); + handle = null; + return result; + } finally { + if (handle) { + await handle.close().catch(() => undefined); + } + if (!published && ownerIdentity) { + await unlinkLockLinkIfUnchanged(dataRoot, dataRootIdentity, ownerPath, ownerIdentity); + } + } +} + +async function inspectPublishedLock( + dataRoot: string, + dataRootIdentity: BigIntStats, + lockPath: string +): Promise { + await assertDataRootIdentity(dataRoot, dataRootIdentity); + + let pathStat: BigIntStats; + try { + pathStat = await lstat(lockPath, { bigint: true }); + } catch (error) { + if (hasFileSystemErrorCode(error, "ENOENT")) { + return null; + } + throw error; + } + + let handle: FileHandle | null = null; + try { + handle = await open(lockPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const openedStat = await handle.stat({ bigint: true }); + assertSafePublishedLockStat(openedStat); + assertSameFileSystemObject(pathStat, openedStat, lockPath); + const payload = await readFileHandleExactly(handle, Number(openedStat.size)); + const record = parseLockRecord(payload); + const ownerPath = join(dataRoot, record.ownerFile); + await assertLockLinkIdentity(lockPath, openedStat); + await assertLockLinkIdentity(ownerPath, openedStat); + await assertDataRootIdentity(dataRoot, dataRootIdentity); + + const result = { + handle, + stat: openedStat, + record, + ownerPath + }; + handle = null; + return result; + } catch (error) { + if (hasFileSystemErrorCode(error, "ENOENT")) { + return null; + } + if (error instanceof ShareDataRootLockedError) { + throw error; + } + throw unsafeLockError(lockPath, error); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function recoverStalePublishedLock( + dataRoot: string, + dataRootIdentity: BigIntStats, + lockPath: string, + existing: InspectedShareDataRootLock +): Promise { + const recoveryNonce = randomUUID().replaceAll("-", ""); + const recoveryPath = join( + dataRoot, + `.share-server.lock.recovery.${process.pid}.${recoveryNonce}` + ); + let recoveryLinked = false; + let publicLockRemoved = false; + + try { + await assertLockLinkIdentity(lockPath, existing.stat); + await assertLockLinkIdentity(existing.ownerPath, existing.stat); + await assertDataRootIdentity(dataRoot, dataRootIdentity); + try { + await link(lockPath, recoveryPath); + recoveryLinked = true; + } catch (error) { + if (hasFileSystemErrorCode(error, "ENOENT") || hasFileSystemErrorCode(error, "EEXIST")) { + return false; + } + throw error; + } + + const claimedStat = await existing.handle.stat({ bigint: true }); + if ( + !claimedStat.isFile() || + claimedStat.nlink !== 3n || + !hasMode(claimedStat, 0o600n) || + !isSameFileSystemObject(claimedStat, existing.stat) + ) { + return false; + } + + await assertLockLinkIdentity(recoveryPath, claimedStat); + await assertLockLinkIdentity(existing.ownerPath, claimedStat); + await assertLockLinkIdentity(lockPath, claimedStat); + if (isLocalProcessAlive(existing.record.pid)) { + throw new ShareDataRootLockedError( + `Share data directory "${dataRoot}" became owned by a running process during recovery.` + ); + } + await assertDataRootIdentity(dataRoot, dataRootIdentity); + + await unlink(lockPath); + publicLockRemoved = true; + await assertLockLinkIdentity(existing.ownerPath, claimedStat); + await unlink(existing.ownerPath); + await assertLockLinkIdentity(recoveryPath, claimedStat); + await unlink(recoveryPath); + recoveryLinked = false; + await syncDirectoryBestEffort(dataRoot); + return true; + } finally { + if (recoveryLinked) { + await unlinkLockLinkIfUnchanged(dataRoot, dataRootIdentity, recoveryPath, existing.stat); + } + if (publicLockRemoved) { + await syncDirectoryBestEffort(dataRoot); + } + } +} + +function parseLockRecord(payload: Uint8Array): ShareDataRootLockRecord { + let value: unknown; + try { + value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(payload)); + } catch (error) { + throw new Error("Share data directory lock metadata is not valid UTF-8 JSON.", { + cause: error + }); + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Share data directory lock metadata must be an object."); + } + const record = value as Record; + if ( + record.schemaVersion !== LOCK_SCHEMA_VERSION || + !Number.isSafeInteger(record.pid) || + (record.pid as number) <= 0 || + typeof record.nonce !== "string" || + !LOCK_NONCE_PATTERN.test(record.nonce) || + typeof record.hostname !== "string" || + record.hostname.length === 0 || + record.hostname.length > 255 || + !Number.isSafeInteger(record.createdAt) || + (record.createdAt as number) < 0 || + typeof record.dataRoot !== "string" || + typeof record.dataRootDevice !== "string" || + !/^\d+$/.test(record.dataRootDevice) || + typeof record.dataRootInode !== "string" || + !/^\d+$/.test(record.dataRootInode) || + typeof record.ownerFile !== "string" || + record.ownerFile !== ownerFileName(record.pid as number, record.nonce) + ) { + throw new Error("Share data directory lock metadata is invalid."); + } + + return record as ShareDataRootLockRecord; +} + +function assertLockRecordBoundToDataRoot( + record: ShareDataRootLockRecord, + dataRoot: string, + dataRootIdentity: BigIntStats +): void { + if ( + record.dataRoot !== dataRoot || + record.dataRootDevice !== dataRootIdentity.dev.toString() || + record.dataRootInode !== dataRootIdentity.ino.toString() + ) { + throw new ShareDataRootLockedError( + `Share data directory "${dataRoot}" contains a lock bound to a different directory identity.` + ); + } +} + +function assertSafeOwnerLockStat(stat: BigIntStats, expectedBytes: number, lockPath: string): void { + if ( + !stat.isFile() || + stat.nlink !== 1n || + stat.size !== BigInt(expectedBytes) || + !hasMode(stat, 0o600n) + ) { + throw unsafeLockError(lockPath); + } +} + +function assertSafePublishedLockStat(stat: BigIntStats): void { + if ( + !stat.isFile() || + stat.nlink !== 2n || + stat.size <= 0n || + stat.size > BigInt(MAX_LOCK_FILE_BYTES) || + !hasMode(stat, 0o600n) + ) { + throw new Error("Published share data directory lock has unsafe file metadata."); + } +} + +async function assertDataRootIdentity( + dataRoot: string, + expectedIdentity: BigIntStats +): Promise { + const current = await lstat(dataRoot, { bigint: true }); + if ( + !current.isDirectory() || + !hasMode(current, 0o700n) || + !isSameFileSystemObject(current, expectedIdentity) + ) { + throw new Error(`Share data directory "${dataRoot}" changed while managing its process lock.`); + } +} + +async function assertLockLinkIdentity(lockPath: string, expected: BigIntStats): Promise { + const current = await lstat(lockPath, { bigint: true }); + if ( + !current.isFile() || + !hasMode(current, 0o600n) || + !isSameFileSystemObject(current, expected) + ) { + throw unsafeLockError(lockPath); + } +} + +async function unlinkLockLinkIfUnchanged( + dataRoot: string, + dataRootIdentity: BigIntStats, + lockPath: string, + expectedIdentity: BigIntStats +): Promise { + try { + await assertDataRootIdentity(dataRoot, dataRootIdentity); + await assertLockLinkIdentity(lockPath, expectedIdentity); + await unlink(lockPath); + } catch (error) { + if (hasFileSystemErrorCode(error, "ENOENT")) { + return; + } + throw error; + } +} + +function ownerFileName(pid: number, nonce: string): string { + return `.share-server.lock.owner.${pid}.${nonce}`; +} + +function isLocalProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (hasFileSystemErrorCode(error, "EPERM")) { + return true; + } + if (hasFileSystemErrorCode(error, "ESRCH")) { + return false; + } + throw error; + } +} + +async function writeFileHandleFully(handle: FileHandle, payload: Uint8Array): Promise { + let offset = 0; + while (offset < payload.byteLength) { + const { bytesWritten } = await handle.write( + payload, + offset, + payload.byteLength - offset, + offset + ); + if (bytesWritten <= 0) { + throw new Error("Share data directory lock write made no progress."); + } + offset += bytesWritten; + } +} + +async function readFileHandleExactly(handle: FileHandle, size: number): Promise { + const payload = new Uint8Array(size); + let offset = 0; + while (offset < size) { + const { bytesRead } = await handle.read(payload, offset, size - offset, offset); + if (bytesRead <= 0) { + throw new Error("Share data directory lock ended before its recorded size."); + } + offset += bytesRead; + } + return payload; +} + +async function syncDirectoryBestEffort(directoryPath: string): Promise { + let handle: FileHandle | null = null; + try { + handle = await open(directoryPath, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY); + await handle.sync(); + } catch { + // Some supported filesystems do not allow directory handles to be synced. + } finally { + await handle?.close().catch(() => undefined); + } +} + +function hasMode(stat: BigIntStats, expectedMode: bigint): boolean { + return process.platform === "win32" || (stat.mode & 0o777n) === expectedMode; +} + +function assertSameFileSystemObject( + current: BigIntStats, + expected: BigIntStats, + lockPath: string +): void { + if (!isSameFileSystemObject(current, expected)) { + throw unsafeLockError(lockPath); + } +} + +function isSameFileSystemObject(left: BigIntStats, right: BigIntStats): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function unsafeLockError(lockPath: string, cause?: unknown): Error { + return new Error(`Unsafe share data directory lock path "${lockPath}".`, { cause }); +} + +function hasFileSystemErrorCode(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && error.code === code; +} From caa4354996e5f3f8ba8e31b3bbdcf29798930fc5 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:30:38 +0800 Subject: [PATCH 085/181] fix(release): version all surfaces in lockstep --- .changeset/config.json | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 2be13d4..086dd3a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,28 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], + "fixed": [ + [ + "@webblackbox/cdp-router", + "@webblackbox/extension", + "@webblackbox/mcp-server", + "@webblackbox/pipeline", + "@webblackbox/player", + "@webblackbox/player-sdk", + "@webblackbox/protocol", + "@webblackbox/recorder", + "@webblackbox/share-server", + "webblackbox" + ] + ], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "privatePackages": { + "version": true, + "tag": false + } } From 2d2ea14be0337c8135b675b18ac666e60367119e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:17:27 +0800 Subject: [PATCH 086/181] fix(share): protect audit client hashes with HMAC --- apps/share-server/README.md | 11 +- apps/share-server/src/index.test.ts | 228 ++++++++++++++++-- apps/share-server/src/index.ts | 356 +++++++++++++++++++++++++++- 3 files changed, 572 insertions(+), 23 deletions(-) diff --git a/apps/share-server/README.md b/apps/share-server/README.md index e9bc2e6..825fb51 100644 --- a/apps/share-server/README.md +++ b/apps/share-server/README.md @@ -42,6 +42,7 @@ Set these environment variables for production-like deployments: - `WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS`: how long expired share records/files are retained before pruning (default `2592000000`, 30 days). Set it to `0` to prune as soon as a share expires. TTL and retention values must be safe integers whose combined timestamp remains representable; invalid startup configuration is rejected. - `WEBBLACKBOX_SHARE_MAX_RECORDS`: upload admission limit for retained record files (default `10000`, with a non-relaxable hard ceiling of `10000`). Uploads fail with `507` before body buffering when capacity remains full after expiry pruning. Lowering this value blocks new uploads at the new limit but does not prevent startup, listing, metadata access, or revocation for existing valid records within the hard ceiling. - `WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES`: rotate the serialized audit log at this byte size (default `16777216`, minimum `512`, hard ceiling `67108864`). Values outside that range are rejected at startup. One previous segment is retained. +- `WEBBLACKBOX_SHARE_AUDIT_HMAC_SECRET`: optional canonical base64url key for audit client hashes. It must decode to 32–64 bytes and pass the startup entropy floor (at least 128 estimated bits); invalid or weak values fail startup without echoing the value. When omitted, the server atomically creates a random 32-byte mode-`0600` key at `audit/client-hash.hmac.key`. Generate an environment value with `node -e 'console.log(require("node:crypto").randomBytes(32).toString("base64url"))'` and keep it out of logs and command history. - `WEBBLACKBOX_SHARE_SHUTDOWN_TIMEOUT_MS`: maximum time to drain in-flight requests and queued audit events after `SIGINT` or `SIGTERM` (default `10000`, hard ceiling `60000`). A timeout forces remaining connections closed and terminates the process. - `WEBBLACKBOX_SHARE_MAX_AUDIT_QUEUE_ENTRIES`: maximum in-process audit appends, including the active append (default `128`, hard ceiling `1024`). Events above this capacity are dropped with a rate-limited operational warning instead of extending the request backlog. - `WEBBLACKBOX_SHARE_AUDIT_QUEUE_TIMEOUT_MS`: maximum time an admitted audit event may wait to start (default `1000`, hard ceiling `30000`). Stale events are dropped rather than written out of order long after their request. @@ -147,12 +148,16 @@ Each share writes: - `archives/.webblackbox` - `records/.json` (redacted public summary only) - `audit/share-access.jsonl` (action, outcome, share id, timestamp, and client hash only) +- `audit/client-hash.hmac.key` (default local audit-HMAC key; absent when an environment key is used) -On POSIX systems, storage directories are enforced as mode `0700`; committed archives, record JSON, and the active/rotated audit logs are enforced as mode `0600`. Startup migrates legacy `0755`/`0644` installations through no-follow file handles and refuses managed directory or committed-file symlinks and path replacements instead of changing an external target. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. +On POSIX systems, storage directories are enforced as mode `0700`; committed archives, record JSON, the audit-HMAC key, and the active/rotated audit logs are enforced as mode `0600`. Startup migrates legacy `0755`/`0644` installations through no-follow file handles and refuses managed directory or committed-file symlinks and path replacements instead of changing an external target. Uploads are streamed to mode-`0600` temporary files under `archives/`, synced, removed on every rejection or disconnect, and atomically renamed only after validation succeeds. Record JSON is likewise written through a synced mode-`0600` temporary file and atomic rename. At startup the server removes interrupted temporary files, orphan archives, orphan records, and corrupt record/archive pairs before serving requests. -Startup also reconciles both `audit/share-access.jsonl` and `audit/share-access.1.jsonl` before accepting requests. The server refuses to start if either file exceeds the configured audit-log ceiling or if a non-empty file does not end on a complete JSONL line. It never byte-truncates an audit file because that can turn a partial record into a misleading line on the next append. Move or delete the whole reported file, preserving it separately if required by your audit-retention policy, and then restart. Lowering the configured ceiling can therefore require an explicit operator rotation or removal of legacy logs. +Startup also reconciles both `audit/share-access.jsonl` and `audit/share-access.1.jsonl` before accepting requests. Complete, within-limit segments that cannot prove they use the HMAC audit schema are deleted rather than retaining legacy publicly salted SHA-256 client hashes that permit offline IPv4 enumeration. If either segment exceeds the configured audit-log ceiling or does not end on a complete JSONL line, neither segment is migrated automatically and startup fails with the original files intact. The server never byte-truncates an audit file because that can turn a partial record into a misleading line on the next append. Move or delete the whole reported file, preserving it separately if required by your audit-retention policy, and then restart. Lowering the configured ceiling can therefore require an explicit operator rotation or removal of legacy logs. Startup also builds a compact, hard-bounded index (at most `10,000` records). List requests select at most one page from that index and only then load and verify those records and archives, so a request never retains every record payload in memory. Expiry pruning walks the compact expiry index one due record at a time. The index is updated after durable uploads, revocations, removals, and startup reconciliation; page entries still receive full record and archive-integrity validation before they are returned. -Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Writes are serialized through a capacity- and time-bounded queue and rotate into one bounded previous segment. Logs must not contain archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. +Audit logs cover successful, blocked, unavailable, and internal-error outcomes for recognized Share routes, including authorization, upload rate/capacity, client-claim, pagination, expiry, and revocation decisions. Client addresses are represented by domain-separated HMAC-SHA256 values, so IPv4 addresses cannot be recovered by enumerating the small address space without the secret. Writes are serialized through a capacity- and time-bounded queue and rotate into one bounded previous segment. Logs must not contain the HMAC secret, raw client addresses, archive plaintext, passphrases, API keys, raw URLs, filenames supplied by the client, or request payloads. + +The HMAC key defines a correlation epoch. Restarting with the same data directory (or the same environment key) preserves client hashes. To rotate the generated key, stop every writer, remove `audit/client-hash.hmac.key`, and restart; to rotate an environment key, replace the environment value and restart. Existing HMAC log segments remain readable but their client hashes do not correlate with the new epoch, and one segment can contain events from both sides of a rotation. An environment key overrides a generated key without deleting it, so removing the environment setting later resumes the stored-key epoch. + Audit queue overflow, expiry, append timeout, and append errors are reported through rate-limited operational warnings. A failed or timed-out sink opens a circuit: queued events are dropped, and after the retry cooldown one recovery probe is admitted only after the physical append has settled. These audit degradations do not turn an already committed upload, revoke, metadata response, page response, or download into a failed HTTP operation. diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index c8fcc4f..66d5d13 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -1,9 +1,10 @@ import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { createHash, randomBytes } from "node:crypto"; +import { createHash, createHmac, randomBytes } from "node:crypto"; import { constants as fsConstants } from "node:fs"; import { createRequire } from "node:module"; import { chmod, + link, mkdir, mkdtemp, open, @@ -27,6 +28,8 @@ const require = createRequire(import.meta.url); const tsxCli = require.resolve("tsx/cli"); const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const apiKey = "share-test-key"; +const testAuditHmacSecretBytes = Buffer.from(Array.from({ length: 32 }, (_, index) => index)); +const testAuditHmacSecret = testAuditHmacSecretBytes.toString("base64url"); const BLOB_FIXTURE_PATH = "blobs/sha256-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.json"; const TEXT_BLOB_FIXTURE_PATH = @@ -1269,8 +1272,16 @@ describe("share-server", () => { const first = await startShareServer(); const archiveTemporaryPath = resolve(first.dataDir, "archives", ".first-writer.upload"); const recordTemporaryPath = resolve(first.dataDir, "records", ".first-writer.record"); + const auditHmacKeyPath = resolve(first.dataDir, "audit", "client-hash.hmac.key"); + const auditHmacKeyBefore = await readFile(auditHmacKeyPath); + const auditHmacTemporaryPath = resolve( + first.dataDir, + "audit", + `.client-hash.${"b".repeat(32)}.hmac-key.tmp` + ); await writeFile(archiveTemporaryPath, "first-writer-upload"); await writeFile(recordTemporaryPath, "first-writer-record"); + await writeFile(auditHmacTemporaryPath, "first-writer-audit-key-temp"); const second = await spawnShareServer({}, first.dataDir); const secondExit = await waitForShareServerExit(second); @@ -1279,6 +1290,10 @@ describe("share-server", () => { expect(second.logs.join("")).toContain("is already in use by a running share-server process"); await expect(readFile(archiveTemporaryPath, "utf8")).resolves.toBe("first-writer-upload"); await expect(readFile(recordTemporaryPath, "utf8")).resolves.toBe("first-writer-record"); + await expect(readFile(auditHmacTemporaryPath, "utf8")).resolves.toBe( + "first-writer-audit-key-temp" + ); + await expect(readFile(auditHmacKeyPath)).resolves.toEqual(auditHmacKeyBefore); const upload = await uploadEncryptedFixture(first); const metadataResponse = await fetch(`${first.baseUrl}/api/share/${upload.shareId}/meta`, { @@ -1297,6 +1312,7 @@ describe("share-server", () => { ).toEqual([]); const restarted = await startShareServer({}, first.dataDir); + await expect(stat(auditHmacTemporaryPath)).rejects.toMatchObject({ code: "ENOENT" }); const restartedMetadataResponse = await fetch( `${restarted.baseUrl}/api/share/${upload.shareId}/meta`, { @@ -1386,17 +1402,20 @@ describe("share-server", () => { const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); const activeAuditPath = resolve(server.dataDir, "audit", "share-access.jsonl"); const rotatedAuditPath = resolve(server.dataDir, "audit", "share-access.1.jsonl"); + const auditHmacKeyPath = resolve(server.dataDir, "audit", "client-hash.hmac.key"); const directoryPaths = [ server.dataDir, resolve(server.dataDir, "archives"), resolve(server.dataDir, "records"), resolve(server.dataDir, "audit") ]; - const committedFilePaths = [archivePath, recordPath, activeAuditPath, rotatedAuditPath]; + const committedFilePaths = [archivePath, recordPath, activeAuditPath, auditHmacKeyPath]; await writeFile(rotatedAuditPath, '{"legacy":true}\n'); await Promise.all(directoryPaths.map((directoryPath) => chmod(directoryPath, 0o755))); - await Promise.all(committedFilePaths.map((filePath) => chmod(filePath, 0o644))); + await Promise.all( + [...committedFilePaths, rotatedAuditPath].map((filePath) => chmod(filePath, 0o644)) + ); const restarted = await startShareServer({}, server.dataDir); @@ -1406,6 +1425,7 @@ describe("share-server", () => { for (const filePath of committedFilePaths) { expect((await stat(filePath)).mode & 0o777).toBe(0o600); } + await expect(stat(rotatedAuditPath)).rejects.toMatchObject({ code: "ENOENT" }); const metadataResponse = await fetch(`${restarted.baseUrl}/api/share/${upload.shareId}/meta`, { headers: { "x-webblackbox-api-key": apiKey } @@ -1759,6 +1779,152 @@ describe("share-server", () => { expect(auditLog).not.toContain("webblackbox-share-"); }); + it("replaces legacy enumerable client hashes with a private HMAC epoch", async () => { + const dataDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-audit-migration-")); + const auditDir = resolve(dataDir, "audit"); + const auditPath = resolve(auditDir, "share-access.jsonl"); + const legacyHash = createHash("sha256") + .update("webblackbox-share-audit:ip:127.0.0.1") + .digest("hex"); + await mkdir(auditDir, { recursive: true }); + await writeFile( + auditPath, + `${JSON.stringify({ + schemaVersion: 1, + action: "list", + outcome: "ok", + clientHash: legacyHash + })}\n` + ); + + const server = await startShareServer({}, dataDir); + const clientHash = await readLatestAuditClientHash(server, "list"); + const auditLog = await readFile(auditPath, "utf8"); + const events = auditLog + .trim() + .split("\n") + .map( + (line) => + JSON.parse(line) as { + schemaVersion: number; + clientHashAlgorithm: string; + clientHash: string; + } + ); + const keyStat = await stat(resolve(auditDir, "client-hash.hmac.key")); + + expect(clientHash).toMatch(/^[a-f0-9]{64}$/); + expect(clientHash).not.toBe(legacyHash); + expect(auditLog).not.toContain(legacyHash); + expect(auditLog).not.toContain("127.0.0.1"); + expect(events).not.toHaveLength(0); + expect( + events.every( + (event) => event.schemaVersion === 2 && event.clientHashAlgorithm === "HMAC-SHA256-v1" + ) + ).toBe(true); + expect(keyStat.size).toBe(32); + if (process.platform !== "win32") { + expect(keyStat.mode & 0o777).toBe(0o600); + } + }); + + it("keeps generated audit client hashes stable across data-directory restarts", async () => { + const firstServer = await startShareServer(); + const dataDir = firstServer.dataDir; + const firstHash = await readLatestAuditClientHash(firstServer, "list"); + + await stopShareServer(firstServer, false); + const restartedServer = await startShareServer({}, dataDir); + const restartedHash = await readLatestAuditClientHash(restartedServer, "list"); + + expect(restartedHash).toBe(firstHash); + }); + + it("uses independent audit client hash keys for different data directories", async () => { + const firstServer = await startShareServer(); + const secondServer = await startShareServer(); + + const [firstHash, secondHash] = await Promise.all([ + readLatestAuditClientHash(firstServer, "list"), + readLatestAuditClientHash(secondServer, "list") + ]); + + expect(secondHash).not.toBe(firstHash); + }); + + it.each([ + ["short", Buffer.alloc(16, 0x5a).toString("base64url")], + ["low-entropy", Buffer.alloc(32, 0x5a).toString("base64url")] + ])("rejects %s configured audit HMAC secrets", async (_label, weakSecret) => { + await expect( + startShareServer({ + WEBBLACKBOX_SHARE_AUDIT_HMAC_SECRET: weakSecret + }) + ).rejects.toThrow(/must be canonical base64url encoding of 32 to 64 high-entropy bytes/); + }); + + it("uses a validated environment audit key without exposing it", async () => { + const server = await startShareServer({ + WEBBLACKBOX_SHARE_AUDIT_HMAC_SECRET: testAuditHmacSecret + }); + const response = await fetch(`${server.baseUrl}/api/share/list`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + const responseBody = await response.text(); + const expectedHash = auditClientHmac(testAuditHmacSecretBytes, "ip:127.0.0.1"); + + await waitForAuditClientHash(server, "list", expectedHash); + const auditLog = await readFile(resolve(server.dataDir, "audit/share-access.jsonl"), "utf8"); + expect(response.status).toBe(200); + expect(responseBody).not.toContain(testAuditHmacSecret); + expect(auditLog).not.toContain(testAuditHmacSecret); + expect(server.logs.join("")).not.toContain(testAuditHmacSecret); + await expect(stat(resolve(server.dataDir, "audit/client-hash.hmac.key"))).rejects.toMatchObject( + { + code: "ENOENT" + } + ); + }); + + it("recovers an interrupted atomic audit HMAC key publication", async () => { + if (process.platform === "win32") { + return; + } + + const dataDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-audit-key-recovery-")); + const auditDir = resolve(dataDir, "audit"); + const temporaryKeyPath = resolve(auditDir, `.client-hash.${"a".repeat(32)}.hmac-key.tmp`); + const finalKeyPath = resolve(auditDir, "client-hash.hmac.key"); + await mkdir(auditDir, { recursive: true }); + await writeFile(temporaryKeyPath, testAuditHmacSecretBytes, { mode: 0o600 }); + await link(temporaryKeyPath, finalKeyPath); + + const server = await startShareServer({}, dataDir); + const expectedHash = auditClientHmac(testAuditHmacSecretBytes, "ip:127.0.0.1"); + + await waitForAuditClientHash(server, "list", expectedHash); + await expect(stat(temporaryKeyPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect((await stat(finalKeyPath)).nlink).toBe(1); + }); + + it("refuses to follow an audit HMAC key symlink", async () => { + if (process.platform === "win32") { + return; + } + + const dataDir = await mkdtemp(resolve(tmpdir(), "webblackbox-share-audit-symlink-")); + const auditDir = resolve(dataDir, "audit"); + const outsideKeyPath = resolve(dataDir, "outside.key"); + const outsideKey = randomBytes(32); + await mkdir(auditDir, { recursive: true }); + await writeFile(outsideKeyPath, outsideKey); + await symlink(outsideKeyPath, resolve(auditDir, "client-hash.hmac.key")); + + await expect(startShareServer({}, dataDir)).rejects.toThrow(/Unsafe share storage path/); + await expect(readFile(outsideKeyPath)).resolves.toEqual(outsideKey); + }); + it("audits authorization failures within a bounded rotating log", async () => { const server = await startShareServer({ WEBBLACKBOX_SHARE_MAX_AUDIT_LOG_BYTES: "512" @@ -1774,7 +1940,9 @@ describe("share-server", () => { expect(response.status).toBe(401); } - const auditFiles = await readdir(resolve(server.dataDir, "audit")); + const auditFiles = (await readdir(resolve(server.dataDir, "audit"))).filter((name) => + name.endsWith(".jsonl") + ); expect(auditFiles).toEqual( expect.arrayContaining(["share-access.jsonl", "share-access.1.jsonl"]) ); @@ -2156,7 +2324,8 @@ describe("share-server", () => { it("derives forwarded clients from the first untrusted hop next to trusted proxies", async () => { const server = await startShareServer({ WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true", - WEBBLACKBOX_TRUSTED_PROXY_IPS: "127.0.0.1" + WEBBLACKBOX_TRUSTED_PROXY_IPS: "127.0.0.1", + WEBBLACKBOX_SHARE_AUDIT_HMAC_SECRET: testAuditHmacSecret }); const response = await fetch(`${server.baseUrl}/api/share/list`, { headers: { @@ -2166,12 +2335,8 @@ describe("share-server", () => { }); expect(response.status).toBe(200); - const expectedHash = createHash("sha256") - .update("webblackbox-share-audit:ip:203.0.113.10") - .digest("hex"); - const spoofedHash = createHash("sha256") - .update("webblackbox-share-audit:ip:198.51.100.99") - .digest("hex"); + const expectedHash = auditClientHmac(testAuditHmacSecretBytes, "ip:203.0.113.10"); + const spoofedHash = auditClientHmac(testAuditHmacSecretBytes, "ip:198.51.100.99"); await waitForAuditClientHash(server, "list", expectedHash); const refreshedAuditLog = await readFile( resolve(server.dataDir, "audit", "share-access.jsonl"), @@ -2193,7 +2358,8 @@ describe("share-server", () => { async (_label, configuredProxy, forwardedProxy) => { const server = await startShareServer({ WEBBLACKBOX_TRUST_X_FORWARDED_FOR: "true", - WEBBLACKBOX_TRUSTED_PROXY_IPS: `127.0.0.1,${configuredProxy}` + WEBBLACKBOX_TRUSTED_PROXY_IPS: `127.0.0.1,${configuredProxy}`, + WEBBLACKBOX_SHARE_AUDIT_HMAC_SECRET: testAuditHmacSecret }); const clientAddress = "198.51.100.99"; const response = await fetch(`${server.baseUrl}/api/share/list`, { @@ -2204,9 +2370,7 @@ describe("share-server", () => { }); expect(response.status).toBe(200); - const expectedHash = createHash("sha256") - .update(`webblackbox-share-audit:ip:${clientAddress}`) - .digest("hex"); + const expectedHash = auditClientHmac(testAuditHmacSecretBytes, `ip:${clientAddress}`); await waitForAuditClientHash(server, "list", expectedHash); const refreshedAuditLog = await readFile( resolve(server.dataDir, "audit/share-access.jsonl"), @@ -2365,6 +2529,40 @@ async function waitForAuditClientHash( throw new Error(`Timed out waiting for ${action} audit event with client hash ${clientHash}.`); } +async function readLatestAuditClientHash( + server: RunningShareServer, + action: string +): Promise { + const auditPath = resolve(server.dataDir, "audit/share-access.jsonl"); + const deadline = Date.now() + 2_000; + + while (Date.now() < deadline) { + try { + const events = (await readFile(auditPath, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { action?: string; clientHash?: string }); + const clientHash = events.findLast((event) => event.action === action)?.clientHash; + if (typeof clientHash === "string") { + return clientHash; + } + } catch { + // Audit creation may still be in flight after the HTTP response completes. + } + + await new Promise((resolvePromise) => setTimeout(resolvePromise, 20)); + } + + throw new Error(`Timed out waiting for ${action} audit event.`); +} + +function auditClientHmac(secret: Uint8Array, clientKey: string): string { + return createHmac("sha256", secret) + .update("webblackbox-share-audit:v1\0") + .update(clientKey) + .digest("hex"); +} + async function createFullNamedPipe(path: string): Promise>> { const fifoResult = spawnSync("mkfifo", [path]); if (fifoResult.status !== 0) { diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index ed53256..dd15368 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -1,6 +1,6 @@ -import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; import { constants as fsConstants, type BigIntStats } from "node:fs"; -import { lstat, mkdir, open, opendir, rename, rm, type FileHandle } from "node:fs/promises"; +import { link, lstat, mkdir, open, opendir, rename, rm, type FileHandle } from "node:fs/promises"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { isIP, SocketAddress } from "node:net"; import { join, resolve } from "node:path"; @@ -187,6 +187,13 @@ const RECORDS_DIR = join(DATA_ROOT, "records"); const AUDIT_DIR = join(DATA_ROOT, "audit"); const SHARE_AUDIT_LOG_PATH = join(AUDIT_DIR, "share-access.jsonl"); const SHARE_AUDIT_ROTATED_LOG_PATH = join(AUDIT_DIR, "share-access.1.jsonl"); +const SHARE_AUDIT_HMAC_KEY_PATH = join(AUDIT_DIR, "client-hash.hmac.key"); +const SHARE_AUDIT_HMAC_SECRET_ENV = "WEBBLACKBOX_SHARE_AUDIT_HMAC_SECRET"; +const SHARE_AUDIT_HMAC_KEY_BYTES = 32; +const MAX_SHARE_AUDIT_HMAC_KEY_BYTES = 64; +const MIN_SHARE_AUDIT_HMAC_ESTIMATED_ENTROPY_BITS = 128; +const SHARE_AUDIT_CLIENT_HASH_ALGORITHM = "HMAC-SHA256-v1"; +const SHARE_AUDIT_CLIENT_HASH_DOMAIN = "webblackbox-share-audit:v1\0"; const SHARE_API_KEY = readOptionalSecret(process.env.WEBBLACKBOX_SHARE_API_KEY); const SHARE_API_CREDENTIALS = parseShareApiCredentials( process.env.WEBBLACKBOX_SHARE_API_KEYS, @@ -302,6 +309,7 @@ let sharePublicOrigin = DEFAULT_BASE_URL; let shareRecordAdmissionQueue = Promise.resolve(); let lastAuditDegradationWarningAt = 0; let suppressedAuditDegradationWarnings = 0; +let shareAuditHmacSecret: Buffer | null = null; const auditWriter = new BoundedAuditWriter({ append: appendShareAuditLine, maxQueuedEntries: MAX_SHARE_AUDIT_QUEUE_ENTRIES, @@ -318,6 +326,9 @@ void startShareServer().catch((error) => { async function startShareServer(): Promise { const port = parsePort(process.env.PORT); const host = parseBindHost(process.env.WEBBLACKBOX_SHARE_BIND_HOST); + const configuredAuditHmacSecret = parseAuditHmacEnvironmentSecret( + process.env[SHARE_AUDIT_HMAC_SECRET_ENV] + ); sharePublicOrigin = resolvePublicOrigin(process.env.WEBBLACKBOX_SHARE_PUBLIC_ORIGIN, host, port); validateAuthorizationConfiguration(host, sharePublicOrigin); if (SHARE_API_CREDENTIALS.length === 0 && !isLoopbackHost(new URL(sharePublicOrigin).hostname)) { @@ -331,7 +342,11 @@ async function startShareServer(): Promise { try { await ensureStorageLayout(dataRootIdentity); + await removeStaleAuditHmacSecretTemps(); await migrateLegacyStoragePermissions(); + shareAuditHmacSecret = + configuredAuditHmacSecret ?? (await loadOrCreatePersistedAuditHmacSecret()); + await migrateLegacyAuditLogs(); await reconcileAuditLogLayout(); await reconcileStorageLayout(); await pruneExpiredShareRecords(Date.now()); @@ -2104,13 +2119,283 @@ async function migrateLegacyStoragePermissions(): Promise { await hardenCommittedStorageFiles(ARCHIVES_DIR, (name) => name.endsWith(".webblackbox")); await hardenCommittedStorageFiles(RECORDS_DIR, (name) => name.endsWith(".json")); const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); - for (const auditPath of [SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH]) { + for (const auditPath of [ + SHARE_AUDIT_LOG_PATH, + SHARE_AUDIT_ROTATED_LOG_PATH, + SHARE_AUDIT_HMAC_KEY_PATH + ]) { await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); await hardenStorageFileIfPresent(auditPath); } await assertManagedStorageLayout(); } +async function loadOrCreatePersistedAuditHmacSecret(): Promise { + const existingSecret = await readPersistedAuditHmacSecret(); + if (existingSecret) { + return existingSecret; + } + + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + const secret = generateAuditHmacSecret(); + const temporaryPath = join( + AUDIT_DIR, + `.client-hash.${randomUUID().replaceAll("-", "")}.hmac-key.tmp` + ); + let handle: FileHandle | null = null; + + try { + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + handle = await open( + temporaryPath, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o600 + ); + await writeFileHandleFully(handle, secret); + await handle.sync(); + if (process.platform !== "win32") { + await handle.chmod(0o600); + } + const temporaryStat = await handle.stat({ bigint: true }); + if ( + !temporaryStat.isFile() || + temporaryStat.nlink !== 1n || + temporaryStat.size !== BigInt(SHARE_AUDIT_HMAC_KEY_BYTES) || + (process.platform !== "win32" && (temporaryStat.mode & 0o777n) !== 0o600n) + ) { + throw unsafeStoragePathError(temporaryPath, "could not create a private HMAC key file"); + } + await handle.close(); + handle = null; + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + await assertStoragePathIdentity(temporaryPath, temporaryStat, "file"); + + let published = false; + try { + await link(temporaryPath, SHARE_AUDIT_HMAC_KEY_PATH); + published = true; + } catch (error) { + if (!hasFileSystemErrorCode(error, "EEXIST")) { + throw error; + } + } + await rm(temporaryPath, { force: true }); + await syncDirectoryBestEffort(AUDIT_DIR); + + if (!published) { + secret.fill(0); + const concurrentlyCreatedSecret = await readPersistedAuditHmacSecret(); + if (!concurrentlyCreatedSecret) { + throw new Error("The Share audit HMAC key was not published atomically."); + } + return concurrentlyCreatedSecret; + } + + const persistedSecret = await readPersistedAuditHmacSecret(); + const persistedSecretMatches = + persistedSecret !== null && timingSafeEqual(secret, persistedSecret); + persistedSecret?.fill(0); + if (!persistedSecretMatches) { + throw new Error("The persisted Share audit HMAC key failed verification."); + } + return secret; + } finally { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }).catch(() => undefined); + } +} + +async function readPersistedAuditHmacSecret(): Promise { + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + let pathStat: BigIntStats; + try { + pathStat = await lstat(SHARE_AUDIT_HMAC_KEY_PATH, { bigint: true }); + } catch (error) { + if (isFileNotFoundError(error)) { + return null; + } + throw error; + } + + let handle: FileHandle | null = null; + try { + handle = await open(SHARE_AUDIT_HMAC_KEY_PATH, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + const openedStat = await handle.stat({ bigint: true }); + if ( + !pathStat.isFile() || + !openedStat.isFile() || + openedStat.nlink !== 1n || + openedStat.size !== BigInt(SHARE_AUDIT_HMAC_KEY_BYTES) || + !isSameFileSystemObject(pathStat, openedStat) || + (process.platform !== "win32" && (openedStat.mode & 0o777n) !== 0o600n) + ) { + throw unsafeStoragePathError( + SHARE_AUDIT_HMAC_KEY_PATH, + `expected a ${SHARE_AUDIT_HMAC_KEY_BYTES}-byte mode-0600 regular file` + ); + } + + const secret = await readFileHandleExactly(handle, SHARE_AUDIT_HMAC_KEY_BYTES); + if (estimateByteEntropyBits(secret) < MIN_SHARE_AUDIT_HMAC_ESTIMATED_ENTROPY_BITS) { + secret.fill(0); + throw unsafeStoragePathError( + SHARE_AUDIT_HMAC_KEY_PATH, + "stored HMAC key does not meet the entropy floor" + ); + } + await assertStoragePathIdentity(SHARE_AUDIT_HMAC_KEY_PATH, openedStat, "file"); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + return secret; + } catch (error) { + if (error instanceof UnsafeStoragePathError) { + throw error; + } + throw unsafeStoragePathError( + SHARE_AUDIT_HMAC_KEY_PATH, + "refusing to follow or read this path", + error + ); + } finally { + await handle?.close().catch(() => undefined); + } +} + +async function removeStaleAuditHmacSecretTemps(): Promise { + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + const directory = await opendir(AUDIT_DIR); + let directoryEntries = 0; + + for await (const entry of directory) { + directoryEntries += 1; + if (directoryEntries > MAX_SHARE_RECORD_DIRECTORY_ENTRIES) { + throw new Error( + `Share audit directory entry limit exceeded (${MAX_SHARE_RECORD_DIRECTORY_ENTRIES}).` + ); + } + if (/^\.client-hash\.[a-f0-9]{32}\.hmac-key\.tmp$/i.test(entry.name)) { + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + await rm(join(AUDIT_DIR, entry.name), { force: true }); + } + } + + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); +} + +type AuditLogHmacMigrationState = + | { kind: "current-or-missing" } + | { kind: "defer-to-reconciliation" } + | { kind: "legacy"; stat: BigIntStats; path: string }; + +async function migrateLegacyAuditLogs(): Promise { + const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); + const states: AuditLogHmacMigrationState[] = []; + for (const auditPath of [SHARE_AUDIT_LOG_PATH, SHARE_AUDIT_ROTATED_LOG_PATH]) { + states.push(await classifyAuditLogHmacMigration(auditPath, auditDirectoryIdentity)); + } + + if (states.some((state) => state.kind === "defer-to-reconciliation")) { + return; + } + + let removedLegacyLog = false; + for (const state of states) { + if (state.kind !== "legacy") { + continue; + } + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + await assertStoragePathIdentity(state.path, state.stat, "file"); + await rm(state.path, { force: true }); + removedLegacyLog = true; + } + if (removedLegacyLog) { + await syncDirectoryBestEffort(AUDIT_DIR); + } +} + +async function classifyAuditLogHmacMigration( + auditPath: string, + auditDirectoryIdentity: BigIntStats +): Promise { + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + let handle: FileHandle; + try { + handle = await open(auditPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW); + } catch (error) { + if (isFileNotFoundError(error)) { + return { kind: "current-or-missing" }; + } + throw unsafeStoragePathError(auditPath, "refusing to follow or replace this path", error); + } + + try { + const openedStat = await handle.stat({ bigint: true }); + if ( + !openedStat.isFile() || + openedStat.nlink !== 1n || + (process.platform !== "win32" && (openedStat.mode & 0o777n) !== 0o600n) + ) { + throw unsafeStoragePathError(auditPath, "expected a private regular audit log"); + } + if (openedStat.size === 0n) { + return { kind: "current-or-missing" }; + } + if (openedStat.size > BigInt(MAX_SHARE_AUDIT_LOG_BYTES)) { + return { kind: "defer-to-reconciliation" }; + } + + const finalByte = Buffer.allocUnsafe(1); + const { bytesRead } = await handle.read(finalByte, 0, 1, Number(openedStat.size - 1n)); + if (bytesRead !== 1 || finalByte[0] !== 0x0a) { + return { kind: "defer-to-reconciliation" }; + } + + let contents: Buffer; + try { + contents = await readFileHandleExactly(handle, Number(openedStat.size)); + } catch (error) { + throw unsafeStoragePathError(auditPath, "changed during HMAC migration", error); + } + const verifiedStat = await handle.stat({ bigint: true }); + if ( + verifiedStat.size !== openedStat.size || + !isSameFileSystemObject(verifiedStat, openedStat) + ) { + throw unsafeStoragePathError(auditPath, "changed during HMAC migration"); + } + await assertStoragePathIdentity(auditPath, verifiedStat, "file"); + await assertStoragePathIdentity(AUDIT_DIR, auditDirectoryIdentity, "directory"); + + if (auditLogContainsOnlyHmacEvents(contents)) { + return { kind: "current-or-missing" }; + } + return { kind: "legacy", path: auditPath, stat: verifiedStat }; + } finally { + await handle.close().catch(() => undefined); + } +} + +function auditLogContainsOnlyHmacEvents(contents: Buffer): boolean { + try { + const lines = new TextDecoder("utf-8", { fatal: true }).decode(contents).split("\n"); + if (lines.pop() !== "" || lines.length === 0) { + return false; + } + return lines.every((line) => { + const event = asRecord(JSON.parse(line) as unknown); + return ( + event.schemaVersion === 2 && + event.clientHashAlgorithm === SHARE_AUDIT_CLIENT_HASH_ALGORITHM && + typeof event.clientHash === "string" && + /^[a-f0-9]{64}$/.test(event.clientHash) + ); + }); + } catch { + return false; + } +} + async function reconcileAuditLogLayout(): Promise { const auditDirectoryIdentity = getManagedStorageDirectoryIdentity(AUDIT_DIR); @@ -3607,11 +3892,12 @@ async function writeShareAuditEvent( } ): Promise { const event = { - schemaVersion: 1, + schemaVersion: 2, timestamp: new Date().toISOString(), action: input.action, outcome: input.outcome, shareId: input.shareId, + clientHashAlgorithm: SHARE_AUDIT_CLIENT_HASH_ALGORITHM, clientHash: hashAuditValue(resolveClientKey(request)), details: input.details }; @@ -3773,7 +4059,14 @@ function hasFileSystemErrorCode(error: unknown, code: string): boolean { } function hashAuditValue(value: string): string { - return createHash("sha256").update(`webblackbox-share-audit:${value}`).digest("hex"); + if (!shareAuditHmacSecret) { + throw new Error("Share audit HMAC key is not initialized."); + } + + return createHmac("sha256", shareAuditHmacSecret) + .update(SHARE_AUDIT_CLIENT_HASH_DOMAIN) + .update(value) + .digest("hex"); } function recordPathForId(id: string): string { @@ -4523,6 +4816,59 @@ function readOptionalSecret(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function parseAuditHmacEnvironmentSecret(value: string | undefined): Buffer | null { + if (value === undefined || value.length === 0) { + return null; + } + + const invalidSecret = () => + new Error( + `${SHARE_AUDIT_HMAC_SECRET_ENV} must be canonical base64url encoding of 32 to 64 high-entropy bytes (at least 128 bits of estimated entropy).` + ); + if (value !== value.trim() || !/^[A-Za-z0-9_-]+$/.test(value)) { + throw invalidSecret(); + } + + const secret = Buffer.from(value, "base64url"); + if ( + secret.toString("base64url") !== value || + secret.byteLength < SHARE_AUDIT_HMAC_KEY_BYTES || + secret.byteLength > MAX_SHARE_AUDIT_HMAC_KEY_BYTES || + estimateByteEntropyBits(secret) < MIN_SHARE_AUDIT_HMAC_ESTIMATED_ENTROPY_BITS + ) { + secret.fill(0); + throw invalidSecret(); + } + + return secret; +} + +function generateAuditHmacSecret(): Buffer { + let secret = randomBytes(SHARE_AUDIT_HMAC_KEY_BYTES); + while (estimateByteEntropyBits(secret) < MIN_SHARE_AUDIT_HMAC_ESTIMATED_ENTROPY_BITS) { + secret.fill(0); + secret = randomBytes(SHARE_AUDIT_HMAC_KEY_BYTES); + } + return secret; +} + +function estimateByteEntropyBits(bytes: Uint8Array): number { + const counts = new Uint16Array(256); + for (const byte of bytes) { + counts[byte] = (counts[byte] ?? 0) + 1; + } + + let bitsPerByte = 0; + for (const count of counts) { + if (count === 0) { + continue; + } + const probability = count / bytes.byteLength; + bitsPerByte -= probability * Math.log2(probability); + } + return bitsPerByte * bytes.byteLength; +} + function parseRateLimitCount(value: string | undefined, fallback: number): number { return parsePositiveInteger(value, fallback); } From 643e240c6236e0b6501b41b6a3b1851f35376437 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:30:55 +0800 Subject: [PATCH 087/181] fix(release): verify private surface versions --- scripts/verify-release-ref.mjs | 45 +++++++++++++++++--- scripts/verify-release-ref.test.mjs | 66 +++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/scripts/verify-release-ref.mjs b/scripts/verify-release-ref.mjs index daed51d..a179d03 100644 --- a/scripts/verify-release-ref.mjs +++ b/scripts/verify-release-ref.mjs @@ -8,6 +8,12 @@ import { fileURLToPath } from "node:url"; const releaseTagPattern = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const privateReleaseSurfaces = new Map([ + ["apps/extension", "@webblackbox/extension"], + ["apps/player", "@webblackbox/player"], + ["apps/share-server", "@webblackbox/share-server"] +]); + export async function verifyReleaseRef({ root, tag, mainRef = "origin/main" }) { if (!releaseTagPattern.test(tag)) { throw new Error(`Release tag must be an exact semantic version tag (vX.Y.Z): ${tag}`); @@ -30,15 +36,15 @@ export async function verifyReleaseRef({ root, tag, mainRef = "origin/main" }) { } const expectedVersion = tag.slice(1); - const packages = await readPublicPackages(root); - if (packages.length === 0) { + const packages = await readReleasePackages(root); + if (!packages.some((entry) => !entry.private)) { throw new Error("No public packages were found for release verification"); } const mismatches = packages.filter((entry) => entry.version !== expectedVersion); if (mismatches.length > 0) { throw new Error( - `Public package versions must match ${tag}: ${mismatches + `Release package versions must match ${tag}: ${mismatches .map((entry) => `${entry.name}@${entry.version}`) .join(", ")}` ); @@ -52,8 +58,9 @@ export async function verifyReleaseRef({ root, tag, mainRef = "origin/main" }) { }; } -async function readPublicPackages(root) { +async function readReleasePackages(root) { const packages = []; + const foundPrivateReleaseSurfaces = new Set(); for (const parent of ["packages", "apps"]) { const parentPath = resolve(root, parent); const entries = await readdir(parentPath, { withFileTypes: true }); @@ -73,15 +80,39 @@ async function readPublicPackages(root) { throw error; } - if (manifest.private === true) { + const packageDirectory = `${parent}/${entry.name}`; + const expectedPrivateReleaseName = privateReleaseSurfaces.get(packageDirectory); + if (manifest.private === true && !expectedPrivateReleaseName) { continue; } if (typeof manifest.name !== "string" || typeof manifest.version !== "string") { - throw new Error(`Invalid public package manifest: ${packagePath}`); + throw new Error(`Invalid release package manifest: ${packagePath}`); + } + if (expectedPrivateReleaseName && manifest.name !== expectedPrivateReleaseName) { + throw new Error( + `Invalid private release surface manifest: expected ${expectedPrivateReleaseName} at ${packagePath}` + ); } - packages.push({ name: manifest.name, version: manifest.version }); + if (expectedPrivateReleaseName) { + foundPrivateReleaseSurfaces.add(packageDirectory); + } + packages.push({ + name: manifest.name, + version: manifest.version, + private: manifest.private === true + }); } } + + const missingPrivateReleaseSurfaces = [...privateReleaseSurfaces.keys()].filter( + (packageDirectory) => !foundPrivateReleaseSurfaces.has(packageDirectory) + ); + if (missingPrivateReleaseSurfaces.length > 0) { + throw new Error( + `Missing private release surface manifests: ${missingPrivateReleaseSurfaces.join(", ")}` + ); + } + return packages; } diff --git a/scripts/verify-release-ref.test.mjs b/scripts/verify-release-ref.test.mjs index c74a683..0da9c87 100644 --- a/scripts/verify-release-ref.test.mjs +++ b/scripts/verify-release-ref.test.mjs @@ -14,7 +14,12 @@ test("accepts an exact release tag on the main history with matching package ver const result = await verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }); assert.equal(result.version, "1.2.3"); - assert.deepEqual(result.packages, ["@example/library"]); + assert.deepEqual(result.packages, [ + "@example/library", + "@webblackbox/extension", + "@webblackbox/player", + "@webblackbox/share-server" + ]); }); test("rejects mutable or malformed release refs", async () => { @@ -59,18 +64,71 @@ test("rejects tags whose public package versions do not match", async () => { await assert.rejects( verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }), - /Public package versions must match/ + /Release package versions must match/ ); }); -async function createRepository(version) { +test("rejects tags whose private release surface versions do not match", async () => { + const root = await createRepository("1.2.3", { + privateVersions: { + player: "1.2.2" + } + }); + git(root, "tag", "v1.2.3"); + + await assert.rejects( + verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }), + /@webblackbox\/player@1\.2\.2/ + ); +}); + +async function createRepository(version, options = {}) { const root = await mkdtemp(resolve(tmpdir(), "webblackbox-release-ref-")); await mkdir(resolve(root, "packages", "library"), { recursive: true }); - await mkdir(resolve(root, "apps"), { recursive: true }); + await mkdir(resolve(root, "apps", "extension"), { recursive: true }); + await mkdir(resolve(root, "apps", "player"), { recursive: true }); + await mkdir(resolve(root, "apps", "share-server"), { recursive: true }); await writeFile( resolve(root, "packages", "library", "package.json"), `${JSON.stringify({ name: "@example/library", version }, null, 2)}\n` ); + const privateVersions = options.privateVersions ?? {}; + await writeFile( + resolve(root, "apps", "extension", "package.json"), + `${JSON.stringify( + { + name: "@webblackbox/extension", + version: privateVersions.extension ?? version, + private: true + }, + null, + 2 + )}\n` + ); + await writeFile( + resolve(root, "apps", "player", "package.json"), + `${JSON.stringify( + { + name: "@webblackbox/player", + version: privateVersions.player ?? version, + private: true + }, + null, + 2 + )}\n` + ); + await writeFile( + resolve(root, "apps", "share-server", "package.json"), + `${JSON.stringify( + { + name: "@webblackbox/share-server", + version: privateVersions.shareServer ?? version, + private: true + }, + null, + 2 + )}\n` + ); git(root, "init", "--initial-branch=main"); git(root, "config", "user.name", "Release Test"); git(root, "config", "user.email", "release-test@example.invalid"); From 54f4f355a3307c4958abe3ba61ccc33134e0548d Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:32:31 +0800 Subject: [PATCH 088/181] ci(bundle): rebaseline hardened Share runtime --- bundle-size/budgets.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundle-size/budgets.json b/bundle-size/budgets.json index e7f13c3..c421d32 100644 --- a/bundle-size/budgets.json +++ b/bundle-size/budgets.json @@ -47,8 +47,8 @@ { "name": "Share server runtime", "path": "apps/share-server/dist/index.js", - "maxBytes": 96000, - "maxGzipBytes": 23000 + "maxBytes": 184000, + "maxGzipBytes": 40000 }, { "name": "Player application", From 7493dbb41c02e56af35401fbffa58aec6e37f8ef Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:35:46 +0800 Subject: [PATCH 089/181] fix(lite): gate body persistence by capture policy --- .../webblackbox/src/lite-materializer.test.ts | 49 +++++++++++++++++++ packages/webblackbox/src/lite-materializer.ts | 4 ++ packages/webblackbox/src/lite-sdk.test.ts | 32 ++++++++++++ 3 files changed, 85 insertions(+) diff --git a/packages/webblackbox/src/lite-materializer.test.ts b/packages/webblackbox/src/lite-materializer.test.ts index f8485ba..5851203 100644 --- a/packages/webblackbox/src/lite-materializer.test.ts +++ b/packages/webblackbox/src/lite-materializer.test.ts @@ -34,6 +34,16 @@ function cloneConfig(): RecorderConfig { }; } +function enableNetworkBodyCapture(config: RecorderConfig): void { + config.capturePolicy = { + ...config.capturePolicy!, + categories: { + ...config.capturePolicy!.categories, + network: "body-allowlist" + } + }; +} + describe("lite-materializer", () => { it("detects which raw events need lite materialization", () => { expect( @@ -137,6 +147,7 @@ describe("lite-materializer", () => { it("materializes network bodies with redaction and byte caps", async () => { const config = cloneConfig(); + enableNetworkBodyCapture(config); config.sampling.bodyCaptureMaxBytes = 4 * 1024; const putBlobCalls: Array<{ mime: string; text: string; bytes: Uint8Array }> = []; @@ -183,6 +194,7 @@ describe("lite-materializer", () => { it("decodes base64 JSON and redacts nested sensitive values before persistence", async () => { const config = cloneConfig(); + enableNetworkBodyCapture(config); config.sampling.bodyCaptureMaxBytes = 64 * 1024; const putBlobCalls: Array<{ mime: string; text: string }> = []; const body = JSON.stringify({ @@ -234,6 +246,7 @@ describe("lite-materializer", () => { it("does not persist malformed structured or undecodable textual bodies", async () => { const config = cloneConfig(); + enableNetworkBodyCapture(config); config.sampling.bodyCaptureMaxBytes = 64 * 1024; const putBlob = vi.fn(async () => "unexpected-hash"); const context = { config, putBlob }; @@ -266,6 +279,7 @@ describe("lite-materializer", () => { it("does not persist network bodies without an allowed MIME type", async () => { const config = cloneConfig(); + enableNetworkBodyCapture(config); config.sampling.bodyCaptureMaxBytes = 4 * 1024; const putBlob = vi.fn(async () => "unexpected-hash"); @@ -289,6 +303,7 @@ describe("lite-materializer", () => { it("respects site policy deny rules for body capture", async () => { const config = cloneConfig(); + enableNetworkBodyCapture(config); config.sitePolicies = [ { originPattern: "https://example.test", @@ -319,6 +334,39 @@ describe("lite-materializer", () => { expect(result).toBeNull(); }); + it("drops forged network bodies before persistence outside body-allowlist policy", async () => { + for (const networkPolicy of ["metadata", "headers-allowlist", undefined] as const) { + const config = cloneConfig(); + config.sampling.bodyCaptureMaxBytes = 4 * 1024; + if (networkPolicy === undefined) { + delete config.capturePolicy; + } else { + config.capturePolicy = { + ...config.capturePolicy!, + categories: { + ...config.capturePolicy!.categories, + network: networkPolicy + } + }; + } + const putBlob = vi.fn(async () => "unexpected-hash"); + + const result = await materializeLiteRawEvent( + createRawEvent("networkBody", { + reqId: `R-forged-${networkPolicy ?? "missing"}`, + url: "https://example.test/api/private", + mimeType: "application/json", + encoding: "utf8", + body: '{"secret":"must-not-be-persisted"}' + }), + { config, putBlob } + ); + + expect(result).toBeNull(); + expect(putBlob).not.toHaveBeenCalled(); + } + }); + it("drops localStorage entry samples during materialization", async () => { const putBlob = vi.fn(async () => "unused"); @@ -387,6 +435,7 @@ describe("lite-materializer", () => { it("treats a zero body-capture budget as disabled", async () => { const config = cloneConfig(); + enableNetworkBodyCapture(config); config.sampling.bodyCaptureMaxBytes = 0; const result = await materializeLiteRawEvent( diff --git a/packages/webblackbox/src/lite-materializer.ts b/packages/webblackbox/src/lite-materializer.ts index 0f280c3..07f0831 100644 --- a/packages/webblackbox/src/lite-materializer.ts +++ b/packages/webblackbox/src/lite-materializer.ts @@ -248,6 +248,10 @@ async function materializeLiteNetworkBody( rawEvent: RawRecorderEvent, context: LiteMaterializerContext ): Promise { + if (context.config.capturePolicy?.categories.network !== "body-allowlist") { + return null; + } + const payload = asRecord(rawEvent.payload); if (!payload) { diff --git a/packages/webblackbox/src/lite-sdk.test.ts b/packages/webblackbox/src/lite-sdk.test.ts index b118748..244a854 100644 --- a/packages/webblackbox/src/lite-sdk.test.ts +++ b/packages/webblackbox/src/lite-sdk.test.ts @@ -462,6 +462,38 @@ describe("WebBlackboxLiteSdk", () => { expect(() => sdk.emitMarker("after-dispose")).toThrow(/disposed/i); }); + it("drops forged capture-agent network bodies before blob persistence", async () => { + const storage = new MemoryPipelineStorage(); + const putBlob = vi.spyOn(storage, "putBlob"); + const sdk = new WebBlackboxLiteSdk({ + sid: "S-sdk-forged-network-body", + injectHooks: false, + useDefaultPlugins: false, + pipelineStorage: storage, + config: { + sampling: { + bodyCaptureMaxBytes: 4 * 1024 + }, + capturePolicy: LOCAL_DEBUG_TEST_POLICY + } + }); + + await sdk.start(); + mockRuntime.instances.at(-1)?.emitBatch([ + createRawEvent("networkBody", { + reqId: "R-forged-agent", + url: "https://example.test/api/private", + mimeType: "application/json", + encoding: "utf8", + body: '{"secret":"must-not-reach-storage"}' + }) + ]); + await sdk.flush(); + + expect(putBlob).not.toHaveBeenCalled(); + await sdk.dispose(); + }); + it("uses safer lite defaults and skips resource-error freeze", async () => { const freezeSpy = vi.fn(); const sdk = new WebBlackboxLiteSdk({ From a585de6ee07f63fe66605ee2b80722ad9f1a4573 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:36:42 +0800 Subject: [PATCH 090/181] fix(lite): enforce tab-bound capture scope --- .../src/lite-capture-agent.test.ts | 45 +++++++++++++++++++ .../webblackbox/src/lite-capture-agent.ts | 10 +++-- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/webblackbox/src/lite-capture-agent.test.ts b/packages/webblackbox/src/lite-capture-agent.test.ts index e150629..db8147f 100644 --- a/packages/webblackbox/src/lite-capture-agent.test.ts +++ b/packages/webblackbox/src/lite-capture-agent.test.ts @@ -688,6 +688,51 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("binds the first activation to the policy tab before evaluating scope", () => { + const tabBoundPolicy: CapturePolicy = { + ...DEFAULT_CAPTURE_POLICY, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + tabId: 7 + } + }; + const matching = createAgent({ capturePolicy: tabBoundPolicy, tabId: 7 }); + const mismatching = createAgent({ capturePolicy: tabBoundPolicy, tabId: 8 }); + + clickTarget(); + matching.agent.flush(); + mismatching.agent.flush(); + + expect(emittedRawTypes(matching.emitBatch)).toContain("click"); + expect(mismatching.emitBatch).not.toHaveBeenCalled(); + matching.agent.dispose(); + mismatching.agent.dispose(); + }); + + it("deactivates capture when a later status targets a different tab", () => { + const tabBoundPolicy: CapturePolicy = { + ...DEFAULT_CAPTURE_POLICY, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + tabId: 7 + } + }; + const { agent, emitBatch } = createAgent({ capturePolicy: tabBoundPolicy, tabId: 7 }); + + agent.setRecordingStatus({ + active: true, + sid: "S-lite-agent-test", + tabId: 8, + mode: "lite", + capturePolicy: tabBoundPolicy + }); + clickTarget(); + agent.flush(); + + expect(emitBatch).not.toHaveBeenCalled(); + agent.dispose(); + }); + it("fails closed when the current document URL is excluded", () => { const excludedPolicy: CapturePolicy = { ...DEFAULT_CAPTURE_POLICY, diff --git a/packages/webblackbox/src/lite-capture-agent.ts b/packages/webblackbox/src/lite-capture-agent.ts index bd64205..a02c06e 100644 --- a/packages/webblackbox/src/lite-capture-agent.ts +++ b/packages/webblackbox/src/lite-capture-agent.ts @@ -259,6 +259,11 @@ export class LiteCaptureAgent { const wasRecording = this.recordingActive; const nextPolicy = state.capturePolicy ?? this.capturePolicy; + + if (typeof state.tabId === "number" && Number.isFinite(state.tabId)) { + this.tabId = Math.round(state.tabId); + } + const nextActive = state.active && this.isDocumentWithinScope(nextPolicy); this.capturePolicy = nextPolicy; @@ -290,10 +295,6 @@ export class LiteCaptureAgent { this.sid = state.sid; } - if (typeof state.tabId === "number" && Number.isFinite(state.tabId)) { - this.tabId = Math.round(state.tabId); - } - if (this.recordingActive) { this.scheduleScopeExpiry(); this.ensureCaptureInstalled(); @@ -1912,6 +1913,7 @@ export class LiteCaptureAgent { private isDocumentWithinScope(policy: CapturePolicy = this.capturePolicy): boolean { return evaluateCaptureScope(policy, { url: readDocumentUrl(), + tabId: this.tabId, topLevel: this.isTopLevelFrame, frameId: this.isTopLevelFrame ? 0 : 1 }).allowed; From 1f1f48ae3d0a3df9dc76703bf6bb4af09ab9b367 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:38:59 +0800 Subject: [PATCH 091/181] fix(player): reject ambiguous ZIP logical names --- .../src/archive-resource-limits.test.ts | 301 ++++++++++++++++++ .../player-sdk/src/archive-resource-limits.ts | 213 ++++++++++++- 2 files changed, 507 insertions(+), 7 deletions(-) diff --git a/packages/player-sdk/src/archive-resource-limits.test.ts b/packages/player-sdk/src/archive-resource-limits.test.ts index 2d32f39..8c023f3 100644 --- a/packages/player-sdk/src/archive-resource-limits.test.ts +++ b/packages/player-sdk/src/archive-resource-limits.test.ts @@ -82,6 +82,105 @@ describe("archive resource limits", () => { ).toThrow(/non-canonical ZIP entry name/i); }); + it("rejects Unicode Path overrides before JSZip can collapse logical entry names", async () => { + const zip = new JSZip(); + zip.file("logical-one-😀", "first"); + zip.file("logical-two-😀", "second"); + const bytes = await zip.generateAsync({ + type: "uint8array", + compression: "STORE", + encodeFileName(name) { + if (name === "logical-one-😀") { + return "entry-one.json"; + } + if (name === "logical-two-😀") { + return "entry-two.json"; + } + return name; + } + }); + expect(replaceUtf8InPlace(bytes, "logical-two-😀", "logical-one-😀")).toBe(2); + + const loaded = await JSZip.loadAsync(bytes); + expect(Object.keys(loaded.files)).toEqual(["logical-one-😀"]); + await expect(loaded.file("logical-one-😀")?.async("string")).resolves.toBe("second"); + expect(() => assertArchiveInputResourceLimits(bytes, resolveArchiveResourceLimits())).toThrow( + /Unicode Path ZIP extra fields/i + ); + }); + + it("rejects local filename and Unicode-extra differences from the central directory", async () => { + const mismatchedZip = new JSZip(); + mismatchedZip.file("safe-name.json", "payload"); + const mismatchedBytes = await mismatchedZip.generateAsync({ + type: "uint8array", + compression: "STORE" + }); + replaceZipHeaderFileName(mismatchedBytes, 0x04034b50, "safe-name.json", "evil-name.json"); + + const loaded = await JSZip.loadAsync(mismatchedBytes); + expect(Object.keys(loaded.files)).toEqual(["evil-name.json"]); + expect(() => + assertArchiveInputResourceLimits(mismatchedBytes, resolveArchiveResourceLimits()) + ).toThrow(/local and central ZIP filenames differ/i); + + const localUnicodeZip = new JSZip(); + localUnicodeZip.file("logical-only-😀", "payload"); + const localUnicodeBytes = await localUnicodeZip.generateAsync({ + type: "uint8array", + compression: "STORE", + encodeFileName(name) { + return name === "logical-only-😀" ? "safe-name.json" : name; + } + }); + replaceZipExtraFieldId(localUnicodeBytes, 0x02014b50, "safe-name.json", 0x7075, 0x7076); + expect(() => + assertArchiveInputResourceLimits(localUnicodeBytes, resolveArchiveResourceLimits()) + ).toThrow(/Unicode Path ZIP extra fields.*local file header/i); + }); + + it("rejects malformed central and local ZIP extra-field bounds", async () => { + const centralZip = new JSZip(); + centralZip.file("central.bin", "payload", { comment: "abc" }); + const centralBytes = await centralZip.generateAsync({ + type: "uint8array", + compression: "STORE" + }); + moveCentralCommentIntoMalformedExtraField(centralBytes, "central.bin"); + expect(() => + assertArchiveInputResourceLimits(centralBytes, resolveArchiveResourceLimits()) + ).toThrow(/malformed ZIP extra fields.*central directory/i); + + const localZip = new JSZip(); + localZip.file("local.bin", "payload"); + const localBytes = await localZip.generateAsync({ + type: "uint8array", + compression: "STORE" + }); + forgeLocalExtraLength(localBytes, "local.bin", 0xffff); + expect(() => + assertArchiveInputResourceLimits(localBytes, resolveArchiveResourceLimits()) + ).toThrow(/malformed local ZIP header/i); + }); + + it("resolves ZIP64 local offsets with prepended archive bytes", async () => { + const zip = new JSZip(); + zip.file("payload.bin", "payload"); + const generated = await zip.generateAsync({ + type: "uint8array", + compression: "STORE" + }); + const zip64 = promoteCentralLocalHeaderOffsetToZip64(generated, "payload.bin"); + const prepended = new Uint8Array(zip64.byteLength + 128); + prepended.fill(0x41, 0, 128); + prepended.set(zip64, 128); + + await expect(JSZip.loadAsync(prepended)).resolves.toBeInstanceOf(JSZip); + expect(() => + assertArchiveInputResourceLimits(prepended, resolveArchiveResourceLimits()) + ).not.toThrow(); + }); + it("rejects high compression ratios without inflating an entry", async () => { const zip = new JSZip(); zip.file("repetitive.txt", "a".repeat(512 * 1024)); @@ -164,6 +263,10 @@ describe("archive resource limits", () => { }); function replaceAsciiInPlace(bytes: Uint8Array, search: string, replacement: string): number { + return replaceUtf8InPlace(bytes, search, replacement); +} + +function replaceUtf8InPlace(bytes: Uint8Array, search: string, replacement: string): number { const searchBytes = new TextEncoder().encode(search); const replacementBytes = new TextEncoder().encode(replacement); if (searchBytes.byteLength !== replacementBytes.byteLength) { @@ -190,3 +293,201 @@ function replaceAsciiInPlace(bytes: Uint8Array, search: string, replacement: str return replacements; } + +function replaceZipHeaderFileName( + bytes: Uint8Array, + signature: number, + search: string, + replacement: string +): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const replacementBytes = encoder.encode(replacement); + + for (let offset = 0; offset + 30 <= bytes.byteLength; offset += 1) { + if (view.getUint32(offset, true) !== signature) { + continue; + } + + const local = signature === 0x04034b50; + const fileNameBytes = view.getUint16(offset + (local ? 26 : 28), true); + const fileNameOffset = offset + (local ? 30 : 46); + if (decoder.decode(bytes.subarray(fileNameOffset, fileNameOffset + fileNameBytes)) !== search) { + continue; + } + if (replacementBytes.byteLength !== fileNameBytes) { + throw new Error("ZIP test replacement names must have the same byte length."); + } + + bytes.set(replacementBytes, fileNameOffset); + return; + } + + throw new Error(`ZIP test header was not found for '${search}'.`); +} + +function replaceZipExtraFieldId( + bytes: Uint8Array, + signature: number, + targetName: string, + searchId: number, + replacementId: number +): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + + for (let offset = 0; offset + 46 <= bytes.byteLength; offset += 1) { + if (view.getUint32(offset, true) !== signature) { + continue; + } + + const fileNameBytes = view.getUint16(offset + 28, true); + const extraBytes = view.getUint16(offset + 30, true); + const fileNameOffset = offset + 46; + if ( + decoder.decode(bytes.subarray(fileNameOffset, fileNameOffset + fileNameBytes)) !== targetName + ) { + continue; + } + + let extraOffset = fileNameOffset + fileNameBytes; + const extraEnd = extraOffset + extraBytes; + while (extraOffset + 4 <= extraEnd) { + const fieldId = view.getUint16(extraOffset, true); + const fieldBytes = view.getUint16(extraOffset + 2, true); + if (fieldId === searchId) { + view.setUint16(extraOffset, replacementId, true); + return; + } + extraOffset += 4 + fieldBytes; + } + } + + throw new Error(`ZIP test extra field ${searchId} was not found for '${targetName}'.`); +} + +function moveCentralCommentIntoMalformedExtraField(bytes: Uint8Array, targetName: string): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + + for (let offset = 0; offset + 46 <= bytes.byteLength; offset += 1) { + if (view.getUint32(offset, true) !== 0x02014b50) { + continue; + } + const fileNameBytes = view.getUint16(offset + 28, true); + const fileNameOffset = offset + 46; + if ( + decoder.decode(bytes.subarray(fileNameOffset, fileNameOffset + fileNameBytes)) !== targetName + ) { + continue; + } + + const commentBytes = view.getUint16(offset + 32, true); + if (commentBytes < 1 || view.getUint16(offset + 30, true) !== 0) { + throw new Error("ZIP test requires a comment and no existing central extra fields."); + } + view.setUint16(offset + 30, commentBytes, true); + view.setUint16(offset + 32, 0, true); + return; + } + + throw new Error(`ZIP test central entry was not found for '${targetName}'.`); +} + +function forgeLocalExtraLength(bytes: Uint8Array, targetName: string, extraBytes: number): void { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + + for (let offset = 0; offset + 30 <= bytes.byteLength; offset += 1) { + if (view.getUint32(offset, true) !== 0x04034b50) { + continue; + } + const fileNameBytes = view.getUint16(offset + 26, true); + const fileNameOffset = offset + 30; + if ( + decoder.decode(bytes.subarray(fileNameOffset, fileNameOffset + fileNameBytes)) === targetName + ) { + view.setUint16(offset + 28, extraBytes, true); + return; + } + } + + throw new Error(`ZIP test local entry was not found for '${targetName}'.`); +} + +function promoteCentralLocalHeaderOffsetToZip64(bytes: Uint8Array, targetName: string): Uint8Array { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const decoder = new TextDecoder(); + const eocdOffset = findEocdOffset(view); + const centralOffset = view.getUint32(eocdOffset + 16, true); + const centralBytes = view.getUint32(eocdOffset + 12, true); + const centralEnd = centralOffset + centralBytes; + const records: Uint8Array[] = []; + let matched = false; + + for (let offset = centralOffset; offset < centralEnd; ) { + if (view.getUint32(offset, true) !== 0x02014b50) { + throw new Error("ZIP test central directory is malformed."); + } + const fileNameBytes = view.getUint16(offset + 28, true); + const extraBytes = view.getUint16(offset + 30, true); + const commentBytes = view.getUint16(offset + 32, true); + const fileNameOffset = offset + 46; + const extraOffset = fileNameOffset + fileNameBytes; + const commentOffset = extraOffset + extraBytes; + const nextOffset = commentOffset + commentBytes; + const record = bytes.slice(offset, nextOffset); + + if (decoder.decode(bytes.subarray(fileNameOffset, extraOffset)) === targetName) { + const localHeaderOffset = view.getUint32(offset + 42, true); + const zip64Extra = new Uint8Array(12); + const zip64View = new DataView(zip64Extra.buffer); + zip64View.setUint16(0, 0x0001, true); + zip64View.setUint16(2, 8, true); + zip64View.setBigUint64(4, BigInt(localHeaderOffset), true); + + const expanded = new Uint8Array(record.byteLength + zip64Extra.byteLength); + const insertionOffset = 46 + fileNameBytes + extraBytes; + expanded.set(record.subarray(0, insertionOffset)); + expanded.set(zip64Extra, insertionOffset); + expanded.set(record.subarray(insertionOffset), insertionOffset + zip64Extra.byteLength); + const expandedView = new DataView(expanded.buffer); + expandedView.setUint16(30, extraBytes + zip64Extra.byteLength, true); + expandedView.setUint32(42, 0xffffffff, true); + records.push(expanded); + matched = true; + } else { + records.push(record); + } + + offset = nextOffset; + } + + if (!matched) { + throw new Error(`ZIP test central entry was not found for '${targetName}'.`); + } + + const expandedCentralBytes = records.reduce((total, record) => total + record.byteLength, 0); + const output = new Uint8Array( + centralOffset + expandedCentralBytes + (bytes.byteLength - eocdOffset) + ); + output.set(bytes.subarray(0, centralOffset)); + let outputOffset = centralOffset; + for (const record of records) { + output.set(record, outputOffset); + outputOffset += record.byteLength; + } + output.set(bytes.subarray(eocdOffset), outputOffset); + new DataView(output.buffer).setUint32(outputOffset + 12, expandedCentralBytes, true); + return output; +} + +function findEocdOffset(view: DataView): number { + for (let offset = view.byteLength - 22; offset >= 0; offset -= 1) { + if (view.getUint32(offset, true) === 0x06054b50) { + return offset; + } + } + throw new Error("ZIP test end-of-central-directory record was not found."); +} diff --git a/packages/player-sdk/src/archive-resource-limits.ts b/packages/player-sdk/src/archive-resource-limits.ts index e418484..7cdd2f8 100644 --- a/packages/player-sdk/src/archive-resource-limits.ts +++ b/packages/player-sdk/src/archive-resource-limits.ts @@ -6,9 +6,18 @@ const ZIP64_END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06064b50; const ZIP64_END_OF_CENTRAL_DIRECTORY_LOCATOR_SIGNATURE = 0x07064b50; const ZIP_CENTRAL_DIRECTORY_ENTRY_SIGNATURE = 0x02014b50; const ZIP_CENTRAL_DIRECTORY_DIGITAL_SIGNATURE = 0x05054b50; +const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; +const ZIP64_EXTRA_FIELD_ID = 0x0001; +const ZIP_UNICODE_PATH_EXTRA_FIELD_ID = 0x7075; +const ZIP_UINT32_SENTINEL = 0xffffffff; const ZIP_END_OF_CENTRAL_DIRECTORY_MIN_BYTES = 22; const ZIP_MAX_COMMENT_BYTES = 0xffff; +type ZipExtraField = { + dataOffset: number; + length: number; +}; + /** Resource limits applied before and while an archive is opened. Overrides may only tighten them. */ export type ArchiveResourceLimits = { maxInputBytes: number; @@ -303,10 +312,17 @@ function readDeclaredZipEntryCount(bytes: Uint8Array): number | null { continue; } + const physicalCentralDirectoryOffset = offset - centralDirectoryBytes; + const archiveZero = physicalCentralDirectoryOffset - centralDirectoryOffset; + if (!Number.isSafeInteger(archiveZero) || archiveZero < 0) { + return Number.POSITIVE_INFINITY; + } + const physicalEntryCount = countPhysicalCentralDirectoryEntries( view, - offset - centralDirectoryBytes, - offset + physicalCentralDirectoryOffset, + offset, + archiveZero ); return physicalEntryCount === null ? Number.POSITIVE_INFINITY @@ -321,7 +337,8 @@ function readDeclaredZipEntryCount(bytes: Uint8Array): number | null { const physicalEntryCount = countPhysicalCentralDirectoryEntries( view, zip64.recordOffset - zip64.centralDirectoryBytes, - zip64.recordOffset + zip64.recordOffset, + zip64.recordOffset - zip64.centralDirectoryBytes - zip64.centralDirectoryOffset ); return physicalEntryCount === null ? Number.POSITIVE_INFINITY @@ -334,9 +351,17 @@ function readDeclaredZipEntryCount(bytes: Uint8Array): number | null { function countPhysicalCentralDirectoryEntries( view: DataView, centralDirectoryOffset: number, - centralDirectoryEnd: number + centralDirectoryEnd: number, + archiveZero: number ): number | null { - if (centralDirectoryOffset < 0 || centralDirectoryEnd > view.byteLength) { + if ( + !Number.isSafeInteger(centralDirectoryOffset) || + !Number.isSafeInteger(centralDirectoryEnd) || + !Number.isSafeInteger(archiveZero) || + centralDirectoryOffset < 0 || + centralDirectoryEnd > view.byteLength || + archiveZero < 0 + ) { return null; } @@ -365,17 +390,36 @@ function countPhysicalCentralDirectoryEntries( const extraBytes = view.getUint16(offset + 30, true); const commentBytes = view.getUint16(offset + 32, true); const fileNameOffset = offset + 46; - const nextOffset = fileNameOffset + fileNameBytes + extraBytes + commentBytes; + const extraOffset = fileNameOffset + fileNameBytes; + const commentOffset = extraOffset + extraBytes; + const nextOffset = commentOffset + commentBytes; if (nextOffset > centralDirectoryEnd) { return null; } const fileName = readCanonicalArchiveEntryName(view, fileNameOffset, fileNameBytes); + const extraFields = readZipExtraFields( + view, + extraOffset, + extraBytes, + `central directory entry '${fileName}'` + ); if (names.has(fileName)) { throw new Error(`Invalid WebBlackbox archive: duplicate ZIP entry '${fileName}'.`); } names.add(fileName); + const localHeaderOffset = readCentralDirectoryLocalHeaderOffset(view, offset, extraFields); + assertMatchingLocalFileHeader( + view, + archiveZero, + localHeaderOffset, + centralDirectoryOffset, + fileNameOffset, + fileNameBytes, + fileName + ); + offset = nextOffset; count += 1; } @@ -383,6 +427,155 @@ function countPhysicalCentralDirectoryEntries( return offset === centralDirectoryEnd ? count : null; } +function readZipExtraFields( + view: DataView, + extraOffset: number, + extraBytes: number, + location: string +): Map { + const extraEnd = extraOffset + extraBytes; + if ( + !Number.isSafeInteger(extraOffset) || + !Number.isSafeInteger(extraEnd) || + extraOffset < 0 || + extraEnd > view.byteLength + ) { + throw new Error(`Invalid WebBlackbox archive: malformed ZIP extra fields in ${location}.`); + } + + const fields = new Map(); + let offset = extraOffset; + while (offset < extraEnd) { + if (offset + 4 > extraEnd) { + throw new Error(`Invalid WebBlackbox archive: malformed ZIP extra fields in ${location}.`); + } + + const fieldId = view.getUint16(offset, true); + const fieldBytes = view.getUint16(offset + 2, true); + const dataOffset = offset + 4; + const nextOffset = dataOffset + fieldBytes; + if (nextOffset > extraEnd) { + throw new Error(`Invalid WebBlackbox archive: malformed ZIP extra fields in ${location}.`); + } + if (fieldId === ZIP_UNICODE_PATH_EXTRA_FIELD_ID) { + throw new Error( + `Invalid WebBlackbox archive: Unicode Path ZIP extra fields are not supported in ${location}.` + ); + } + + // JSZip retains the last field for duplicate IDs, so mirror that behavior for ZIP64 offsets. + fields.set(fieldId, { dataOffset, length: fieldBytes }); + offset = nextOffset; + } + + return fields; +} + +function readCentralDirectoryLocalHeaderOffset( + view: DataView, + centralEntryOffset: number, + extraFields: ReadonlyMap +): number { + const localHeaderOffset = view.getUint32(centralEntryOffset + 42, true); + if (localHeaderOffset !== ZIP_UINT32_SENTINEL) { + return localHeaderOffset; + } + + const zip64 = extraFields.get(ZIP64_EXTRA_FIELD_ID); + if (!zip64) { + throw new Error( + "Invalid WebBlackbox archive: ZIP64 local header offset is missing from the central directory." + ); + } + + let offset = zip64.dataOffset; + const end = zip64.dataOffset + zip64.length; + if (view.getUint32(centralEntryOffset + 24, true) === ZIP_UINT32_SENTINEL) { + offset = skipZip64Integer(offset, end, "uncompressed size"); + } + if (view.getUint32(centralEntryOffset + 20, true) === ZIP_UINT32_SENTINEL) { + offset = skipZip64Integer(offset, end, "compressed size"); + } + if (offset + 8 > end) { + throw new Error( + "Invalid WebBlackbox archive: malformed ZIP64 local header offset in the central directory." + ); + } + + const value = view.getBigUint64(offset, true); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + return Number.POSITIVE_INFINITY; + } + return Number(value); +} + +function skipZip64Integer(offset: number, end: number, field: string): number { + if (offset + 8 > end) { + throw new Error( + `Invalid WebBlackbox archive: malformed ZIP64 ${field} in the central directory.` + ); + } + return offset + 8; +} + +function assertMatchingLocalFileHeader( + view: DataView, + archiveZero: number, + localHeaderOffset: number, + centralDirectoryOffset: number, + centralFileNameOffset: number, + centralFileNameBytes: number, + fileName: string +): void { + const physicalOffset = archiveZero + localHeaderOffset; + if ( + !Number.isSafeInteger(physicalOffset) || + physicalOffset < archiveZero || + physicalOffset + 30 > centralDirectoryOffset || + view.getUint32(physicalOffset, true) !== ZIP_LOCAL_FILE_HEADER_SIGNATURE + ) { + throw new Error(`Invalid WebBlackbox archive: invalid local ZIP header for '${fileName}'.`); + } + + const localFileNameBytes = view.getUint16(physicalOffset + 26, true); + const localExtraBytes = view.getUint16(physicalOffset + 28, true); + const localFileNameOffset = physicalOffset + 30; + const localExtraOffset = localFileNameOffset + localFileNameBytes; + const localHeaderEnd = localExtraOffset + localExtraBytes; + if (!Number.isSafeInteger(localHeaderEnd) || localHeaderEnd > centralDirectoryOffset) { + throw new Error(`Invalid WebBlackbox archive: malformed local ZIP header for '${fileName}'.`); + } + if ( + localFileNameBytes !== centralFileNameBytes || + !equalBytes(view, localFileNameOffset, centralFileNameOffset, centralFileNameBytes) + ) { + throw new Error( + `Invalid WebBlackbox archive: local and central ZIP filenames differ for '${fileName}'.` + ); + } + + readZipExtraFields( + view, + localExtraOffset, + localExtraBytes, + `local file header for '${fileName}'` + ); +} + +function equalBytes( + view: DataView, + leftOffset: number, + rightOffset: number, + length: number +): boolean { + for (let index = 0; index < length; index += 1) { + if (view.getUint8(leftOffset + index) !== view.getUint8(rightOffset + index)) { + return false; + } + } + return true; +} + function readCanonicalArchiveEntryName( view: DataView, fileNameOffset: number, @@ -425,6 +618,7 @@ function readZip64CentralDirectory( ): { entryCount: number; centralDirectoryBytes: number; + centralDirectoryOffset: number; recordOffset: number; } | null { const locatorOffset = eocdOffset - 20; @@ -440,6 +634,7 @@ function readZip64CentralDirectory( return { entryCount: Number.POSITIVE_INFINITY, centralDirectoryBytes: Number.POSITIVE_INFINITY, + centralDirectoryOffset: Number.POSITIVE_INFINITY, recordOffset: 0 }; } @@ -455,13 +650,16 @@ function readZip64CentralDirectory( const entryCount = view.getBigUint64(zip64Offset + 32, true); const centralDirectoryBytes = view.getBigUint64(zip64Offset + 40, true); + const centralDirectoryOffset = view.getBigUint64(zip64Offset + 48, true); if ( entryCount > BigInt(Number.MAX_SAFE_INTEGER) || - centralDirectoryBytes > BigInt(Number.MAX_SAFE_INTEGER) + centralDirectoryBytes > BigInt(Number.MAX_SAFE_INTEGER) || + centralDirectoryOffset > BigInt(Number.MAX_SAFE_INTEGER) ) { return { entryCount: Number.POSITIVE_INFINITY, centralDirectoryBytes: Number.POSITIVE_INFINITY, + centralDirectoryOffset: Number.POSITIVE_INFINITY, recordOffset: zip64Offset }; } @@ -469,6 +667,7 @@ function readZip64CentralDirectory( return { entryCount: Number(entryCount), centralDirectoryBytes: Number(centralDirectoryBytes), + centralDirectoryOffset: Number(centralDirectoryOffset), recordOffset: zip64Offset }; } From 0655ff907361fa3cf0553abc7032f600566d65c8 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:39:51 +0800 Subject: [PATCH 092/181] fix(recorder): enforce WebSocket payload policy --- apps/extension/src/sw/index.ts | 27 ++-- packages/recorder/src/index.ts | 1 + packages/recorder/src/recorder.ts | 15 ++- packages/recorder/src/websocket-frame.test.ts | 120 ++++++++++++++++++ packages/recorder/src/websocket-frame.ts | 101 +++++++++++++++ 5 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 packages/recorder/src/websocket-frame.test.ts create mode 100644 packages/recorder/src/websocket-frame.ts diff --git a/apps/extension/src/sw/index.ts b/apps/extension/src/sw/index.ts index 0e62a4d..72260e5 100644 --- a/apps/extension/src/sw/index.ts +++ b/apps/extension/src/sw/index.ts @@ -24,6 +24,7 @@ import { } from "@webblackbox/protocol"; import { createDefaultRecorderPlugins, + normalizeWebSocketFramePayload, type RawRecorderEvent, WebBlackboxRecorder } from "@webblackbox/recorder"; @@ -2493,7 +2494,11 @@ async function attachCdp(runtime: SessionRuntime): Promise { router = createCdpRouter(createChromeDebuggerTransport()); const unsubscribeEvent = router.onEvent((event) => { - const normalizedPayload = normalizeFullModePayload(event.method, event.params ?? {}); + const normalizedPayload = normalizeFullModePayload( + event.method, + event.params ?? {}, + runtime.config.capturePolicy?.categories.network + ); const scopeEvaluation = evaluateCdpCaptureScopeEvent( runtime.cdpScope, event.method, @@ -3881,7 +3886,11 @@ function normalizeMimeType(value: string | null): string | undefined { return normalizeMimeTypeUtil(value); } -function normalizeFullModePayload(method: string, params: unknown): unknown { +function normalizeFullModePayload( + method: string, + params: unknown, + networkPolicy: CapturePolicy["categories"]["network"] | undefined +): unknown { const payload = asRecord(params); if (!payload) { @@ -3889,19 +3898,7 @@ function normalizeFullModePayload(method: string, params: unknown): unknown { } if (method === "Network.webSocketFrameReceived" || method === "Network.webSocketFrameSent") { - const response = asRecord(payload.response); - const rawData = typeof response?.payloadData === "string" ? response.payloadData : ""; - - return { - ...payload, - direction: method.endsWith("Sent") ? "sent" : "received", - frame: { - opcode: typeof response?.opcode === "number" ? response.opcode : undefined, - masked: response?.mask === true, - payloadLength: rawData.length, - payloadPreview: rawData.slice(0, 512) - } - }; + return normalizeWebSocketFramePayload(method, payload, networkPolicy); } return payload; diff --git a/packages/recorder/src/index.ts b/packages/recorder/src/index.ts index b4a0d49..803c95a 100644 --- a/packages/recorder/src/index.ts +++ b/packages/recorder/src/index.ts @@ -6,3 +6,4 @@ export * from "./recorder.js"; export * from "./redaction.js"; export * from "./ring-buffer.js"; export * from "./types.js"; +export * from "./websocket-frame.js"; diff --git a/packages/recorder/src/recorder.ts b/packages/recorder/src/recorder.ts index 09adb81..90309ca 100644 --- a/packages/recorder/src/recorder.ts +++ b/packages/recorder/src/recorder.ts @@ -16,6 +16,7 @@ import type { RecorderPlugin, RecorderPluginContext } from "./plugins.js"; import { redactPayload } from "./redaction.js"; import { EventRingBuffer } from "./ring-buffer.js"; import type { EventNormalizer, RawRecorderEvent, RecorderIngestResult } from "./types.js"; +import { normalizeWebSocketFramePayload } from "./websocket-frame.js"; const NON_TEXT_KEYBOARD_KEYS = new Set([ "Alt", @@ -118,15 +119,23 @@ export class WebBlackboxRecorder { return {}; } - const redactedPayload = redactPayload(normalized.payload, this.config.redaction); + const policyBoundPayload = + normalized.eventType === "network.ws.frame" + ? normalizeWebSocketFramePayload( + nextRawEvent.rawType, + normalized.payload, + this.config.capturePolicy?.categories.network + ) + : normalized.payload; + const redactedPayload = redactPayload(policyBoundPayload, this.config.redaction); const privacy = classifyPrivacy( normalized.eventType, - normalized.payload, + policyBoundPayload, this.config.capturePolicy ); const violation = evaluateCapturePolicy( normalized.eventType, - normalized.payload, + policyBoundPayload, privacy, this.config.capturePolicy ); diff --git a/packages/recorder/src/websocket-frame.test.ts b/packages/recorder/src/websocket-frame.test.ts new file mode 100644 index 0000000..74b0823 --- /dev/null +++ b/packages/recorder/src/websocket-frame.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_CAPTURE_POLICY, + DEFAULT_RECORDER_CONFIG, + type CapturePolicy +} from "@webblackbox/protocol"; + +import { WebBlackboxRecorder } from "./recorder.js"; +import { WEBSOCKET_FRAME_PAYLOAD_PREVIEW_MAX_CHARS } from "./websocket-frame.js"; + +type NetworkCapturePolicy = CapturePolicy["categories"]["network"]; + +type RecordedWebSocketFrame = { + requestId?: string; + timestamp?: number; + direction?: "sent" | "received"; + response?: unknown; + payloadData?: unknown; + payloadPreview?: unknown; + frame?: { + opcode?: number; + masked?: boolean; + payloadLength?: number; + payloadPreview?: string; + }; +}; + +describe("WebSocket frame capture policy", () => { + it.each(["metadata", "headers-allowlist"] as const)( + "persists metadata without a payload preview in %s mode", + (networkPolicy) => { + const rawPayload = "opaque-private-websocket-value"; + const event = ingestWebSocketFrame(networkPolicy, rawPayload); + const data = event?.data as RecordedWebSocketFrame | undefined; + + expect(event?.type).toBe("network.ws.frame"); + expect(data).toEqual({ + requestId: "ws-request-1", + timestamp: 12.5, + direction: "received", + frame: { + opcode: 1, + masked: false, + payloadLength: rawPayload.length + } + }); + expect(JSON.stringify(data)).not.toContain(rawPayload); + expect(data?.response).toBeUndefined(); + expect(data?.payloadData).toBeUndefined(); + expect(data?.payloadPreview).toBeUndefined(); + expect(data?.frame?.payloadPreview).toBeUndefined(); + } + ); + + it("bounds and redacts payload previews in body-allowlist mode", () => { + const rawPayload = "x".repeat(WEBSOCKET_FRAME_PAYLOAD_PREVIEW_MAX_CHARS + 80); + const boundedEvent = ingestWebSocketFrame( + "body-allowlist", + rawPayload, + "Network.webSocketFrameSent" + ); + const boundedData = boundedEvent?.data as RecordedWebSocketFrame | undefined; + + expect(boundedEvent?.type).toBe("network.ws.frame"); + expect(boundedData?.direction).toBe("sent"); + expect(boundedData?.frame?.payloadLength).toBe(rawPayload.length); + expect(boundedData?.frame?.payloadPreview).toBe( + rawPayload.slice(0, WEBSOCKET_FRAME_PAYLOAD_PREVIEW_MAX_CHARS) + ); + expect(boundedData?.frame?.payloadPreview).toHaveLength( + WEBSOCKET_FRAME_PAYLOAD_PREVIEW_MAX_CHARS + ); + expect(boundedData?.response).toBeUndefined(); + + const sensitivePayload = "websocket-secret-material"; + const redactedEvent = ingestWebSocketFrame("body-allowlist", sensitivePayload); + const redactedData = redactedEvent?.data as RecordedWebSocketFrame | undefined; + + expect(redactedData?.frame?.payloadLength).toBe(sensitivePayload.length); + expect(redactedData?.frame?.payloadPreview).not.toBe(sensitivePayload); + expect(redactedData?.frame?.payloadPreview).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.stringify(redactedData)).not.toContain(sensitivePayload); + }); +}); + +function ingestWebSocketFrame( + networkPolicy: NetworkCapturePolicy, + payloadData: string, + rawType = "Network.webSocketFrameReceived" +) { + const recorder = new WebBlackboxRecorder({ + ...DEFAULT_RECORDER_CONFIG, + capturePolicy: { + ...DEFAULT_CAPTURE_POLICY, + categories: { + ...DEFAULT_CAPTURE_POLICY.categories, + network: networkPolicy + } + } + }); + + return recorder.ingest({ + source: "cdp", + rawType, + tabId: 7, + sid: "S-websocket-policy", + t: 100, + mono: 50, + payload: { + requestId: "ws-request-1", + timestamp: 12.5, + response: { + opcode: 1, + mask: false, + payloadData + } + } + }).event; +} diff --git a/packages/recorder/src/websocket-frame.ts b/packages/recorder/src/websocket-frame.ts new file mode 100644 index 0000000..568024b --- /dev/null +++ b/packages/recorder/src/websocket-frame.ts @@ -0,0 +1,101 @@ +import type { CapturePolicy } from "@webblackbox/protocol"; + +export const WEBSOCKET_FRAME_PAYLOAD_PREVIEW_MAX_CHARS = 512; + +type NetworkCapturePolicy = CapturePolicy["categories"]["network"]; + +/** + * Reduces a CDP WebSocket frame to bounded metadata, adding a payload preview + * only when the effective network policy explicitly allows response bodies. + */ +export function normalizeWebSocketFramePayload( + method: string, + input: unknown, + networkPolicy: NetworkCapturePolicy | undefined +): Record { + const payload = asRecord(input); + const response = asRecord(payload?.response); + const frame = asRecord(payload?.frame); + const responsePayload = asString(response?.payloadData); + const previewSource = + responsePayload ?? + asString(frame?.payloadPreview) ?? + asString(payload?.payloadPreview) ?? + asString(payload?.payloadData); + const declaredPayloadLength = asNonNegativeInteger(frame?.payloadLength); + const payloadLength = + responsePayload !== undefined + ? responsePayload.length + : (declaredPayloadLength ?? previewSource?.length ?? 0); + const normalizedFrame: Record = { + opcode: asFiniteNumber(frame?.opcode) ?? asFiniteNumber(response?.opcode), + masked: + typeof frame?.masked === "boolean" + ? frame.masked + : typeof response?.mask === "boolean" + ? response.mask + : false, + payloadLength + }; + + if (networkPolicy === "body-allowlist" && previewSource !== undefined) { + normalizedFrame.payloadPreview = previewSource.slice( + 0, + WEBSOCKET_FRAME_PAYLOAD_PREVIEW_MAX_CHARS + ); + } + + const normalized: Record = { + direction: resolveDirection(method, payload), + frame: normalizedFrame + }; + const requestId = asString(payload?.requestId); + const timestamp = asFiniteNumber(payload?.timestamp); + + if (requestId !== undefined) { + normalized.requestId = requestId; + } + + if (timestamp !== undefined) { + normalized.timestamp = timestamp; + } + + return normalized; +} + +function resolveDirection( + method: string, + payload: Record | null +): "sent" | "received" | undefined { + if (method === "Network.webSocketFrameSent") { + return "sent"; + } + + if (method === "Network.webSocketFrameReceived") { + return "received"; + } + + return payload?.direction === "sent" || payload?.direction === "received" + ? payload.direction + : undefined; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asNonNegativeInteger(value: unknown): number | undefined { + const number = asFiniteNumber(value); + + return number !== undefined && number >= 0 ? Math.round(number) : undefined; +} From 0e6c50fc6b588a66165633001f65b0d3392dc5dd Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:40:57 +0800 Subject: [PATCH 093/181] fix(extension): recover offscreen recorder ports --- apps/extension/src/offscreen/index.ts | 95 ++++++++-- .../src/offscreen/runtime-port.test.ts | 116 ++++++++++++ apps/extension/src/offscreen/runtime-port.ts | 166 ++++++++++++++++++ .../offscreen/screen-recording-port.test.ts | 52 ++++++ .../src/offscreen/screen-recording-port.ts | 55 ++++++ 5 files changed, 465 insertions(+), 19 deletions(-) create mode 100644 apps/extension/src/offscreen/runtime-port.test.ts create mode 100644 apps/extension/src/offscreen/runtime-port.ts create mode 100644 apps/extension/src/offscreen/screen-recording-port.test.ts create mode 100644 apps/extension/src/offscreen/screen-recording-port.ts diff --git a/apps/extension/src/offscreen/index.ts b/apps/extension/src/offscreen/index.ts index 10ae008..8065e1a 100644 --- a/apps/extension/src/offscreen/index.ts +++ b/apps/extension/src/offscreen/index.ts @@ -11,6 +11,11 @@ import { getChromeApi } from "../shared/chrome-api.js"; import { createExtensionI18n } from "../shared/i18n.js"; import { PORT_NAMES } from "../shared/messages.js"; import { getExtensionPipelineStorage } from "./pipeline-storage.js"; +import { ResilientRuntimePort } from "./runtime-port.js"; +import { + pauseScreenRecordingForPortDisconnect, + resumeScreenRecordingAfterPortReconnect +} from "./screen-recording-port.js"; type OffscreenPipelineRequest = { kind: "sw.pipeline-request"; @@ -98,6 +103,7 @@ type OffscreenScreenRecordingState = { sizeBytes: number; pendingChunks: Set>; stopping: boolean; + pausedForPortDisconnect: boolean; stopPromise: Promise | null; }; @@ -105,7 +111,6 @@ const chromeApi = getChromeApi(); createExtensionI18n({ pageTitleKey: "pageTitleOffscreen" }); -const port = chromeApi?.runtime?.connect({ name: PORT_NAMES.offscreen }); const pipelines = new Map(); const screenRecordings = new Map(); const EXPORT_OBJECT_URL_TTL_MS = 90_000; @@ -114,6 +119,9 @@ const SCREEN_RECORDING_TIMESLICE_MS = 1_000; const SCREEN_RECORDING_STOP_TIMEOUT_MS = 10_000; const SCREEN_RECORDING_MAX_FRAME_RATE = 30; const SCREEN_RECORDING_VIDEO_BITS_PER_SECOND = 3_500_000; +const OFFSCREEN_PORT_RECONNECT_INITIAL_MS = 250; +const OFFSCREEN_PORT_RECONNECT_MAX_MS = 2_000; +const OFFSCREEN_PORT_DISCONNECT_DEADLINE_MS = 5_000; const SCREEN_RECORDING_MIME_CANDIDATES = [ "video/webm;codecs=vp9", "video/webm;codecs=vp8", @@ -127,10 +135,11 @@ const state: OffscreenState = { }; let keepaliveTimer: ReturnType | null = null; +let runtimePort: ResilientRuntimePort | null = null; console.info("[WebBlackbox] offscreen pipeline initialized"); -port?.onMessage.addListener((message) => { +function handleRuntimePortMessage(message: unknown): void { if (message && typeof message === "object") { const kind = (message as { kind?: unknown }).kind; @@ -162,16 +171,65 @@ port?.onMessage.addListener((message) => { void handlePipelineRequest(message as OffscreenPipelineRequest); } } -}); +} -port?.onDisconnect?.addListener(() => { +function handleRuntimePortDisconnect(): void { stopServiceWorkerKeepalive(); -}); + for (const recording of screenRecordings.values()) { + if (!pauseScreenRecordingForPortDisconnect(recording)) { + void stopOffscreenScreenRecording( + recording.sid, + recording.recordingId, + "service-worker-disconnected", + false + ); + } + } +} -postToSw({ - kind: "offscreen.ready", - t: Date.now() -}); +function handleRuntimePortReconnect(): void { + for (const recording of screenRecordings.values()) { + if (!resumeScreenRecordingAfterPortReconnect(recording)) { + void stopOffscreenScreenRecording( + recording.sid, + recording.recordingId, + "service-worker-reconnect-failed", + true + ); + } + } + + postToSw({ + kind: "offscreen.ready", + t: Date.now() + }); + syncServiceWorkerKeepalive(); +} + +function handleRuntimePortDisconnectDeadline(): void { + for (const recording of screenRecordings.values()) { + void stopOffscreenScreenRecording( + recording.sid, + recording.recordingId, + "service-worker-reconnect-timeout", + true + ); + } +} + +if (chromeApi?.runtime) { + runtimePort = new ResilientRuntimePort({ + connect: () => chromeApi.runtime!.connect({ name: PORT_NAMES.offscreen }), + onMessage: handleRuntimePortMessage, + onConnected: handleRuntimePortReconnect, + onDisconnected: handleRuntimePortDisconnect, + onDisconnectDeadline: handleRuntimePortDisconnectDeadline, + reconnectInitialMs: OFFSCREEN_PORT_RECONNECT_INITIAL_MS, + reconnectMaxMs: OFFSCREEN_PORT_RECONNECT_MAX_MS, + disconnectDeadlineMs: OFFSCREEN_PORT_DISCONNECT_DEADLINE_MS + }); + runtimePort.start(); +} async function handlePipelineRequest(message: OffscreenPipelineRequest): Promise { try { @@ -332,12 +390,8 @@ function postPipelineResponse(message: OffscreenPipelineResponse): void { postToSw(message); } -function postToSw(message: unknown): void { - try { - port?.postMessage(message); - } catch { - void 0; - } +function postToSw(message: unknown): boolean { + return runtimePort?.post(message) ?? false; } function syncServiceWorkerKeepalive(): void { @@ -425,6 +479,7 @@ async function startOffscreenScreenRecording( sizeBytes: 0, pendingChunks: new Set(), stopping: false, + pausedForPortDisconnect: false, stopPromise: null }; @@ -567,10 +622,7 @@ function handleScreenRecordingData( const task = (async () => { const bytes = new Uint8Array(await blob.arrayBuffer()); const endOffsetMs = Math.max(0, Math.round(performance.now() - recording.startedAt)); - recording.postedChunkCount += 1; - recording.sizeBytes += bytes.byteLength; - - postToSw({ + const posted = postToSw({ kind: "offscreen.screen-recording-chunk", sid: recording.sid, recordingId: recording.recordingId, @@ -582,6 +634,11 @@ function handleScreenRecordingData( endOffsetMs, durationMs: Math.max(0, endOffsetMs - startOffsetMs) }); + if (!posted) { + throw new Error("Offscreen screen-recording chunk could not reach the service worker."); + } + recording.postedChunkCount += 1; + recording.sizeBytes += bytes.byteLength; })(); recording.pendingChunks.add(task); diff --git a/apps/extension/src/offscreen/runtime-port.test.ts b/apps/extension/src/offscreen/runtime-port.test.ts new file mode 100644 index 0000000..9e2ceba --- /dev/null +++ b/apps/extension/src/offscreen/runtime-port.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PortDisconnectHandler, PortLike, PortMessageHandler } from "../shared/chrome-api.js"; +import { ResilientRuntimePort } from "./runtime-port.js"; + +class FakePort implements PortLike { + public readonly name = "webblackbox:offscreen"; + + public readonly messages: unknown[] = []; + + private readonly messageHandlers = new Set(); + + private readonly disconnectHandlers = new Set(); + + public readonly onMessage = { + addListener: (handler: PortMessageHandler) => this.messageHandlers.add(handler), + removeListener: (handler: PortMessageHandler) => this.messageHandlers.delete(handler) + }; + + public readonly onDisconnect = { + addListener: (handler: PortDisconnectHandler) => this.disconnectHandlers.add(handler), + removeListener: (handler: PortDisconnectHandler) => this.disconnectHandlers.delete(handler) + }; + + public postMessage(message: unknown): void { + this.messages.push(message); + } + + public emitMessage(message: unknown): void { + for (const handler of this.messageHandlers) { + handler(message); + } + } + + public emitDisconnect(): void { + for (const handler of [...this.disconnectHandlers]) { + handler(); + } + } +} + +describe("ResilientRuntimePort", () => { + it("reconnects after a service-worker port disconnect before the safety deadline", async () => { + vi.useFakeTimers(); + const first = new FakePort(); + const second = new FakePort(); + const ports = [first, second]; + const onMessage = vi.fn(); + const onConnected = vi.fn(); + const onDisconnected = vi.fn(); + const onDisconnectDeadline = vi.fn(); + const manager = new ResilientRuntimePort({ + connect: () => ports.shift() ?? new FakePort(), + onMessage, + onConnected, + onDisconnected, + onDisconnectDeadline, + reconnectInitialMs: 250, + reconnectMaxMs: 1_000, + disconnectDeadlineMs: 3_000 + }); + + manager.start(); + expect(onConnected).toHaveBeenCalledTimes(1); + first.emitMessage({ ready: true }); + expect(onMessage).toHaveBeenCalledWith({ ready: true }); + + first.emitDisconnect(); + expect(onDisconnected).toHaveBeenCalledTimes(1); + expect(manager.post({ lost: false })).toBe(false); + + await vi.advanceTimersByTimeAsync(250); + expect(onConnected).toHaveBeenCalledTimes(2); + expect(manager.post({ recovered: true })).toBe(true); + expect(second.messages).toContainEqual({ recovered: true }); + await vi.advanceTimersByTimeAsync(3_000); + expect(onDisconnectDeadline).not.toHaveBeenCalled(); + manager.stop(); + vi.useRealTimers(); + }); + + it("fires one fail-closed deadline while reconnect attempts remain bounded", async () => { + vi.useFakeTimers(); + const first = new FakePort(); + let attempts = 0; + const onDisconnectDeadline = vi.fn(); + const manager = new ResilientRuntimePort({ + connect: () => { + attempts += 1; + if (attempts === 1) { + return first; + } + throw new Error("worker unavailable"); + }, + onMessage: vi.fn(), + onConnected: vi.fn(), + onDisconnected: vi.fn(), + onDisconnectDeadline, + reconnectInitialMs: 100, + reconnectMaxMs: 400, + disconnectDeadlineMs: 1_000 + }); + + manager.start(); + first.emitDisconnect(); + await vi.advanceTimersByTimeAsync(1_000); + + expect(onDisconnectDeadline).toHaveBeenCalledTimes(1); + expect(attempts).toBeGreaterThan(1); + expect(attempts).toBeLessThanOrEqual(5); + await vi.advanceTimersByTimeAsync(2_000); + expect(onDisconnectDeadline).toHaveBeenCalledTimes(1); + manager.stop(); + vi.useRealTimers(); + }); +}); diff --git a/apps/extension/src/offscreen/runtime-port.ts b/apps/extension/src/offscreen/runtime-port.ts new file mode 100644 index 0000000..5d286b2 --- /dev/null +++ b/apps/extension/src/offscreen/runtime-port.ts @@ -0,0 +1,166 @@ +import type { PortLike } from "../shared/chrome-api.js"; + +export type ResilientRuntimePortOptions = { + connect: () => PortLike; + onMessage: (message: unknown) => void; + onConnected: () => void; + onDisconnected: () => void; + onDisconnectDeadline: () => void; + reconnectInitialMs: number; + reconnectMaxMs: number; + disconnectDeadlineMs: number; +}; + +type BoundPort = { + port: PortLike; + onMessage: (message: unknown) => void; + onDisconnect: () => void; +}; + +/** Keeps an offscreen document attached across service-worker restarts. */ +export class ResilientRuntimePort { + private bound: BoundPort | null = null; + + private reconnectTimer: ReturnType | null = null; + + private disconnectDeadlineTimer: ReturnType | null = null; + + private reconnectAttempts = 0; + + private stopped = true; + + public constructor(private readonly options: ResilientRuntimePortOptions) {} + + public start(): void { + if (!this.stopped) { + return; + } + + this.stopped = false; + this.connect(); + } + + public stop(): void { + this.stopped = true; + this.clearReconnectTimer(); + this.clearDisconnectDeadline(); + this.reconnectAttempts = 0; + + const bound = this.bound; + if (!bound) { + return; + } + + this.unbind(bound); + this.bound = null; + try { + bound.port.disconnect?.(); + } catch { + void 0; + } + } + + public post(message: unknown): boolean { + const bound = this.bound; + if (!bound) { + return false; + } + + try { + bound.port.postMessage(message); + return true; + } catch { + this.handleDisconnect(bound); + return false; + } + } + + private connect(): void { + if (this.stopped || this.bound) { + return; + } + + try { + const port = this.options.connect(); + const bound: BoundPort = { + port, + onMessage: (message) => this.options.onMessage(message), + onDisconnect: () => this.handleDisconnect(bound) + }; + port.onMessage.addListener(bound.onMessage); + port.onDisconnect.addListener(bound.onDisconnect); + this.bound = bound; + this.reconnectAttempts = 0; + this.clearReconnectTimer(); + this.clearDisconnectDeadline(); + this.options.onConnected(); + } catch { + this.scheduleReconnect(); + } + } + + private handleDisconnect(bound: BoundPort): void { + if (this.bound !== bound) { + return; + } + + this.unbind(bound); + this.bound = null; + this.options.onDisconnected(); + this.scheduleDisconnectDeadline(); + this.scheduleReconnect(); + } + + private unbind(bound: BoundPort): void { + bound.port.onMessage.removeListener(bound.onMessage); + bound.port.onDisconnect.removeListener(bound.onDisconnect); + } + + private scheduleReconnect(): void { + if (this.stopped || this.bound || this.reconnectTimer !== null) { + return; + } + + const exponent = Math.min(this.reconnectAttempts, 8); + const delay = Math.min( + this.options.reconnectInitialMs * 2 ** exponent, + this.options.reconnectMaxMs + ); + this.reconnectAttempts += 1; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private scheduleDisconnectDeadline(): void { + if (this.stopped || this.disconnectDeadlineTimer !== null) { + return; + } + + this.disconnectDeadlineTimer = setTimeout(() => { + this.disconnectDeadlineTimer = null; + if (!this.bound && !this.stopped) { + this.options.onDisconnectDeadline(); + } + }, this.options.disconnectDeadlineMs); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer === null) { + return; + } + + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + private clearDisconnectDeadline(): void { + if (this.disconnectDeadlineTimer === null) { + return; + } + + clearTimeout(this.disconnectDeadlineTimer); + this.disconnectDeadlineTimer = null; + } +} diff --git a/apps/extension/src/offscreen/screen-recording-port.test.ts b/apps/extension/src/offscreen/screen-recording-port.test.ts new file mode 100644 index 0000000..a2c874b --- /dev/null +++ b/apps/extension/src/offscreen/screen-recording-port.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + pauseScreenRecordingForPortDisconnect, + resumeScreenRecordingAfterPortReconnect, + type PortBoundScreenRecording +} from "./screen-recording-port.js"; + +function createRecording(): PortBoundScreenRecording & { + recorder: PortBoundScreenRecording["recorder"] & { state: RecordingState }; +} { + const tracks = [{ enabled: true }, { enabled: true }] as MediaStreamTrack[]; + const recorder = { + state: "recording" as RecordingState, + pause: vi.fn(() => { + recorder.state = "paused"; + }), + resume: vi.fn(() => { + recorder.state = "recording"; + }) + }; + + return { + recorder, + stream: { + getTracks: () => tracks + }, + stopping: false, + pausedForPortDisconnect: false + }; +} + +describe("screen recording port lifecycle", () => { + it("disables tracks and pauses until the worker port reconnects", () => { + const recording = createRecording(); + + expect(pauseScreenRecordingForPortDisconnect(recording)).toBe(true); + expect(recording.recorder.pause).toHaveBeenCalledOnce(); + expect(recording.stream.getTracks().every((track) => !track.enabled)).toBe(true); + expect(resumeScreenRecordingAfterPortReconnect(recording)).toBe(true); + expect(recording.recorder.resume).toHaveBeenCalledOnce(); + expect(recording.stream.getTracks().every((track) => track.enabled)).toBe(true); + }); + + it("fails closed when an inactive recorder cannot be suspended", () => { + const recording = createRecording(); + recording.recorder.state = "inactive"; + + expect(pauseScreenRecordingForPortDisconnect(recording)).toBe(false); + expect(recording.stream.getTracks().every((track) => !track.enabled)).toBe(true); + }); +}); diff --git a/apps/extension/src/offscreen/screen-recording-port.ts b/apps/extension/src/offscreen/screen-recording-port.ts new file mode 100644 index 0000000..30294ef --- /dev/null +++ b/apps/extension/src/offscreen/screen-recording-port.ts @@ -0,0 +1,55 @@ +export type PortBoundScreenRecording = { + recorder: Pick; + stream: Pick; + stopping: boolean; + pausedForPortDisconnect: boolean; +}; + +/** Suspends sensitive media production while the service worker cannot receive chunks. */ +export function pauseScreenRecordingForPortDisconnect( + recording: PortBoundScreenRecording +): boolean { + if (recording.stopping) { + return true; + } + + for (const track of recording.stream.getTracks()) { + track.enabled = false; + } + + try { + if (recording.recorder.state === "recording") { + recording.recorder.pause(); + } else if (recording.recorder.state !== "paused") { + return false; + } + recording.pausedForPortDisconnect = true; + return true; + } catch { + return false; + } +} + +/** Resumes only recordings that this disconnect path suspended. */ +export function resumeScreenRecordingAfterPortReconnect( + recording: PortBoundScreenRecording +): boolean { + if (!recording.pausedForPortDisconnect || recording.stopping) { + return true; + } + + try { + if (recording.recorder.state === "paused") { + recording.recorder.resume(); + } else if (recording.recorder.state !== "recording") { + return false; + } + for (const track of recording.stream.getTracks()) { + track.enabled = true; + } + recording.pausedForPortDisconnect = false; + return true; + } catch { + return false; + } +} From 879d6ae21d15f97cab6ec5d181b5d4647114432f Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:42:14 +0800 Subject: [PATCH 094/181] fix(share): drain physical audit writes --- apps/share-server/src/audit-writer.test.ts | 55 ++++++++++++++++++++++ apps/share-server/src/audit-writer.ts | 13 ++++- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/share-server/src/audit-writer.test.ts b/apps/share-server/src/audit-writer.test.ts index b3d282d..9805b5d 100644 --- a/apps/share-server/src/audit-writer.test.ts +++ b/apps/share-server/src/audit-writer.test.ts @@ -109,7 +109,16 @@ describe("BoundedAuditWriter", () => { }); expect(appendCalls).toBe(1); + let drained = false; + const drain = writer.drain().then(() => { + drained = true; + }); + await delay(5); + expect(drained).toBe(false); + releaseHungWrite?.(); + await drain; + expect(drained).toBe(true); await delay(25); await expect(writer.write("recovery-probe\n")).resolves.toEqual({ status: "written", @@ -121,6 +130,52 @@ describe("BoundedAuditWriter", () => { }); expect(appendCalls).toBe(3); }); + + it("drains every late-settling physical append across recovery attempts", async () => { + const pendingWrites: Array<() => void> = []; + const writer = new BoundedAuditWriter({ + append: () => + new Promise((resolve) => { + pendingWrites.push(resolve); + }), + maxQueuedEntries: 1, + queueWaitTimeoutMs: 100, + writeTimeoutMs: 10, + retryCooldownMs: 10 + }); + + await expect(writer.write("first-timeout\n")).resolves.toEqual({ + status: "failed", + reason: "write-timeout" + }); + expect(pendingWrites).toHaveLength(1); + + let firstDrainSettled = false; + const firstDrain = writer.drain().then(() => { + firstDrainSettled = true; + }); + await delay(5); + expect(firstDrainSettled).toBe(false); + pendingWrites.shift()?.(); + await firstDrain; + + await delay(15); + await expect(writer.write("recovery-timeout\n")).resolves.toEqual({ + status: "failed", + reason: "write-timeout" + }); + expect(pendingWrites).toHaveLength(1); + + let secondDrainSettled = false; + const secondDrain = writer.drain().then(() => { + secondDrainSettled = true; + }); + await delay(5); + expect(secondDrainSettled).toBe(false); + pendingWrites.shift()?.(); + await secondDrain; + expect(secondDrainSettled).toBe(true); + }); }); async function waitFor(predicate: () => boolean): Promise { diff --git a/apps/share-server/src/audit-writer.ts b/apps/share-server/src/audit-writer.ts index 3c72a09..d10ec04 100644 --- a/apps/share-server/src/audit-writer.ts +++ b/apps/share-server/src/audit-writer.ts @@ -35,6 +35,7 @@ export class BoundedAuditWriter { private readonly queueWaitTimeoutMs: number; private readonly writeTimeoutMs: number; private readonly retryCooldownMs: number; + private readonly pendingPhysicalWrites = new Set>(); private tail: Promise = Promise.resolve(); private queuedEntries = 0; private activePhysicalWrites = 0; @@ -95,7 +96,15 @@ export class BoundedAuditWriter { } async drain(): Promise { - await this.tail; + while (true) { + await this.tail; + const pendingPhysicalWrites = [...this.pendingPhysicalWrites]; + if (pendingPhysicalWrites.length === 0) { + return; + } + + await Promise.allSettled(pendingPhysicalWrites); + } } private tryReserveCircuitProbe(now: number): boolean | null { @@ -155,7 +164,9 @@ export class BoundedAuditWriter { ) .finally(() => { this.activePhysicalWrites -= 1; + this.pendingPhysicalWrites.delete(physicalWrite); }); + this.pendingPhysicalWrites.add(physicalWrite); let timeout: ReturnType | undefined; const timedWrite = new Promise<{ status: "timeout" }>((resolve) => { From e4fdcadef4a6645357172f3be4999c5135f5b478 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:43:32 +0800 Subject: [PATCH 095/181] fix(player): bound archive input buffering --- apps/player/src/lib/archive-input.test.ts | 156 ++++++++++++++++++ apps/player/src/lib/archive-input.ts | 185 ++++++++++++++++++++++ apps/player/src/main.ts | 7 +- 3 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 apps/player/src/lib/archive-input.test.ts create mode 100644 apps/player/src/lib/archive-input.ts diff --git a/apps/player/src/lib/archive-input.test.ts b/apps/player/src/lib/archive-input.test.ts new file mode 100644 index 0000000..97fbc14 --- /dev/null +++ b/apps/player/src/lib/archive-input.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + ArchiveInputLimitError, + readArchiveBlobBounded, + readArchiveResponseBounded +} from "./archive-input.js"; + +describe("bounded Player archive input", () => { + it("accepts an exact-size Blob and rejects an oversized Blob before arrayBuffer", async () => { + const exactRead = vi.fn(async () => new Uint8Array([1, 2, 3, 4]).buffer); + await expect(readArchiveBlobBounded({ size: 4, arrayBuffer: exactRead }, 4)).resolves.toEqual( + new Uint8Array([1, 2, 3, 4]) + ); + expect(exactRead).toHaveBeenCalledOnce(); + + const oversizedRead = vi.fn(async () => new Uint8Array(5).buffer); + await expect( + readArchiveBlobBounded({ size: 5, arrayBuffer: oversizedRead }, 4) + ).rejects.toMatchObject({ + name: "ArchiveInputLimitError", + actualBytes: 5, + maxBytes: 4 + }); + expect(oversizedRead).not.toHaveBeenCalled(); + }); + + it("streams an exact Content-Length boundary", async () => { + const response = archiveResponse([new Uint8Array([1, 2]), new Uint8Array([3, 4])], "4"); + + await expect(readArchiveResponseBounded(response, 4)).resolves.toEqual( + new Uint8Array([1, 2, 3, 4]) + ); + }); + + it.each(["-1", "01", "1.5", "1, 1", "9007199254740992"])( + "rejects invalid Content-Length %s", + async (contentLength) => { + const response = archiveResponse([new Uint8Array([1])], contentLength); + await expect(readArchiveResponseBounded(response, 4)).rejects.toThrow( + /invalid Content-Length/i + ); + } + ); + + it("rejects an oversized Content-Length before reading", async () => { + const getReader = vi.fn(); + const cancel = vi.fn(async () => undefined); + const body = { getReader, cancel } as unknown as ReadableStream>; + const response = { + headers: new Headers({ "content-length": "5" }), + body + }; + + await expect(readArchiveResponseBounded(response, 4)).rejects.toMatchObject({ + name: "ArchiveInputLimitError", + actualBytes: 5, + maxBytes: 4 + }); + expect(getReader).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("cancels a forged short Content-Length stream that exceeds the hard limit", async () => { + let canceled = false; + const body = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2])); + controller.enqueue(new Uint8Array([3, 4, 5])); + }, + cancel() { + canceled = true; + } + }); + const response = { + headers: new Headers({ "content-length": "2" }), + body + }; + + await expect(readArchiveResponseBounded(response, 4)).rejects.toMatchObject({ + name: "ArchiveInputLimitError", + actualBytes: 5, + maxBytes: 4 + }); + expect(canceled).toBe(true); + }); + + it("accepts a bounded stream without Content-Length and cancels it on overflow", async () => { + await expect( + readArchiveResponseBounded( + archiveResponse([new Uint8Array([1, 2]), new Uint8Array([3, 4])]), + 4 + ) + ).resolves.toEqual(new Uint8Array([1, 2, 3, 4])); + + let canceled = false; + const body = new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])); + controller.enqueue(new Uint8Array([4, 5])); + }, + cancel() { + canceled = true; + } + }); + await expect( + readArchiveResponseBounded({ headers: new Headers(), body }, 4) + ).rejects.toBeInstanceOf(ArchiveInputLimitError); + expect(canceled).toBe(true); + }); + + it("fails closed for missing, truncated, and errored response bodies", async () => { + await expect( + readArchiveResponseBounded( + { + headers: new Headers({ "content-length": "0" }), + body: null + }, + 4 + ) + ).rejects.toThrow(/no readable body/i); + + await expect( + readArchiveResponseBounded(archiveResponse([new Uint8Array([1, 2])], "3"), 4) + ).rejects.toThrow(/Content-Length mismatch/i); + + const errored = new ReadableStream>({ + start(controller) { + controller.error(new Error("stream failed")); + } + }); + await expect( + readArchiveResponseBounded({ headers: new Headers(), body: errored }, 4) + ).rejects.toThrow("stream failed"); + }); +}); + +function archiveResponse( + chunks: Array>, + contentLength?: string +): { + headers: Headers; + body: ReadableStream>; +} { + return { + headers: new Headers(contentLength === undefined ? {} : { "content-length": contentLength }), + body: new ReadableStream>({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + } + }) + }; +} diff --git a/apps/player/src/lib/archive-input.ts b/apps/player/src/lib/archive-input.ts new file mode 100644 index 0000000..409ce59 --- /dev/null +++ b/apps/player/src/lib/archive-input.ts @@ -0,0 +1,185 @@ +import { DEFAULT_ARCHIVE_RESOURCE_LIMITS } from "@webblackbox/player-sdk"; + +export const MAX_PLAYER_ARCHIVE_INPUT_BYTES = DEFAULT_ARCHIVE_RESOURCE_LIMITS.maxInputBytes; + +export class ArchiveInputLimitError extends Error { + public override readonly name = "ArchiveInputLimitError"; + + public constructor( + public readonly actualBytes: number, + public readonly maxBytes: number + ) { + super(`Archive input exceeds the Player limit (${actualBytes} > ${maxBytes} bytes).`); + } +} + +type ArchiveBlobInput = Pick; + +type ArchiveByteChunk = Uint8Array; + +type ArchiveResponseInput = { + body: ReadableStream | null; + headers: Pick; +}; + +export async function readArchiveBlobBounded( + input: ArchiveBlobInput, + maxBytes = MAX_PLAYER_ARCHIVE_INPUT_BYTES +): Promise { + assertMaxBytes(maxBytes); + assertObservedBytes(input.size, maxBytes, "Blob size"); + + const bytes = new Uint8Array(await input.arrayBuffer()); + assertObservedBytes(bytes.byteLength, maxBytes, "Blob contents"); + if (bytes.byteLength !== input.size) { + throw new Error( + `Archive Blob size changed while being read (${bytes.byteLength} bytes read; expected ${input.size}).` + ); + } + return bytes; +} + +export async function readArchiveResponseBounded( + response: ArchiveResponseInput, + maxBytes = MAX_PLAYER_ARCHIVE_INPUT_BYTES +): Promise { + assertMaxBytes(maxBytes); + const body = response.body; + let declaredBytes: number | null; + try { + declaredBytes = parseContentLength(response.headers.get("content-length"), maxBytes); + } catch (error) { + if (body) { + await cancelStream(body, error); + } + throw error; + } + if (!body) { + throw new Error("Archive response has no readable body."); + } + + const reader = body.getReader(); + const accumulator = new BoundedByteAccumulator(maxBytes, declaredBytes); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!(value instanceof Uint8Array)) { + throw new Error("Archive response stream produced a non-byte chunk."); + } + accumulator.append(value); + } + + return accumulator.finish(); + } catch (error) { + await cancelReader(reader, error); + throw error; + } finally { + reader.releaseLock(); + } +} + +function parseContentLength(value: string | null, maxBytes: number): number | null { + if (value === null) { + return null; + } + if (!/^(?:0|[1-9]\d*)$/.test(value)) { + throw new Error(`Archive response has an invalid Content-Length header: ${value}`); + } + + const declaredBytes = Number(value); + if (!Number.isSafeInteger(declaredBytes)) { + throw new Error(`Archive response has an invalid Content-Length header: ${value}`); + } + if (declaredBytes > maxBytes) { + throw new ArchiveInputLimitError(declaredBytes, maxBytes); + } + return declaredBytes; +} + +class BoundedByteAccumulator { + private output: Uint8Array; + + private length = 0; + + public constructor( + private readonly maxBytes: number, + private readonly declaredBytes: number | null + ) { + this.output = new Uint8Array(0); + } + + public append(chunk: ArchiveByteChunk): void { + const nextLength = this.length + chunk.byteLength; + if (!Number.isSafeInteger(nextLength) || nextLength > this.maxBytes) { + throw new ArchiveInputLimitError(nextLength, this.maxBytes); + } + if (this.declaredBytes !== null && nextLength > this.declaredBytes) { + throw new Error( + `Archive response exceeded its Content-Length (${nextLength} > ${this.declaredBytes} bytes).` + ); + } + + if (nextLength > this.output.byteLength) { + const grownCapacity = Math.min( + this.maxBytes, + Math.max(nextLength, Math.ceil(Math.max(1, this.output.byteLength) * 1.5)) + ); + const grown = new Uint8Array(grownCapacity); + grown.set(this.output.subarray(0, this.length)); + this.output = grown; + } + + this.output.set(chunk, this.length); + this.length = nextLength; + } + + public finish(): Uint8Array { + if (this.declaredBytes !== null && this.length !== this.declaredBytes) { + throw new Error( + `Archive response Content-Length mismatch (${this.length} bytes read; expected ${this.declaredBytes}).` + ); + } + return this.output.byteLength === this.length ? this.output : this.output.slice(0, this.length); + } +} + +function assertMaxBytes(maxBytes: number): void { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new TypeError("Archive input limit must be a positive safe integer."); + } +} + +function assertObservedBytes(actualBytes: number, maxBytes: number, detail: string): void { + if (!Number.isSafeInteger(actualBytes) || actualBytes < 0) { + throw new Error(`Archive ${detail} is not a valid byte length.`); + } + if (actualBytes > maxBytes) { + throw new ArchiveInputLimitError(actualBytes, maxBytes); + } +} + +async function cancelReader( + reader: ReadableStreamDefaultReader, + reason: unknown +): Promise { + try { + await reader.cancel(reason); + } catch { + // Preserve the original validation or stream failure. + } +} + +async function cancelStream( + stream: ReadableStream, + reason: unknown +): Promise { + try { + await stream.cancel(reason); + } catch { + // Preserve the Content-Length validation failure. + } +} diff --git a/apps/player/src/main.ts b/apps/player/src/main.ts index 607a7bb..6868c9c 100644 --- a/apps/player/src/main.ts +++ b/apps/player/src/main.ts @@ -15,6 +15,7 @@ import { createRoot } from "react-dom/client"; import { hasFilePayload, pickArchiveFile } from "./lib/archive-files.js"; import { hasPlaybackEvents } from "./lib/archive-health.js"; +import { readArchiveBlobBounded, readArchiveResponseBounded } from "./lib/archive-input.js"; import { toArrayBuffer } from "./lib/binary.js"; import { formatCompareSummary } from "./lib/compare-summary.js"; import { escapeHtml, getElement } from "./lib/dom.js"; @@ -1557,7 +1558,7 @@ async function loadPrimaryArchiveFile(file: File): Promise { const loadToken = primaryArchiveLoadGate.begin(); try { - const bytes = new Uint8Array(await file.arrayBuffer()); + const bytes = await readArchiveBlobBounded(file); await loadPrimaryArchiveBytes(bytes, file.name, loadToken); } catch (error) { if (primaryArchiveLoadGate.isCurrent(loadToken)) { @@ -1642,7 +1643,7 @@ async function handleCompareArchiveChange(): Promise { } try { - const bytes = new Uint8Array(await file.arrayBuffer()); + const bytes = await readArchiveBlobBounded(file); const comparePlayer = await openArchiveWithPassphraseFallback(bytes, file.name); if (!compareArchiveLoadGate.isCurrent(loadToken)) { @@ -2023,7 +2024,7 @@ async function loadArchiveFromShareReference( throw new Error(message || `HTTP ${response.status}`); } - const bytes = new Uint8Array(await response.arrayBuffer()); + const bytes = await readArchiveResponseBounded(response); const loaded = await loadPrimaryArchiveBytes( bytes, `shared-${resolved.shareId}.webblackbox`, From 43e405420d382889239c0619b43fd35fe36ac7a6 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:46:21 +0800 Subject: [PATCH 096/181] fix(extension): authorize recorder reconnects --- apps/extension/src/offscreen/index.ts | 65 +++++++++++++++---- .../offscreen/screen-recording-port.test.ts | 37 +++++++++++ .../src/offscreen/screen-recording-port.ts | 52 +++++++++++++++ 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/apps/extension/src/offscreen/index.ts b/apps/extension/src/offscreen/index.ts index 8065e1a..46b06b5 100644 --- a/apps/extension/src/offscreen/index.ts +++ b/apps/extension/src/offscreen/index.ts @@ -13,8 +13,9 @@ import { PORT_NAMES } from "../shared/messages.js"; import { getExtensionPipelineStorage } from "./pipeline-storage.js"; import { ResilientRuntimePort } from "./runtime-port.js"; import { + parseActiveScreenRecordingSessionIds, pauseScreenRecordingForPortDisconnect, - resumeScreenRecordingAfterPortReconnect + reconcileScreenRecordingsWithWorkerSessions } from "./screen-recording-port.js"; type OffscreenPipelineRequest = { @@ -135,6 +136,7 @@ const state: OffscreenState = { }; let keepaliveTimer: ReturnType | null = null; +let screenRecordingHandshakeTimer: ReturnType | null = null; let runtimePort: ResilientRuntimePort | null = null; console.info("[WebBlackbox] offscreen pipeline initialized"); @@ -152,11 +154,17 @@ function handleRuntimePortMessage(message: unknown): void { if (kind === "sw.pipeline-status") { const activeSessions = (message as { activeSessions?: unknown }).activeSessions; + const activeScreenRecordingSessionIds = parseActiveScreenRecordingSessionIds( + (message as { sessions?: unknown }).sessions + ); const updatedAt = (message as { updatedAt?: unknown }).updatedAt; state.activeSessions = typeof activeSessions === "number" && Number.isFinite(activeSessions) ? activeSessions : 0; state.updatedAt = typeof updatedAt === "number" ? updatedAt : Date.now(); + if (activeScreenRecordingSessionIds) { + reconcileScreenRecordingsAfterWorkerHandshake(activeScreenRecordingSessionIds); + } syncServiceWorkerKeepalive(); console.info("[WebBlackbox] offscreen pipeline status", { @@ -175,6 +183,7 @@ function handleRuntimePortMessage(message: unknown): void { function handleRuntimePortDisconnect(): void { stopServiceWorkerKeepalive(); + let needsHandshake = false; for (const recording of screenRecordings.values()) { if (!pauseScreenRecordingForPortDisconnect(recording)) { void stopOffscreenScreenRecording( @@ -183,22 +192,16 @@ function handleRuntimePortDisconnect(): void { "service-worker-disconnected", false ); + } else if (!recording.stopping) { + needsHandshake = true; } } + if (needsHandshake) { + startScreenRecordingHandshakeDeadline(); + } } function handleRuntimePortReconnect(): void { - for (const recording of screenRecordings.values()) { - if (!resumeScreenRecordingAfterPortReconnect(recording)) { - void stopOffscreenScreenRecording( - recording.sid, - recording.recordingId, - "service-worker-reconnect-failed", - true - ); - } - } - postToSw({ kind: "offscreen.ready", t: Date.now() @@ -206,7 +209,41 @@ function handleRuntimePortReconnect(): void { syncServiceWorkerKeepalive(); } +function reconcileScreenRecordingsAfterWorkerHandshake( + activeSessionIds: ReadonlySet +): void { + clearScreenRecordingHandshakeDeadline(); + const failures = reconcileScreenRecordingsWithWorkerSessions( + screenRecordings.values(), + activeSessionIds + ); + for (const { recording, reason } of failures) { + void stopOffscreenScreenRecording(recording.sid, recording.recordingId, reason, true); + } +} + +function startScreenRecordingHandshakeDeadline(): void { + if (screenRecordingHandshakeTimer !== null) { + return; + } + + screenRecordingHandshakeTimer = setTimeout(() => { + screenRecordingHandshakeTimer = null; + handleRuntimePortDisconnectDeadline(); + }, OFFSCREEN_PORT_DISCONNECT_DEADLINE_MS); +} + +function clearScreenRecordingHandshakeDeadline(): void { + if (screenRecordingHandshakeTimer === null) { + return; + } + + clearTimeout(screenRecordingHandshakeTimer); + screenRecordingHandshakeTimer = null; +} + function handleRuntimePortDisconnectDeadline(): void { + clearScreenRecordingHandshakeDeadline(); for (const recording of screenRecordings.values()) { void stopOffscreenScreenRecording( recording.sid, @@ -668,6 +705,10 @@ function cleanupOffscreenScreenRecording(recording: OffscreenScreenRecordingStat screenRecordings.delete(recording.sid); } + if (screenRecordings.size === 0) { + clearScreenRecordingHandshakeDeadline(); + } + syncServiceWorkerKeepalive(); } diff --git a/apps/extension/src/offscreen/screen-recording-port.test.ts b/apps/extension/src/offscreen/screen-recording-port.test.ts index a2c874b..a2ca3a4 100644 --- a/apps/extension/src/offscreen/screen-recording-port.test.ts +++ b/apps/extension/src/offscreen/screen-recording-port.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest"; import { + parseActiveScreenRecordingSessionIds, pauseScreenRecordingForPortDisconnect, + reconcileScreenRecordingsWithWorkerSessions, resumeScreenRecordingAfterPortReconnect, type PortBoundScreenRecording } from "./screen-recording-port.js"; @@ -31,6 +33,18 @@ function createRecording(): PortBoundScreenRecording & { } describe("screen recording port lifecycle", () => { + it("accepts only an authoritative bounded list of active session ids", () => { + expect( + parseActiveScreenRecordingSessionIds([ + { sid: "S-active-1", active: true, tabId: 1 }, + { sid: "S-active-2", active: true, tabId: 2 } + ]) + ).toEqual(new Set(["S-active-1", "S-active-2"])); + expect(parseActiveScreenRecordingSessionIds([{ sid: "S-stopped", active: false }])).toBeNull(); + expect(parseActiveScreenRecordingSessionIds([{ sid: "", active: true }])).toBeNull(); + expect(parseActiveScreenRecordingSessionIds({ sid: "S-not-an-array" })).toBeNull(); + }); + it("disables tracks and pauses until the worker port reconnects", () => { const recording = createRecording(); @@ -49,4 +63,27 @@ describe("screen recording port lifecycle", () => { expect(pauseScreenRecordingForPortDisconnect(recording)).toBe(false); expect(recording.stream.getTracks().every((track) => !track.enabled)).toBe(true); }); + + it("resumes only recordings claimed by the restored worker handshake", () => { + const claimed = { ...createRecording(), sid: "S-claimed" }; + const unclaimed = { ...createRecording(), sid: "S-unclaimed" }; + pauseScreenRecordingForPortDisconnect(claimed); + pauseScreenRecordingForPortDisconnect(unclaimed); + + const failures = reconcileScreenRecordingsWithWorkerSessions( + [claimed, unclaimed], + new Set(["S-claimed"]) + ); + + expect(claimed.recorder.resume).toHaveBeenCalledOnce(); + expect(claimed.stream.getTracks().every((track) => track.enabled)).toBe(true); + expect(unclaimed.recorder.resume).not.toHaveBeenCalled(); + expect(unclaimed.stream.getTracks().every((track) => !track.enabled)).toBe(true); + expect(failures).toEqual([ + { + recording: unclaimed, + reason: "service-worker-session-not-active" + } + ]); + }); }); diff --git a/apps/extension/src/offscreen/screen-recording-port.ts b/apps/extension/src/offscreen/screen-recording-port.ts index 30294ef..c146ab3 100644 --- a/apps/extension/src/offscreen/screen-recording-port.ts +++ b/apps/extension/src/offscreen/screen-recording-port.ts @@ -5,6 +5,58 @@ export type PortBoundScreenRecording = { pausedForPortDisconnect: boolean; }; +export type SessionBoundScreenRecording = PortBoundScreenRecording & { sid: string }; + +export type ScreenRecordingHandshakeFailure = { + recording: TRecording; + reason: "service-worker-session-not-active" | "service-worker-reconnect-failed"; +}; + +/** Parses the authoritative active-session list sent by the current service worker. */ +export function parseActiveScreenRecordingSessionIds(input: unknown): ReadonlySet | null { + if (!Array.isArray(input) || input.length > 10_000) { + return null; + } + + const activeSessionIds = new Set(); + for (const item of input) { + if (item === null || typeof item !== "object" || Array.isArray(item)) { + return null; + } + + const record = item as Record; + if (typeof record.sid !== "string" || record.sid.length === 0 || record.sid.length > 256) { + return null; + } + if (record.active !== true) { + return null; + } + activeSessionIds.add(record.sid); + } + + return activeSessionIds; +} + +/** Resumes only recordings claimed by the restored worker and reports every fail-closed stop. */ +export function reconcileScreenRecordingsWithWorkerSessions< + TRecording extends SessionBoundScreenRecording +>( + recordings: Iterable, + activeSessionIds: ReadonlySet +): ScreenRecordingHandshakeFailure[] { + const failures: ScreenRecordingHandshakeFailure[] = []; + for (const recording of recordings) { + if (!activeSessionIds.has(recording.sid)) { + failures.push({ recording, reason: "service-worker-session-not-active" }); + continue; + } + if (!resumeScreenRecordingAfterPortReconnect(recording)) { + failures.push({ recording, reason: "service-worker-reconnect-failed" }); + } + } + return failures; +} + /** Suspends sensitive media production while the service worker cannot receive chunks. */ export function pauseScreenRecordingForPortDisconnect( recording: PortBoundScreenRecording From c6b865b49172ea3b2a598ada76e9d31749097a5e Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:48:48 +0800 Subject: [PATCH 097/181] fix(share): prune retained expirations before hashing --- apps/share-server/src/index.test.ts | 64 ++++++++++++++++++++++++++++- apps/share-server/src/index.ts | 19 ++++++--- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/apps/share-server/src/index.test.ts b/apps/share-server/src/index.test.ts index 66d5d13..a9d2d13 100644 --- a/apps/share-server/src/index.test.ts +++ b/apps/share-server/src/index.test.ts @@ -19,7 +19,7 @@ import { request as createHttpRequest } from "node:http"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import JSZip from "jszip"; import { afterEach, describe, expect, it } from "vitest"; @@ -1591,6 +1591,68 @@ describe("share-server", () => { } ); + it("prunes retention-expired archives before reading them during startup reconciliation", async () => { + const retentionConfiguration = { + WEBBLACKBOX_SHARE_DEFAULT_TTL_MS: "1000", + WEBBLACKBOX_SHARE_MAX_TTL_MS: "1000", + WEBBLACKBOX_SHARE_RETAIN_EXPIRED_MS: "0" + }; + const server = await startShareServer(retentionConfiguration); + const upload = await uploadEncryptedFixture(server); + const recordPath = resolve(server.dataDir, "records", `${upload.shareId}.json`); + const archivePath = resolve(server.dataDir, "archives", `${upload.shareId}.webblackbox`); + const record = JSON.parse(await readFile(recordPath, "utf8")) as { expiresAt?: unknown }; + const expiresAt = record.expiresAt; + if (typeof expiresAt !== "number") { + throw new Error("Expected the uploaded Share record to have an expiry timestamp."); + } + + await stopShareServer(server, false); + await new Promise((resolvePromise) => + setTimeout(resolvePromise, Math.max(0, expiresAt - Date.now() + 25)) + ); + + const readGuardPath = resolve(server.dataDir, "deny-expired-archive-read.mjs"); + await writeFile( + readGuardPath, + `import fileSystemPromises from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; + +const originalOpen = fileSystemPromises.open; +fileSystemPromises.open = async (...arguments_) => { + const handle = await originalOpen(...arguments_); + if (String(arguments_[0]) === process.env.WEBBLACKBOX_TEST_DENY_ARCHIVE_READ_PATH) { + handle.read = async () => { + const error = new Error("Expired archive validation read was attempted."); + error.code = "EIO"; + throw error; + }; + } + return handle; +}; +syncBuiltinESMExports(); +` + ); + const inheritedNodeOptions = process.env.NODE_OPTIONS?.trim(); + const restarted = await startShareServer( + { + ...retentionConfiguration, + NODE_OPTIONS: [inheritedNodeOptions, `--import=${pathToFileURL(readGuardPath).href}`] + .filter((value): value is string => Boolean(value)) + .join(" "), + WEBBLACKBOX_TEST_DENY_ARCHIVE_READ_PATH: archivePath + }, + server.dataDir + ); + + await expect(stat(recordPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(stat(archivePath)).rejects.toMatchObject({ code: "ENOENT" }); + const listResponse = await fetch(`${restarted.baseUrl}/api/share/list`, { + headers: { "x-webblackbox-api-key": apiKey } + }); + await expect(listResponse.json()).resolves.toMatchObject({ items: [], total: 0 }); + }); + it("does not expose stale metadata or downloads after archives change at runtime", async () => { const server = await startShareServer(); const truncatedUpload = await uploadEncryptedFixture(server); diff --git a/apps/share-server/src/index.ts b/apps/share-server/src/index.ts index dd15368..b105ce7 100644 --- a/apps/share-server/src/index.ts +++ b/apps/share-server/src/index.ts @@ -348,7 +348,8 @@ async function startShareServer(): Promise { configuredAuditHmacSecret ?? (await loadOrCreatePersistedAuditHmacSecret()); await migrateLegacyAuditLogs(); await reconcileAuditLogLayout(); - await reconcileStorageLayout(); + const startupNow = Date.now(); + await reconcileStorageLayout(startupNow); await pruneExpiredShareRecords(Date.now()); const activeRequests = new Set>(); @@ -2625,7 +2626,7 @@ function unsafeStoragePathError( }); } -async function reconcileStorageLayout(): Promise { +async function reconcileStorageLayout(startupNow: number): Promise { await assertManagedStorageLayout(); resetShareRecordIndex(); const archiveIds = await collectStoredIds(ARCHIVES_DIR, ".webblackbox", ".upload"); @@ -2638,9 +2639,17 @@ async function reconcileStorageLayout(): Promise { for (const id of recordIds) { if (archiveIds.has(id)) { const record = await readRecord(id); - if (record && (await hasValidStoredArchive(record))) { - indexEntries.push(createShareRecordIndexEntry(record)); - continue; + if (record) { + const indexEntry = createShareRecordIndexEntry(record); + if (indexEntry.retentionDeadline <= startupNow) { + await removeStoredShare(id); + archiveIds.delete(id); + continue; + } + if (await hasValidStoredArchive(record)) { + indexEntries.push(indexEntry); + continue; + } } } From 46dfbaf2e102eacea82018a7fd113bfdf329bbe7 Mon Sep 17 00:00:00 2001 From: unadlib Date: Sat, 11 Jul 2026 04:49:10 +0800 Subject: [PATCH 098/181] docs(release): document lockstep versioning --- CONTRIBUTING.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c36a391..896838a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,18 +45,18 @@ webblackbox/ ## Common Commands -| Command | What it does | -| ----------------------- | ---------------------------------------------------- | -| `pnpm build` | Build the whole workspace | -| `pnpm dev` | Run workspace watch tasks | -| `pnpm test` | Run workspace tests | -| `pnpm lint` | Run ESLint across packages | -| `pnpm typecheck` | Run TypeScript checks | -| `pnpm format` | Format the repo with Prettier | -| `pnpm format:check` | Verify formatting | -| `pnpm changeset` | Create a release changeset | -| `pnpm version-packages` | Apply changesets and sync extension manifest version | -| `pnpm release` | Publish npm packages via Changesets | +| Command | What it does | +| ----------------------- | -------------------------------------------- | +| `pnpm build` | Build the whole workspace | +| `pnpm dev` | Run workspace watch tasks | +| `pnpm test` | Run workspace tests | +| `pnpm lint` | Run ESLint across packages | +| `pnpm typecheck` | Run TypeScript checks | +| `pnpm format` | Format the repo with Prettier | +| `pnpm format:check` | Verify formatting | +| `pnpm changeset` | Create a release changeset | +| `pnpm version-packages` | Apply the lockstep workspace release version | +| `pnpm release` | Publish npm packages via Changesets | Use `pnpm --filter