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 + } } 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 155b32a..94c8f4b 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 @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Dependency audit + run: pnpm audit --audit-level=high + - name: Format Check run: pnpm format:check @@ -39,6 +42,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: API Documentation Drift Check + run: pnpm docs:api:check + - name: Test run: pnpm test @@ -46,7 +52,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 @@ -94,13 +100,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 @@ -109,7 +115,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 a93001d..7353c87 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -11,26 +11,83 @@ on: required: true type: string -permissions: - contents: write +permissions: {} + +concurrency: + group: release-assets-stable + queue: max + cancel-in-progress: false jobs: - chrome-extension: + verify-release: runs-on: ubuntu-latest + outputs: + verified_sha: ${{ steps.verified_source.outputs.sha }} + 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@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 Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + + - name: Verify immutable release source + run: | + git fetch --force --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: Export verified release commit + id: verified_source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + 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: + - name: Checkout verified release commit + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + ref: ${{ needs.verify-release.outputs.verified_sha }} + - 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 @@ -38,13 +95,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 @@ -76,21 +136,24 @@ 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/ steps: - - name: Checkout released ref - uses: actions/checkout@v4 + - name: Checkout verified release commit + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + ref: ${{ needs.verify-release.outputs.verified_sha }} - 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..4773d7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,59 +6,93 @@ 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-stable-publish + queue: max 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@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 }} + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + + - name: Verify immutable release source + run: | + git fetch --force --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@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 - 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 + - name: Production dependency audit + run: pnpm audit --prod --audit-level=high + + - name: Verify source quality + run: | + pnpm format:check + pnpm lint + pnpm typecheck + pnpm docs:api:check + pnpm test + - name: Build workspace run: pnpm build + - name: Verify bundle budgets + run: pnpm bundle:size + - name: Verify publish artifacts - run: > - pnpm - --filter @webblackbox/mcp-server - --filter @webblackbox/cdp-router - --filter @webblackbox/pipeline - --filter @webblackbox/player-sdk - --filter @webblackbox/protocol - --filter @webblackbox/recorder - --filter webblackbox - exec npm pack --dry-run --json + run: pnpm release:verify-artifacts - name: Publish packages to npm env: @@ -71,5 +105,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/.npmrc b/.npmrc deleted file mode 100644 index ef60a1b..0000000 --- a/.npmrc +++ /dev/null @@ -1,3 +0,0 @@ -auto-install-peers=true -strict-peer-dependencies=false -shared-workspace-lockfile=true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64eb82b..dce80c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ This guide is for working on the monorepo itself: building apps locally, running ## Prerequisites - Node.js `>= 22.0.0` -- pnpm `10.28.1` +- pnpm `11.13.1` ## Setup @@ -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
+
@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..3682d1f --- /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..1404cc8 100644 --- a/docs/api/player-sdk/classes/WebBlackboxPlayer.html +++ b/docs/api/player-sdk/classes/WebBlackboxPlayer.html @@ -1,15 +1,49 @@ -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.

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

              Reads and verifies one blob without retaining it in the Player cache.

              +
              +
              +

              Parameters

              +
                +
              • hash: string
              • +
              • maxBytes: number
              +

              Returns Promise<PlayerBlob | 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

            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            + +
            +
            +
            diff --git a/docs/api/player-sdk/functions/assertArchiveInputResourceLimits.html b/docs/api/player-sdk/functions/assertArchiveInputResourceLimits.html new file mode 100644 index 0000000..54985e6 --- /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..b4f58fb --- /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..3b13ad3 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..8f8b7f0 --- /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..339aa1d 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
                                });
                                @@ -30,7 +58,7 @@
                                -
                                // Get binary blob by hash (screenshots, DOM snapshots, response bodies)
                                const blob = await player.getBlob("abc123...");
                                if (blob) {
                                console.log(blob.mime); // "image/webp"
                                console.log(blob.bytes); // Uint8Array
                                } +
                                // Get binary blob by hash (screenshots, DOM snapshots, response bodies)
                                const blob = await player.getBlob("abc123...");
                                if (blob) {
                                console.log(blob.mime); // "image/webp"
                                console.log(blob.bytes); // Uint8Array
                                }

                                // Trusted analyzers can enforce a per-read ceiling without populating the
                                // Player blob cache. Integrity and decryption checks are still applied.
                                const transient = await player.readBlobTransient("abc123...", 2 * 1024 * 1024);
                                @@ -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..99c2b21 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..f085229 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..2c20991 --- /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..b4e51bf 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..1f9f22e 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..0babab5 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..b42fc7e 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..91d79f3 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..bebc854 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..2568a25 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..3f46767 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..3b0c634 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..f367410 --- /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..117df97 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..1348239 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..72bf0c2 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..1433fff 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..f29a552 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..bd644df 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..bb0483f 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..c3dda7d 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..a370dc6 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..6fe4712 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..8dfc220 --- /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;
                                                                                                                                    coverage: PrivacyScannerCoverage | null;
                                                                                                                                };
                                                                                                                            }
                                                                                                                            +
                                                                                                                            +
                                                                                                                            +
                                                                                                                            +
                                                                                                                            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;
                                                                                                                                coverage: PrivacyScannerCoverage | null;
                                                                                                                            }
                                                                                                                            +
                                                                                                                            + +
                                                                                                                            +
                                                                                                                            diff --git a/docs/api/player-sdk/types/RealtimeNetworkEntry.html b/docs/api/player-sdk/types/RealtimeNetworkEntry.html index aa2f39b..01f5423 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..91fa9fa --- /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..f8d2dc3 --- /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..98d7a30 --- /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..8c1ab46 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..be08bf9 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..2325c67 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..4932a5e --- /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 d255cda..c07bcf9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.1.0", "license": "MIT", "description": "MCP engineering monorepo powered by pnpm + turbo", - "packageManager": "pnpm@10.28.1", + "packageManager": "pnpm@11.13.1", "engines": { "node": ">=22.0.0" }, @@ -15,21 +15,26 @@ "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\" \"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", + "test": "turbo run test && pnpm test:scripts", + "test:scripts": "node --test scripts/*.test.mjs", + "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", "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", "version-packages": "changeset version", "release": "changeset publish", + "release:verify-artifacts": "node scripts/verify-publish-artifacts.mjs", + "release:verify-ref:test": "node --test scripts/verify-release-ref.test.mjs", "commit": "cz", "prepare": "husky" }, @@ -47,11 +52,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", @@ -60,10 +65,19 @@ "prettier": "^3.6.2", "tsup": "^8.5.1", "tsx": "^4.20.6", - "turbo": "^2.6.1", - "typedoc": "^0.28.17", + "turbo": "2.9.18", + "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" + }, + "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/packages/cdp-router/CHANGELOG.md b/packages/cdp-router/CHANGELOG.md index 3bdd414..f4e2930 100644 --- a/packages/cdp-router/CHANGELOG.md +++ b/packages/cdp-router/CHANGELOG.md @@ -1,5 +1,13 @@ # @webblackbox/cdp-router +## 0.7.0 + +### Patch Changes + +- b3cfda9: Rebuild publish artifacts deterministically and include the package license in the published tarball. +- Updated dependencies [b3cfda9] + - @webblackbox/protocol@0.7.0 + ## 0.6.0 ### Minor Changes 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/cdp-router/package.json b/packages/cdp-router/package.json index 4d2cdfc..8ec8a09 100644 --- a/packages/cdp-router/package.json +++ b/packages/cdp-router/package.json @@ -1,7 +1,7 @@ { "name": "@webblackbox/cdp-router", "description": "Chrome DevTools Protocol router for managing debugger targets and CDP sessions across tabs, iframes, and workers.", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,9 +40,11 @@ "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" + "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/CHANGELOG.md b/packages/pipeline/CHANGELOG.md index 98e2930..307f9f3 100644 --- a/packages/pipeline/CHANGELOG.md +++ b/packages/pipeline/CHANGELOG.md @@ -1,5 +1,17 @@ # @webblackbox/pipeline +## 0.7.0 + +### Minor Changes + +- b3cfda9: Add durable content-delivery receipts, identity-safe session metadata rebinding, monotonic recovery + watermarks, bounded streaming privacy analysis, and strict encrypted-export enforcement. + +### Patch Changes + +- Updated dependencies [b3cfda9] + - @webblackbox/protocol@0.7.0 + ## 0.6.0 ### Minor Changes 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/pipeline/README.md b/packages/pipeline/README.md index 1e0e26d..567a55d 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,30 +79,45 @@ 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. -### Optional At-Rest Storage Encryption +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. -`EncryptedPipelineStorage` encrypts chunk/blob cache payload bytes before persistence (for example when using IndexedDB storage). +### Required At-Rest Storage Encryption + +`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 @@ -112,6 +127,39 @@ const hash = await pipeline.putBlob("image/webp", screenshotBytes); // Returns SHA-256 hash for content-addressable retrieval ``` +### Bounded Privacy Analysis + +`buildPrivacyManifest` accepts arrays or streaming blob sources. Services scanning untrusted +archives should provide the exact blob total and explicit byte, finding, and elapsed-time ceilings. +The returned coverage distinguishes a complete `passed` result from partial or opaque analysis. + +```typescript +import { buildPrivacyManifest } from "@webblackbox/pipeline"; + +const privacy = await buildPrivacyManifest({ + events, + blobs: readBlobs() as AsyncIterable<{ + hash: string; + mime: string; + bytes: Uint8Array; + }>, + expectedBlobCount: manifest.stats.blobCount, + encrypted: false, + preEncryption: false, + scan: { + maxTargetBytes: 2 * 1024 * 1024, + maxTotalBytes: 64 * 1024 * 1024, + maxFindings: 1, + deadlineMs: 5_000, + stopOnFinding: true + } +}); + +if (privacy.scanner.coverage?.complete !== true) { + console.warn("Privacy analysis is partial", privacy.scanner.coverage?.incompleteReason); +} +``` + ## Event Chunking The `EventChunker` groups events into size-bounded chunks: diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index ee94df2..299a96f 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -1,7 +1,7 @@ { "name": "@webblackbox/pipeline", "description": "Chunking, indexing, blob storage, and archive export pipeline for WebBlackbox recording sessions.", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -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/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/packages/pipeline/src/chunker.test.ts b/packages/pipeline/src/chunker.test.ts new file mode 100644 index 0000000..de16088 --- /dev/null +++ b/packages/pipeline/src/chunker.test.ts @@ -0,0 +1,195 @@ +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("orders delayed events by their sampled timeline before finalizing a chunk", async () => { + const chunker = new EventChunker(1024 * 1024, "none"); + await chunker.append(createEvent(30)); + await chunker.append(createEvent(10)); + await chunker.append(createEvent(20)); + + const chunk = await chunker.flush(); + + expect(chunk?.events.map((event) => event.id)).toEqual(["E-10", "E-20", "E-30"]); + expect(chunk?.meta).toMatchObject({ + tStart: 10, + tEnd: 30, + monoStart: 10, + monoEnd: 30 + }); + }); + + it("keeps wall and monotonic clocks ordered when their sampled order conflicts", async () => { + const chunker = new EventChunker(1024 * 1024, "none"); + await chunker.append({ ...createEvent(10), t: 30 }); + await chunker.append({ ...createEvent(20), t: 10 }); + + const chunk = await chunker.flush(); + + expect(chunk?.events.map(({ t, mono }) => ({ t, mono }))).toEqual([ + { t: 30, mono: 10 }, + { t: 30, mono: 20 } + ]); + expect(chunk?.meta).toMatchObject({ + tStart: 30, + tEnd: 30, + monoStart: 10, + monoEnd: 20 + }); + }); + + it("clamps events that arrive behind an already finalized timeline boundary", async () => { + const chunker = new EventChunker(1, "none"); + const first = await chunker.append(createEvent(30)); + const delayedInput = createEvent(10); + const delayed = await chunker.append(delayedInput); + + expect(first?.events[0]).toMatchObject({ t: 30, mono: 30 }); + expect(delayed?.events[0]).toMatchObject({ id: "E-10", t: 30, mono: 30 }); + expect(delayedInput).toMatchObject({ t: 10, mono: 10 }); + }); + + it("restores the durable timeline boundary before accepting resumed events", async () => { + const chunker = new EventChunker(1, "none"); + chunker.restoreSequence(7); + chunker.restoreTimelineBoundary(80, 100); + + const resumed = await chunker.append(createEvent(5)); + + expect(resumed?.meta).toMatchObject({ + seq: 8, + tStart: 80, + tEnd: 80, + monoStart: 100, + monoEnd: 100 + }); + expect(resumed?.events[0]).toMatchObject({ t: 80, mono: 100 }); + }); + + it("does not advance the timeline boundary when a chunk commit fails", 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(50))).rejects.toThrow("simulated commit failure"); + const retried = await chunker.append(createEvent(10)); + + expect(retried?.events[0]).toMatchObject({ t: 10, mono: 10 }); + }); + + 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..4bbb628 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,80 @@ export class EventChunker { private sequence = 0; + private finalizedTimeEnd = Number.NEGATIVE_INFINITY; + + private finalizedMonoEnd = Number.NEGATIVE_INFINITY; + + 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 +110,50 @@ export class EventChunker { this.sequence = Math.floor(sequence); } - private async finalize(): Promise { - this.sequence += 1; + public restoreTimelineBoundary(tEnd: number, monoEnd: number): void { + if (Number.isFinite(tEnd)) { + this.finalizedTimeEnd = Math.max(this.finalizedTimeEnd, tEnd); + } - const events = [...this.pending]; + if (Number.isFinite(monoEnd)) { + this.finalizedMonoEnd = Math.max(this.finalizedMonoEnd, monoEnd); + } + } + + 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 = canonicalizeEventTimeline( + this.pending, + this.finalizedTimeEnd, + this.finalizedMonoEnd + ); 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,9 +166,53 @@ export class EventChunker { bytes, events }; + + await this.commit?.(chunk); + + this.pending.length = 0; + this.pendingBytes = 0; + this.sequence = sequence; + this.finalizedTimeEnd = last?.t ?? this.finalizedTimeEnd; + this.finalizedMonoEnd = last?.mono ?? this.finalizedMonoEnd; + + return chunk; } } +function canonicalizeEventTimeline( + events: readonly WebBlackboxEvent[], + previousTimeEnd: number, + previousMonoEnd: number +): WebBlackboxEvent[] { + let time = previousTimeEnd; + let mono = previousMonoEnd; + + return events + .map((event, ordinal) => ({ event, ordinal })) + .sort( + (left, right) => + left.event.mono - right.event.mono || + left.event.t - right.event.t || + left.ordinal - right.ordinal + ) + .map(({ event }) => { + const nextTime = Math.max(time, event.t); + const nextMono = Math.max(mono, event.mono); + time = nextTime; + mono = nextMono; + + if (nextTime === event.t && nextMono === event.mono) { + return event; + } + + return { + ...event, + t: nextTime, + mono: nextMono + }; + }); +} + function estimateEventNdjsonBytes(event: WebBlackboxEvent): number { return JSON.stringify(event).length + 1; } diff --git a/packages/pipeline/src/codec.ts b/packages/pipeline/src/codec.ts index b44bfb7..a2ff6a8 100644 --- a/packages/pipeline/src/codec.ts +++ b/packages/pipeline/src/codec.ts @@ -18,7 +18,8 @@ export function encodeEventsNdjson(events: WebBlackboxEvent[]): Uint8Array { } export function decodeEventsNdjson(input: string | Uint8Array): WebBlackboxEvent[] { - const text = typeof input === "string" ? input : new TextDecoder().decode(input); + const text = + typeof input === "string" ? input : new TextDecoder("utf-8", { fatal: true }).decode(input); if (!text.trim()) { return []; diff --git a/packages/pipeline/src/exporter.ts b/packages/pipeline/src/exporter.ts index fd3eff9..100e75f 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,38 +250,80 @@ 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) { throw new Error(`Archive is missing required file: ${path}`); } - const content = await file.async("string"); - return JSON.parse(content) as TValue; + const content = decodeArchiveUtf8(await file.async("uint8array"), path); + 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 = decodeArchiveUtf8(decoded, path) + .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( @@ -263,7 +334,15 @@ async function readArchiveFileText( ): Promise { const bytes = await readFileBytes(zip, path); const decrypted = await decryptArchiveFile(path, bytes, manifest, archiveKey); - return new TextDecoder().decode(decrypted); + return decodeArchiveUtf8(decrypted, path); +} + +function decodeArchiveUtf8(bytes: Uint8Array, path: string): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`Invalid WebBlackbox archive: '${path}' contains invalid UTF-8.`); + } } async function verifyArchiveIntegrity(zip: JSZip, integrity: HashesManifest): Promise { @@ -395,7 +474,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 85c8edc..04bb04d 100644 --- a/packages/pipeline/src/index.test.ts +++ b/packages/pipeline/src/index.test.ts @@ -1,18 +1,30 @@ +import "fake-indexeddb/auto"; + import { describe, expect, it } from "vitest"; import JSZip from "jszip"; import { + type CapturePolicy, DEFAULT_CAPTURE_POLICY, + type ExportManifest, type SessionMetadata, type WebBlackboxEvent } from "@webblackbox/protocol"; +import { decodeChunkEvents, encodeChunkEvents } from "./codec.js"; import { readWebBlackboxArchive } from "./exporter.js"; -import { FlightRecorderPipeline } from "./pipeline.js"; import { + FlightRecorderPipeline, + type FlightRecorderPipelineOptions, + type PipelineDeliverySidecar +} from "./pipeline.js"; +import { + deleteIndexedDbDatabase, derivePipelineStorageKey, EncryptedPipelineStorage, - MemoryPipelineStorage + IndexedDbPipelineStorage, + MemoryPipelineStorage, + type StoredChunk } from "./storage.js"; const SESSION: SessionMetadata = { @@ -31,6 +43,47 @@ 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 + }); +} + +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); + } +} + +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, @@ -38,6 +91,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, @@ -71,10 +158,7 @@ function createEvent( : "low", redacted: true }, - data: data ?? { - reqId: "R-1", - message: "hello" - } + data: eventData }; } @@ -93,9 +177,59 @@ function createNoisyPayload(size: number, seed: number): string { } describe("pipeline", () => { + it("rebinds mutable session metadata without accepting a different recording identity", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage }); + await pipeline.start(); + const rebound: SessionMetadata = { + ...SESSION, + url: "https://example.com/after-navigation", + title: "Updated title", + tags: ["triaged"], + endedAt: SESSION.startedAt + 1_000 + }; + + await pipeline.rebindSessionMetadata(rebound); + await expect(storage.getSession(SESSION.sid)).resolves.toEqual(rebound); + await expect(pipeline.rebindSessionMetadata({ ...rebound, mode: "full" })).rejects.toThrow( + /immutable recording identity/ + ); + }); + + 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 = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 100 @@ -110,7 +244,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 @@ -129,9 +263,897 @@ describe("pipeline", () => { expect(indexes.request.some((entry) => entry.reqId === "R-1")).toBe(true); }); + it("resumes a partially committed delivery batch across multiple chunks and pipeline restart", async () => { + const storage = new MemoryPipelineStorage(); + const events = Array.from({ length: 4 }, (_, index) => + createEvent(`E-${index + 1}`, "user.marker", index + 1, { + message: createNoisyPayload(64, index) + }) + ); + const provenance = (eventIndex: number) => ({ + tabId: 1, + frameId: 0, + documentId: "document-delivery", + batchId: "batch-delivery", + fingerprint: "a".repeat(64), + eventCount: events.length, + eventIndex + }); + const query = { + tabId: 1, + frameId: 0, + documentId: "document-delivery", + batchId: "batch-delivery" + }; + const first = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + + await first.start(); + await first.ingestBatchWithDelivery( + events.slice(0, 2), + new Map(events.slice(0, 2).map((event, index) => [event.id, provenance(index)])) + ); + await first.flush(); + expect(await first.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ + complete: false, + indexes: [ + { eventIndex: 0, outcome: "event", eventId: "E-1" }, + { eventIndex: 1, outcome: "event", eventId: "E-2" } + ] + }) + ); + + const restarted = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + await restarted.start(); + await restarted.ingestBatchWithDelivery( + events.slice(2), + new Map(events.slice(2).map((event, index) => [event.id, provenance(index + 2)])) + ); + await restarted.flush(); + + const completedReceipt = await restarted.getDeliveryReceipt(query); + expect(completedReceipt).toEqual(expect.objectContaining({ complete: true })); + expect(completedReceipt?.indexes).toHaveLength(4); + const persistedIds = ( + await Promise.all( + (await storage.listChunks(SESSION.sid)).map((chunk) => + decodeChunkEvents(chunk.bytes, chunk.meta.codec) + ) + ) + ) + .flat() + .map((event) => event.id); + expect(persistedIds).toEqual(["E-1", "E-2", "E-3", "E-4"]); + }); + + it("resumes partial multi-chunk delivery through encrypted indexeddb restart", async () => { + const databaseName = `wb-delivery-restart-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ + "encrypt", + "decrypt" + ]); + const session = { ...SESSION, sid: "S-encrypted-delivery-restart" }; + const events = Array.from({ length: 4 }, (_, index) => ({ + ...createEvent(`E-encrypted-${index + 1}`, "user.marker", index + 1, { + message: createNoisyPayload(64, index) + }), + sid: session.sid + })); + const provenance = (eventIndex: number) => ({ + tabId: 1, + frameId: 0, + documentId: "document-encrypted-delivery", + batchId: "batch-encrypted-delivery", + fingerprint: "6".repeat(64), + eventCount: events.length, + eventIndex + }); + const query = { + tabId: 1, + frameId: 0, + documentId: "document-encrypted-delivery", + batchId: "batch-encrypted-delivery" + }; + const firstStorage = new EncryptedPipelineStorage(new IndexedDbPipelineStorage(databaseName), { + key + }); + const first = createTestPipeline({ session, storage: firstStorage, maxChunkBytes: 1 }); + + await first.start(); + await first.ingestBatchWithDelivery( + events.slice(0, 2), + new Map(events.slice(0, 2).map((event, index) => [event.id, provenance(index)])) + ); + expect(await first.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ complete: false }) + ); + + const restartedStorage = new EncryptedPipelineStorage( + new IndexedDbPipelineStorage(databaseName), + { key } + ); + const restarted = createTestPipeline({ + session, + storage: restartedStorage, + maxChunkBytes: 1 + }); + await restarted.start(); + await restarted.ingestBatchWithDelivery( + events.slice(2), + new Map(events.slice(2).map((event, index) => [event.id, provenance(index + 2)])) + ); + await restarted.flush(); + + expect(await restarted.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ complete: true }) + ); + const persisted = ( + await Promise.all( + (await restartedStorage.listChunks(session.sid)).map((chunk) => + decodeChunkEvents(chunk.bytes, chunk.meta.codec) + ) + ) + ).flat(); + expect(persisted.map((event) => event.id)).toEqual(events.map((event) => event.id)); + + await restarted.close({ purge: true }); + await deleteIndexedDbDatabase(databaseName); + }); + + it("commits no-output progress without a chunk and completes only after output flush", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const base = { + tabId: 1, + frameId: 2, + documentId: "document-mixed", + batchId: "batch-mixed", + fingerprint: "b".repeat(64), + eventCount: 2 + }; + const query = { + tabId: base.tabId, + frameId: base.frameId, + documentId: base.documentId, + batchId: base.batchId + }; + const event = createEvent("E-1", "user.marker", 1); + + await pipeline.start(); + await pipeline.commitDeliveryProgress([{ ...base, eventIndex: 1, outcome: "no-output" }]); + expect(await storage.listChunks(SESSION.sid)).toEqual([]); + await pipeline.ingestBatchWithDelivery( + [event], + new Map([[event.id, { ...base, eventIndex: 0 }]]) + ); + expect(await pipeline.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ + complete: false, + indexes: [{ eventIndex: 1, outcome: "no-output" }] + }) + ); + + await pipeline.flush(); + const completedReceipt = await pipeline.getDeliveryReceipt(query); + expect(completedReceipt).toEqual(expect.objectContaining({ complete: true })); + expect(completedReceipt?.indexes).toHaveLength(2); + }); + + it("flushes pending output before no-output progress can complete its delivery receipt", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const base = { + tabId: 1, + frameId: 2, + documentId: "document-flush-before-progress", + batchId: "batch-flush-before-progress", + fingerprint: "e".repeat(64), + eventCount: 2 + }; + const query = { + tabId: base.tabId, + frameId: base.frameId, + documentId: base.documentId, + batchId: base.batchId + }; + const event = createEvent("E-1", "user.marker", 1); + + await pipeline.start(); + await pipeline.ingestBatchWithDelivery( + [event], + new Map([[event.id, { ...base, eventIndex: 0 }]]) + ); + expect(await storage.listChunks(SESSION.sid)).toEqual([]); + + await pipeline.commitDeliveryProgress([{ ...base, eventIndex: 1, outcome: "no-output" }]); + + expect(await storage.listChunks(SESSION.sid)).toHaveLength(1); + expect(await pipeline.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ + complete: true, + indexes: [ + { eventIndex: 0, outcome: "event", eventId: event.id }, + { eventIndex: 1, outcome: "no-output" } + ] + }) + ); + }); + + it("rejects no-output progress that conflicts with pending output provenance", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const base = { + tabId: 1, + frameId: 0, + documentId: "document-outcome-conflict", + batchId: "batch-outcome-conflict", + fingerprint: "f".repeat(64), + eventCount: 1 + }; + const query = { + tabId: base.tabId, + frameId: base.frameId, + documentId: base.documentId, + batchId: base.batchId + }; + const event = createEvent("E-1", "user.marker", 1); + + await pipeline.start(); + await pipeline.ingestBatchWithDelivery( + [event], + new Map([[event.id, { ...base, eventIndex: 0 }]]) + ); + + await expect( + pipeline.commitDeliveryProgress([{ ...base, eventIndex: 0, outcome: "no-output" }]) + ).rejects.toThrow(/conflicts with pending output provenance/i); + expect(await pipeline.getDeliveryReceipt(query)).toEqual({ + ...base, + indexes: [{ eventIndex: 0, outcome: "event", eventId: event.id }], + complete: true + }); + }); + + it("does not commit no-output progress when flushing pending output fails", async () => { + const storage = new FailOnceChunkStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const base = { + tabId: 1, + frameId: 0, + documentId: "document-progress-flush-failure", + batchId: "batch-progress-flush-failure", + fingerprint: "9".repeat(64), + eventCount: 2 + }; + const query = { + tabId: base.tabId, + frameId: base.frameId, + documentId: base.documentId, + batchId: base.batchId + }; + const event = createEvent("E-1", "user.marker", 1); + + await pipeline.start(); + await pipeline.ingestBatchWithDelivery( + [event], + new Map([[event.id, { ...base, eventIndex: 0 }]]) + ); + + await expect( + pipeline.commitDeliveryProgress([{ ...base, eventIndex: 1, outcome: "no-output" }]) + ).rejects.toThrow(/simulated chunk persistence failure/i); + expect(await pipeline.getDeliveryReceipt(query)).toBeUndefined(); + expect(await storage.listChunks(SESSION.sid)).toEqual([]); + + await pipeline.commitDeliveryProgress([{ ...base, eventIndex: 1, outcome: "no-output" }]); + expect(await pipeline.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ complete: true }) + ); + }); + + it("does not advance a delivery receipt when its output chunk commit fails", async () => { + const storage = new FailOnceChunkStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1 }); + const event = createEvent("E-1", "user.marker", 1); + const provenance = { + tabId: 1, + frameId: 0, + documentId: "document-failure", + batchId: "batch-failure", + fingerprint: "c".repeat(64), + eventCount: 1, + eventIndex: 0 + }; + const query = { + tabId: provenance.tabId, + frameId: provenance.frameId, + documentId: provenance.documentId, + batchId: provenance.batchId + }; + + await pipeline.start(); + await expect( + pipeline.ingestBatchWithDelivery([event], new Map([[event.id, provenance]])) + ).rejects.toThrow(/simulated chunk persistence failure/i); + expect(await pipeline.getDeliveryReceipt(query)).toBeUndefined(); + expect(await storage.listChunks(SESSION.sid)).toEqual([]); + + await pipeline.ingestBatchWithDelivery([event], new Map([[event.id, provenance]])); + expect(await pipeline.getDeliveryReceipt(query)).toEqual( + expect.objectContaining({ complete: true }) + ); + }); + + it("retries a delivery batch without duplicating events retained before a failed chunk append", async () => { + const storage = new FailOnceChunkStorage(); + const events = [ + createEvent("E-retained-1", "user.marker", 1, { value: "first" }), + createEvent("E-retained-2", "user.marker", 2, { value: "second" }) + ]; + const pipeline = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: JSON.stringify(events[0]).length + 2 + }); + const provenance = (eventIndex: number) => ({ + tabId: 1, + frameId: 0, + documentId: "document-retained-retry", + batchId: "batch-retained-retry", + fingerprint: "4".repeat(64), + eventCount: events.length, + eventIndex + }); + const sidecar = new Map( + events.map((event, eventIndex) => [event.id, provenance(eventIndex)] as const) + ); + + await pipeline.start(); + await expect(pipeline.ingestBatchWithDelivery(events, sidecar)).rejects.toThrow( + /simulated chunk persistence failure/i + ); + await pipeline.ingestBatchWithDelivery(events, sidecar); + await pipeline.flush(); + + const chunks = await storage.listChunks(SESSION.sid); + const persisted = ( + await Promise.all(chunks.map((chunk) => decodeChunkEvents(chunk.bytes, chunk.meta.codec))) + ).flat(); + 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("rejects an explicitly undefined delivery sidecar entry instead of dropping provenance", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const event = createEvent("E-undefined-sidecar", "user.marker", 1); + const sidecar = new Map([[event.id, undefined]]) as unknown as PipelineDeliverySidecar; + + await pipeline.start(); + await expect(pipeline.ingestBatchWithDelivery([event], sidecar)).rejects.toThrow( + /invalid pipeline delivery event provenance/i + ); + await pipeline.flush(); + expect(await storage.listChunks(SESSION.sid)).toEqual([]); + }); + + it("deduplicates exact pending and durable delivery retries across calls", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const event = createEvent("E-delivery-retry", "user.marker", 1); + const provenance = { + tabId: 1, + frameId: 0, + documentId: "document-delivery-retry", + batchId: "batch-delivery-retry", + fingerprint: "7".repeat(64), + eventCount: 1, + eventIndex: 0 + }; + const sidecar = new Map([[event.id, provenance]]); + + await pipeline.start(); + await pipeline.ingestBatchWithDelivery([event], sidecar); + await pipeline.ingestBatchWithDelivery([event], sidecar); + await pipeline.flush(); + await pipeline.ingestBatchWithDelivery([event], sidecar); + await pipeline.flush(); + + const chunks = await storage.listChunks(SESSION.sid); + const persisted = ( + await Promise.all(chunks.map((chunk) => decodeChunkEvents(chunk.bytes, chunk.meta.codec))) + ).flat(); + expect(persisted.map((candidate) => candidate.id)).toEqual([event.id]); + expect(chunks).toHaveLength(1); + }); + + it("rejects cross-call event-id and batch-index provenance conflicts", async () => { + const storage = new MemoryPipelineStorage(); + const pipeline = createTestPipeline({ session: SESSION, storage, maxChunkBytes: 1024 * 1024 }); + const event = createEvent("E-delivery-conflict", "user.marker", 1); + const base = { + tabId: 1, + frameId: 0, + documentId: "document-delivery-conflict", + batchId: "batch-delivery-conflict", + fingerprint: "8".repeat(64), + eventCount: 2 + }; + + await pipeline.start(); + await pipeline.ingestBatchWithDelivery( + [event], + new Map([[event.id, { ...base, eventIndex: 0 }]]) + ); + + await expect( + pipeline.ingestBatchWithDelivery( + [event], + new Map([[event.id, { ...base, batchId: "different-batch", eventIndex: 0 }]]) + ) + ).rejects.toThrow(/event id was reused with different delivery provenance/i); + await expect( + pipeline.ingestBatchWithDelivery( + [createEvent("E-different-event", "user.marker", 2)], + new Map([ + [ + "E-different-event", + { + ...base, + eventIndex: 0 + } + ] + ]) + ) + ).rejects.toThrow(/batch index already has a different pending event/i); + + await pipeline.flush(); + const chunks = await storage.listChunks(SESSION.sid); + const persisted = ( + await Promise.all(chunks.map((chunk) => decodeChunkEvents(chunk.bytes, chunk.meta.codec))) + ).flat(); + expect(persisted.map((candidate) => candidate.id)).toEqual([event.id]); + }); + + 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("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-00000051", "user.marker", 1), + ref: { act: "A-000007" } + }); + await first.close(); + + const restored = createTestPipeline({ + session: SESSION, + storage, + maxChunkBytes: 1 + }); + await restored.start(); + 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]?.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); + }); + + it("restores the persisted timeline watermark before ingesting delayed events", async () => { + const storage = new MemoryPipelineStorage(); + const session = { + ...SESSION, + sid: "S-delayed-event-restart" + }; + const first = createTestPipeline({ session, storage, maxChunkBytes: 1 }); + await first.start(); + await first.ingest({ + ...createEvent("E-00000001", "user.marker", 100), + sid: session.sid + }); + await first.close(); + + const restored = createTestPipeline({ session, storage, maxChunkBytes: 1 }); + await restored.start(); + await restored.ingest({ + ...createEvent("E-00000002", "user.marker", 10), + sid: session.sid + }); + const exported = await restored.exportBundle(FULL_EXPORT_OPTIONS); + const archive = await readWebBlackboxArchive(exported.bytes); + await restored.close(); + + expect(archive.events.map(({ id, t, mono }) => ({ id, t, mono }))).toEqual([ + { id: "E-00000001", t: 100, mono: 100 }, + { id: "E-00000002", t: 100, mono: 100 } + ]); + }); + + 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, size: 10 }] + } + ] + }); + }); + + 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("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({ + 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 = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session: { ...SESSION, sid: "S-nested-request-id" @@ -164,7 +1186,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 +1222,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 +1257,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 +1293,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 +1326,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 +1347,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 +1362,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 +1418,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 +1446,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 +1476,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 +1545,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 +1569,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 +1599,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 +1621,7 @@ describe("pipeline", () => { ...SESSION, sid: "S-recovery" }; - const initialPipeline = new FlightRecorderPipeline({ + const initialPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -574,7 +1644,7 @@ describe("pipeline", () => { ); await initialPipeline.flush(); - const recoveredPipeline = new FlightRecorderPipeline({ + const recoveredPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -600,7 +1670,7 @@ describe("pipeline", () => { ...SESSION, sid: "S-recovery-seq" }; - const initialPipeline = new FlightRecorderPipeline({ + const initialPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -615,7 +1685,7 @@ describe("pipeline", () => { ); await initialPipeline.flush(); - const recoveredPipeline = new FlightRecorderPipeline({ + const recoveredPipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -638,7 +1708,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 +1736,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 +1763,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 @@ -723,9 +1793,37 @@ describe("pipeline", () => { } }); + it("rejects invalid UTF-8 in unencrypted archive metadata", async () => { + const pipeline = createTestPipeline({ + session: SESSION, + storage: new MemoryPipelineStorage(), + maxChunkBytes: 128 + }); + await pipeline.start(); + await pipeline.ingest(createEvent("E-invalid-metadata-utf8", "user.click", 10)); + + const exported = await pipeline.exportBundle(FULL_EXPORT_OPTIONS); + const zip = await JSZip.loadAsync(exported.bytes); + const manifestFile = zip.file("manifest.json"); + if (!manifestFile) { + throw new Error("Missing fixture manifest."); + } + const manifestBytes = await manifestFile.async("uint8array"); + replaceFirstByteSequenceForTest( + manifestBytes, + new TextEncoder().encode("https://example.com"), + 0xff + ); + zip.file("manifest.json", manifestBytes); + await writeArchiveIntegrityForTest(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect(readWebBlackboxArchive(bytes)).rejects.toThrow(/invalid UTF-8/i); + }); + 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 +1855,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 +1889,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, @@ -871,6 +1969,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", { @@ -879,7 +2033,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 +2069,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 +2105,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 +2177,7 @@ describe("pipeline", () => { startedAt: sessionEnd - 60 * 60 * 1000, endedAt: sessionEnd }; - const pipeline = new FlightRecorderPipeline({ + const pipeline = createTestPipeline({ session, storage, maxChunkBytes: 128 @@ -1052,7 +2206,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 +2226,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 +2266,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 @@ -1136,12 +2290,33 @@ describe("pipeline", () => { await pipelineB.ingest(createEvent("E-pb-1", "user.click", 2)); await pipelineB.flush(); await pipelineB.finalizeIndexes(); + const deliveryProgress = { + tabId: 1, + frameId: 0, + documentId: "document-purge-a", + batchId: "batch-purge-a", + fingerprint: "5".repeat(64), + eventCount: 1, + eventIndex: 0, + outcome: "no-output" as const + }; + const deliveryQuery = { + tabId: deliveryProgress.tabId, + frameId: deliveryProgress.frameId, + documentId: deliveryProgress.documentId, + batchId: deliveryProgress.batchId + }; + await pipelineA.commitDeliveryProgress([deliveryProgress]); + expect(await pipelineA.getDeliveryReceipt(deliveryQuery)).toEqual( + expect.objectContaining({ complete: true }) + ); await pipelineA.close({ purge: true }); expect(await storage.getSession(sessionA.sid)).toBeUndefined(); expect(await storage.listChunks(sessionA.sid)).toHaveLength(0); expect(await storage.getIntegrity(sessionA.sid)).toBeUndefined(); + expect(await storage.getDeliveryReceipt(sessionA.sid, deliveryQuery)).toBeUndefined(); expect(await storage.getIndexes(sessionA.sid)).toEqual({ time: [], request: [], @@ -1154,3 +2329,48 @@ 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 replaceFirstByteSequenceForTest( + target: Uint8Array, + sequence: Uint8Array, + replacement: number +): void { + for (let offset = 0; offset <= target.byteLength - sequence.byteLength; offset += 1) { + if (sequence.every((value, index) => target[offset + index] === value)) { + target[offset] = replacement; + return; + } + } + + throw new Error("Fixture byte sequence was not found."); +} + +function toArrayBufferForTest(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} diff --git a/packages/pipeline/src/pipeline.ts b/packages/pipeline/src/pipeline.ts index 3469072..747e32d 100644 --- a/packages/pipeline/src/pipeline.ts +++ b/packages/pipeline/src/pipeline.ts @@ -18,7 +18,27 @@ 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 { + assertPipelineResumeChangesWithinLimits, + isPipelineDeliveryEventProvenance, + isPipelineResumeDelta, + isPipelineResumeState, + PIPELINE_STORAGE_SECURITY, + type PipelineDeliveryEventChange, + type PipelineDeliveryEventProvenance, + type PipelineDeliveryNoOutputChange, + type PipelineDeliveryReceipt, + type PipelineDeliveryReceiptQuery, + type PipelineResumeDelta, + type PipelineResumeState, + type PipelineStorage, + type RecorderSequenceWatermark, + type ScreenRecordingChunkReference, + type ScreenRecordingResumeChange, + type ScreenRecordingResumeState, + type StoredBlob, + type StoredChunk +} from "./storage.js"; export type FlightRecorderPipelineOptions = { session: SessionMetadata; @@ -30,6 +50,8 @@ export type FlightRecorderPipelineOptions = { trustedPlaintextExemptionEvidenceRefs?: readonly string[]; }; +export type PipelineDeliverySidecar = ReadonlyMap; + export type ExportResult = { fileName: string; bytes: Uint8Array; @@ -44,6 +66,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; }; @@ -53,6 +80,8 @@ type PreparedExportChunk = { blobHashes: string[]; }; +type ScreenRecordingChunkCheckpoint = Omit; + type ExportIndexes = { time: ReturnType["time"]; request: RequestIndexEntry[]; @@ -86,41 +115,108 @@ 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; 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; + private sequenceWatermark: RecorderSequenceWatermark = { event: 0, action: 0 }; + private readonly screenRecordingChunks = new Map< + string, + Map + >(); + private readonly pendingEventIds = new Set(); + private readonly pendingDeliveryByEventId = new Map(); 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 { - const lastSequence = - (await this.options.storage.getLatestChunkMeta(this.options.session.sid))?.seq ?? 0; + await this.ensureStorageReady(); + const latestMeta = await this.options.storage.getLatestChunkMeta(this.options.session.sid); + const lastSequence = latestMeta?.seq ?? 0; this.chunker.restoreSequence(lastSequence); + if (latestMeta) { + this.chunker.restoreTimelineBoundary(latestMeta.tEnd, latestMeta.monoEnd); + } + const resumeState = latestMeta + ? 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); } + /** Updates mutable archive metadata while preserving the pipeline's exact recording identity. */ + public async rebindSessionMetadata(session: SessionMetadata): Promise { + if ( + session.sid !== this.options.session.sid || + session.tabId !== this.options.session.tabId || + session.startedAt !== this.options.session.startedAt || + session.mode !== this.options.session.mode + ) { + throw new Error("Pipeline session metadata changed immutable recording identity."); + } + + const rebound: SessionMetadata = { + ...session, + tags: [...session.tags] + }; + await this.enqueueChunkOperation(async () => { + await this.ensureStorageReady(); + await this.options.storage.putSession(rebound); + this.options.session = rebound; + }); + } + 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 () => { + this.assertEventResumeStateWithinLimits(event); + this.assertEventIdIsNotPending(event.id); + this.pendingEventIds.add(event.id); + + try { + await this.chunker.append(event); + } catch (error) { + this.pendingEventIds.delete(event.id); + throw error; + } + this.applyEventToResumeState(event); + }); } public async ingestBatch(events: WebBlackboxEvent[]): Promise { @@ -132,48 +228,247 @@ export class FlightRecorderPipeline { assertPrivacyClassifiedEvent(event); } + if (!this.acceptingEvents) { + throw new Error("FlightRecorderPipeline cannot ingest events after close() has started."); + } + + await this.enqueueChunkOperation(async () => { + for (const event of events) { + this.assertEventResumeStateWithinLimits(event); + this.assertEventIdIsNotPending(event.id); + this.pendingEventIds.add(event.id); + + try { + await this.chunker.append(event); + } catch (error) { + this.pendingEventIds.delete(event.id); + throw error; + } + this.applyEventToResumeState(event); + } + }); + } + + public async ingestBatchWithDelivery( + events: WebBlackboxEvent[], + deliveryByEventId: PipelineDeliverySidecar + ): Promise { + if (events.length === 0) { + if (deliveryByEventId.size > 0) { + throw new Error("Pipeline delivery sidecar references an empty event batch."); + } + return; + } + + this.requireDeliveryStorage(); + const changes = normalizePipelineDeliverySidecar( + this.options.session.sid, + events, + deliveryByEventId + ); + for (const event of events) { - const chunk = await this.chunker.append(event); + assertPrivacyClassifiedEvent(event); + } - if (!chunk) { - continue; + if (!this.acceptingEvents) { + throw new Error("FlightRecorderPipeline cannot ingest events after close() has started."); + } + + await this.enqueueChunkOperation(async () => { + await this.ensureStorageReady(); + const durableReceipts = new Map(); + + for (const change of changes.values()) { + const key = createPipelineDeliveryBatchKey(change); + + if (!durableReceipts.has(key)) { + durableReceipts.set( + key, + await this.options.storage.getDeliveryReceipt!(this.options.session.sid, { + tabId: change.tabId, + frameId: change.frameId, + documentId: change.documentId, + batchId: change.batchId + }) + ); + } } - await this.persistChunk( - chunk.meta.chunkId, - chunk.meta.seq, - chunk.meta.codec, - chunk.events, - chunk.bytes - ); + for (const event of events) { + this.assertEventResumeStateWithinLimits(event); + const change = changes.get(event.id); + const pendingForEventId = this.pendingDeliveryByEventId.get(event.id); + + if (!change) { + this.assertEventIdIsNotPending(event.id); + + this.pendingEventIds.add(event.id); + + try { + await this.chunker.append(event); + } catch (error) { + this.pendingEventIds.delete(event.id); + throw error; + } + this.applyEventToResumeState(event); + continue; + } + + if (pendingForEventId) { + if (isSamePipelineDeliveryEventChange(pendingForEventId, change)) { + continue; + } + + throw new Error("Pipeline event id was reused with different delivery provenance."); + } + + if (this.pendingEventIds.has(event.id)) { + throw new Error("Pipeline event id is already pending without delivery provenance."); + } + + const pendingForIndex = [...this.pendingDeliveryByEventId.values()].find((pending) => + isSamePipelineDeliveryBatchIndex(pending, change) + ); + + if (pendingForIndex) { + throw new Error("Pipeline delivery batch index already has a different pending event."); + } + + const durableReceipt = durableReceipts.get(createPipelineDeliveryBatchKey(change)); + const durableIndex = durableReceipt?.indexes.find( + (index) => index.eventIndex === change.eventIndex + ); + + if (durableReceipt) { + if ( + durableReceipt.fingerprint !== change.fingerprint || + durableReceipt.eventCount !== change.eventCount + ) { + throw new Error("Pipeline delivery batch identity was reused with different content."); + } + + if (durableIndex) { + if (durableIndex.outcome === "event" && durableIndex.eventId === event.id) { + continue; + } + + throw new Error("Pipeline delivery batch index already has a different outcome."); + } + } + + this.pendingDeliveryByEventId.set(event.id, change); + this.pendingEventIds.add(event.id); + + try { + await this.chunker.append(event); + } catch (error) { + this.pendingEventIds.delete(event.id); + if (this.pendingDeliveryByEventId.get(event.id) === change) { + this.pendingDeliveryByEventId.delete(event.id); + } + throw error; + } + this.applyEventToResumeState(event); + } + }); + } + + public async commitDeliveryProgress( + changes: readonly PipelineDeliveryNoOutputChange[] + ): Promise { + if (changes.length === 0) { + return; } + + this.requireDeliveryStorage(); + await this.enqueueChunkOperation(async () => { + await this.ensureStorageReady(); + const pendingConflicts = this.findPendingDeliveryOutcomeConflicts(changes); + + // Output provenance is only durable once its containing chunk is committed. Flush before + // metadata-only progress so a completed receipt can never acknowledge an in-memory output. + await this.chunker.flush(); + + if (pendingConflicts.length > 0) { + throw new Error( + "Pipeline delivery progress conflicts with pending output provenance for the same batch index." + ); + } + + await this.options.storage.commitDeliveryProgress!(this.options.session.sid, changes); + }); + } + + public async getDeliveryReceipt( + query: PipelineDeliveryReceiptQuery + ): Promise { + this.requireDeliveryStorage(); + await this.enqueueChunkOperation(async () => undefined); + await this.ensureStorageReady(); + return this.options.storage.getDeliveryReceipt!(this.options.session.sid, query); + } + + 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 { - 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(); + + if (options.purge) { + await this.options.storage.deleteSession(this.options.session.sid); + } + }); + const tracked = attempt.finally(() => { + if (this.closePromise === tracked) { + this.closePromise = null; + } + }); + + this.closePromise = tracked; + return tracked; } - public async close(options: { purge?: boolean } = {}): Promise { - await this.flush(); + private requireDeliveryStorage(): void { + if (!this.options.storage.getDeliveryReceipt || !this.options.storage.commitDeliveryProgress) { + throw new Error("Pipeline storage does not support durable delivery receipts."); + } + } - if (options.purge) { - await this.options.storage.deleteSession(this.options.session.sid); + private assertEventIdIsNotPending(eventId: string): void { + if (this.pendingEventIds.has(eventId)) { + throw new Error("Pipeline event id is already pending in the current chunk."); } } + private findPendingDeliveryOutcomeConflicts( + changes: readonly PipelineDeliveryNoOutputChange[] + ): PipelineDeliveryEventChange[] { + return [...this.pendingDeliveryByEventId.values()].filter((pending) => + changes.some((change) => isSamePipelineDeliveryBatchIndex(change, pending)) + ); + } + public async putBlob(mime: string, bytes: Uint8Array): Promise { + await this.ensureStorageReady(); const hash = await sha256Hex(bytes); const blob: StoredBlob = { hash, @@ -193,6 +488,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); @@ -202,6 +498,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, { @@ -226,6 +523,7 @@ export class FlightRecorderPipeline { const privacyManifest = await buildPrivacyManifest({ events, blobs, + expectedBlobCount: blobs.length, capturePolicy: this.options.capturePolicy, encrypted, transfer: buildExportTransferPolicy({ @@ -308,40 +606,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 { @@ -559,6 +849,7 @@ export class FlightRecorderPipeline { const privacyManifest = await buildPrivacyManifest({ events, blobs: exportData.blobs, + expectedBlobCount: exportData.blobs.length, capturePolicy: this.options.capturePolicy, encrypted, transfer: buildExportTransferPolicy({ @@ -659,9 +950,14 @@ 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); + const deliveryChanges = events.flatMap((event) => { + const change = this.pendingDeliveryByEventId.get(event.id); + return change ? [change] : []; + }); const chunk: StoredChunk = { sid: this.options.session.sid, @@ -677,10 +973,184 @@ export class FlightRecorderPipeline { codec, sha256: hash }, - bytes + bytes, + resumeDelta: derivePipelineResumeDelta(this.sequenceWatermark, events, deliveryChanges) }; await this.options.storage.putChunk(chunk); + + for (const event of events) { + this.pendingEventIds.delete(event.id); + } + + for (const change of deliveryChanges) { + if (this.pendingDeliveryByEventId.get(change.eventId) === change) { + this.pendingDeliveryByEventId.delete(change.eventId); + } + } + } + + 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) { + throw new Error("Latest pipeline chunk metadata points to missing chunk payload."); + } + + if (isPipelineResumeState(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); + + // 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 empty = emptyPipelineResumeState(); + const legacy = applyPipelineResumeDelta( + empty, + derivePipelineResumeDelta(empty.sequenceWatermark, events) + ); + legacy.sequenceWatermark.action = Math.max( + legacy.sequenceWatermark.action, + legacy.sequenceWatermark.event + ); + 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]) + ); + applyScreenRecordingEvent(this.screenRecordingChunks, event); + } + + private assertEventResumeStateWithinLimits(event: WebBlackboxEvent): void { + const change = readScreenRecordingResumeChange(event); + + if (change) { + assertPipelineResumeChangesWithinLimits(this.screenRecordingChunks, [change]); + } + } + + private snapshotResumeState(): PipelineResumeState { + return { + sequenceWatermark: { ...this.sequenceWatermark }, + screenRecordings: [...this.screenRecordingChunks.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)) + }; + } + + 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, + { + hash: chunk.hash, + ...(chunk.size === undefined ? {} : { size: chunk.size }) + } + ]) + ) + ); + } + } + + 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, + this.options.capturePolicy + ); + return this.storageReadyPromise; } private async buildIndexesFromChunks(chunks: StoredChunk[]): Promise { @@ -735,6 +1205,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: { @@ -937,6 +1438,331 @@ 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 createPipelineDeliveryBatchKey(delivery: PipelineDeliveryEventProvenance): string { + return JSON.stringify([delivery.tabId, delivery.frameId, delivery.documentId, delivery.batchId]); +} + +function isSamePipelineDeliveryBatchIndex( + left: PipelineDeliveryEventProvenance, + right: PipelineDeliveryEventProvenance +): boolean { + return ( + left.tabId === right.tabId && + left.frameId === right.frameId && + left.documentId === right.documentId && + left.batchId === right.batchId && + left.eventIndex === right.eventIndex + ); +} + +function isSamePipelineDeliveryEventChange( + left: PipelineDeliveryEventChange, + right: PipelineDeliveryEventChange +): boolean { + return ( + isSamePipelineDeliveryBatchIndex(left, right) && + left.fingerprint === right.fingerprint && + left.eventCount === right.eventCount && + left.eventId === right.eventId + ); +} + +function normalizePipelineDeliverySidecar( + sid: string, + events: readonly WebBlackboxEvent[], + deliveryByEventId: PipelineDeliverySidecar +): Map { + const eventIds = new Set(); + const changes = new Map(); + const indexesByBatch = new Map>(); + + for (const event of events) { + if (event.sid !== sid) { + throw new Error("Pipeline delivered event belongs to another session."); + } + if (eventIds.has(event.id)) { + throw new Error("Pipeline delivered event batch contains duplicate event ids."); + } + eventIds.add(event.id); + + if (!deliveryByEventId.has(event.id)) { + continue; + } + const provenance = deliveryByEventId.get(event.id); + if (!isPipelineDeliveryEventProvenance(provenance)) { + throw new Error("Invalid pipeline delivery event provenance."); + } + + const batchKey = createPipelineDeliveryBatchKey(provenance); + const indexes = indexesByBatch.get(batchKey) ?? new Set(); + if (indexes.has(provenance.eventIndex)) { + throw new Error("Pipeline delivery sidecar repeats a batch event index."); + } + indexes.add(provenance.eventIndex); + indexesByBatch.set(batchKey, indexes); + changes.set(event.id, { + ...provenance, + outcome: "event", + eventId: event.id + }); + } + + for (const eventId of deliveryByEventId.keys()) { + if (!eventIds.has(eventId)) { + throw new Error("Pipeline delivery sidecar references an event outside its batch."); + } + } + + return changes; +} + +function derivePipelineResumeDelta( + base: RecorderSequenceWatermark, + events: readonly WebBlackboxEvent[], + deliveryChanges: readonly PipelineDeliveryEventChange[] = [] +): PipelineResumeDelta { + let sequenceWatermark = { ...base }; + const screenRecordingChanges: ScreenRecordingResumeChange[] = []; + + for (const event of events) { + sequenceWatermark = mergeSequenceWatermarks( + sequenceWatermark, + deriveRecorderSequenceWatermark([event]) + ); + const change = readScreenRecordingResumeChange(event); + + if (change) { + screenRecordingChanges.push(change); + } + } + + return deliveryChanges.length > 0 + ? { + version: 2, + sequenceWatermark, + screenRecordingChanges, + deliveryChanges: deliveryChanges.map((change) => ({ ...change })) + } + : { + 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, checkpoint]) => ({ index, ...checkpoint })) + .sort((left, right) => left.index - right.index) + })) + .sort((left, right) => left.recordingId.localeCompare(right.recordingId)) + }; +} + +function applyScreenRecordingEvent( + output: Map>, + event: WebBlackboxEvent +): void { + const change = readScreenRecordingResumeChange(event); + + if (!change) { + return; + } + + applyScreenRecordingResumeChange(output, change); +} + +function applyScreenRecordingResumeChange( + output: Map>, + change: ScreenRecordingResumeChange +): void { + if (change.operation === "delete") { + output.delete(change.recordingId); + return; + } + + if (change.operation === "reset") { + output.set(change.recordingId, new Map()); + return; + } + + 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 ( + event.type !== "screen.recording.start" && + event.type !== "screen.recording.chunk" && + event.type !== "screen.recording.end" && + event.type !== "screen.recording.error" + ) { + return null; + } + + const data = asUnknownRecord(event.data); + const recordingId = data?.recordingId; + + if (typeof recordingId !== "string" || recordingId.length === 0 || recordingId.length > 256) { + return null; + } + + if (event.type === "screen.recording.start") { + return { + operation: "reset", + recordingId + }; + } + + if (event.type === "screen.recording.end") { + return { + operation: "delete", + recordingId + }; + } + + if (event.type === "screen.recording.error") { + return data?.stage === "restart" + ? { + operation: "delete", + recordingId + } + : null; + } + + const hash = data?.chunkId; + const index = data?.index; + const size = data?.size; + + 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 { + operation: "put", + recordingId, + index: index as number, + hash, + ...(Number.isSafeInteger(size) && (size as number) >= 0 ? { size: size as number } : {}) + }; +} + +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/privacy.test.ts b/packages/pipeline/src/privacy.test.ts index b961298..896161d 100644 --- a/packages/pipeline/src/privacy.test.ts +++ b/packages/pipeline/src/privacy.test.ts @@ -1,7 +1,7 @@ import type { WebBlackboxEvent } from "@webblackbox/protocol"; import { describe, expect, it } from "vitest"; -import { buildPrivacyManifest } from "./privacy.js"; +import { assertPrivacyScannerPassed, buildPrivacyManifest } from "./privacy.js"; function createEvent( id: string, @@ -27,6 +27,17 @@ function createEvent( } describe("privacy scanner", () => { + it("does not treat a legacy passed result without coverage as a complete scan", () => { + expect(() => + assertPrivacyScannerPassed({ + scannedAt: new Date(0).toISOString(), + preEncryption: true, + status: "passed", + findings: [] + }) + ).toThrow(/did not complete/i); + }); + it("does not classify browser numeric event metadata as phone numbers", async () => { const manifest = await buildPrivacyManifest({ events: [createEvent("E-tab-id")], @@ -55,7 +66,7 @@ describe("privacy scanner", () => { ]); }); - it("does not scan recorder config policy metadata as captured content", async () => { + it("does not treat recorder config field names alone as captured secrets", async () => { const manifest = await buildPrivacyManifest({ events: [ createEvent( @@ -76,4 +87,365 @@ describe("privacy scanner", () => { expect(manifest.scanner.status).toBe("passed"); }); + + it("does scan attacker-controlled values even when an archive labels them meta.config", async () => { + const manifest = await buildPrivacyManifest({ + events: [ + createEvent( + "E-config-secret", + { exampleOnly: "-----BEGIN PRIVATE KEY-----" }, + 2_104_634_568, + "meta.config" + ) + ], + blobs: [], + encrypted: false + }); + + expect(manifest.scanner).toMatchObject({ + status: "blocked", + findings: [{ kind: "private-key", path: "event:E-config-secret" }] + }); + }); + + it("preserves event object keys as scanner context", async () => { + const manifest = await buildPrivacyManifest({ + events: [createEvent("E-key-secret", { api_key: "abcdefghijklmnop" })], + blobs: [], + encrypted: false + }); + + expect(manifest.scanner).toMatchObject({ + status: "blocked", + findings: [{ kind: "api-key", path: "event:E-key-secret" }] + }); + }); + + it("scans sensitive event keys even when their values are not strings", async () => { + const manifest = await buildPrivacyManifest({ + events: [ + createEvent( + "E-non-string-key-secret", + { + "alice@example.com": true, + "-----BEGIN PRIVATE KEY-----": null + }, + 2_104_634_568, + "meta.config" + ) + ], + blobs: [], + encrypted: false + }); + + expect(manifest.scanner.status).toBe("blocked"); + expect(manifest.scanner.findings.map((finding) => finding.kind)).toEqual( + expect.arrayContaining(["private-key", "email"]) + ); + }); + + it("scans asynchronously supplied blobs without retaining a caller-owned collection", async () => { + async function* blobs() { + yield { + hash: "a".repeat(64), + mime: "application/octet-stream", + bytes: new Uint8Array([1, 2, 3]) + }; + yield { + hash: "b".repeat(64), + mime: "text/plain", + bytes: new TextEncoder().encode("-----BEGIN PRIVATE KEY-----") + }; + } + + const manifest = await buildPrivacyManifest({ + events: [], + blobs: blobs(), + encrypted: false, + preEncryption: false + }); + + expect(manifest.totals.blobs).toBe(2); + expect(manifest.scanner).toMatchObject({ + preEncryption: false, + status: "blocked", + findings: [{ kind: "private-key", path: `blob:${"b".repeat(64)}` }] + }); + }); + + it("scans decoded JSON string values instead of trusting their escaped representation", async () => { + const escapedDash = "\\u002d"; + const bytes = new TextEncoder().encode( + `{"value":"${escapedDash.repeat(5)}BEGIN PRIVATE KEY${escapedDash.repeat(5)}"}` + ); + + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [{ hash: "c".repeat(64), mime: "application/json", bytes }], + encrypted: false, + preEncryption: false + }); + + expect(manifest.scanner.status).toBe("blocked"); + expect(manifest.scanner.findings).toMatchObject([{ kind: "private-key" }]); + expect(manifest.scanner.coverage).toMatchObject({ + complete: true, + scannedBlobCount: 1, + opaqueBlobCount: 0 + }); + }); + + it("retains raw JSON key context while also scanning semantic values", async () => { + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [ + { + hash: "0".repeat(64), + mime: "application/json", + bytes: new TextEncoder().encode('{"api_key":"abcdefghijklmnop"}') + } + ], + encrypted: false + }); + + expect(manifest.scanner.status).toBe("blocked"); + expect(manifest.scanner.findings).toMatchObject([{ kind: "api-key" }]); + }); + + it("reports invalid UTF-8 text candidates as opaque instead of covered", async () => { + const secret = "-----BEGIN PRIVATE KEY-----"; + const bytes = new Uint8Array(2 + secret.length * 2); + bytes.set([0xff, 0xfe]); + for (let index = 0; index < secret.length; index += 1) { + bytes[2 + index * 2] = secret.charCodeAt(index); + } + + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [{ hash: "d".repeat(64), mime: "application/json", bytes }], + encrypted: false, + preEncryption: false + }); + + expect(manifest.scanner.status).toBe("passed"); + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "decode-failed", + scannedBlobCount: 0, + opaqueBlobCount: 1 + }); + expect(() => assertPrivacyScannerPassed(manifest.scanner)).toThrow(/did not complete/i); + }); + + it("still blocks raw findings in malformed JSON while reporting incomplete coverage", async () => { + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [ + { + hash: "2".repeat(64), + mime: "application/json", + bytes: new TextEncoder().encode('{"value":"-----BEGIN PRIVATE KEY-----"') + } + ], + encrypted: false + }); + + expect(manifest.scanner).toMatchObject({ + status: "blocked", + coverage: { + complete: false, + incompleteReason: "decode-failed", + opaqueBlobCount: 1 + } + }); + }); + + it("keeps intentional binary blobs complete for pre-encryption export compatibility", async () => { + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [ + { + hash: "1".repeat(64), + mime: "application/octet-stream", + bytes: new Uint8Array([0xff, 0xfe, 0xfd]) + } + ], + encrypted: true + }); + + expect(manifest.scanner).toMatchObject({ + status: "passed", + coverage: { + complete: true, + scannedBlobCount: 0, + opaqueBlobCount: 1 + } + }); + expect(() => assertPrivacyScannerPassed(manifest.scanner)).not.toThrow(); + }); + + it("bounds target bytes and fails closed when export coverage is incomplete", async () => { + const manifest = await buildPrivacyManifest({ + events: [createEvent("E-large", { value: "x".repeat(512) })], + blobs: [], + encrypted: false, + scan: { + maxTargetBytes: 60 + } + }); + + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "target-byte-limit", + scannedEventCount: 0 + }); + expect(() => assertPrivacyScannerPassed(manifest.scanner)).toThrow(/did not complete/i); + }); + + it("does not trust a caller-supplied source length below the actual blob bytes", async () => { + const maxTargetBytes = 64; + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [ + { + hash: "6".repeat(64), + mime: "application/json", + bytes: new TextEncoder().encode(`{"value":"${"x".repeat(256)}"}`), + sourceByteLength: 1 + } + ], + encrypted: false, + scan: { maxTargetBytes } + }); + + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "target-byte-limit", + scannedBlobCount: 0, + opaqueBlobCount: 1, + inspectedBytes: 0 + }); + }); + + it("charges raw plus semantic JSON expansion to the target budget", async () => { + const bytes = new TextEncoder().encode(`{"value":"${"x".repeat(32)}"}`); + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [{ hash: "9".repeat(64), mime: "application/json", bytes }], + encrypted: false, + scan: { maxTargetBytes: bytes.byteLength + 8 } + }); + + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "target-byte-limit", + scannedBlobCount: 0, + opaqueBlobCount: 1, + inspectedBytes: bytes.byteLength + 8 + }); + }); + + it("bounds intermediate JSON semantic expansion before repeated leaves amplify it", async () => { + const largeKey = "k".repeat(50 * 1024); + const bytes = new TextEncoder().encode(JSON.stringify({ [largeKey]: Array(5_000).fill("x") })); + const maxBytes = 80 * 1024; + const startedAt = performance.now(); + + const manifest = await buildPrivacyManifest({ + events: [], + blobs: [{ hash: "7".repeat(64), mime: "application/json", bytes }], + encrypted: false, + scan: { + maxTargetBytes: maxBytes, + maxTotalBytes: maxBytes, + deadlineMs: 5_000 + } + }); + + expect(performance.now() - startedAt).toBeLessThan(250); + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "target-byte-limit", + inspectedBytes: maxBytes, + opaqueBlobCount: 1 + }); + }); + + it("stops at the configured finding bound without consuming later blobs", async () => { + let blobIteratorStarted = false; + async function* blobs() { + blobIteratorStarted = true; + yield { + hash: "e".repeat(64), + mime: "application/json", + bytes: new TextEncoder().encode("{}") + }; + } + + const manifest = await buildPrivacyManifest({ + events: [createEvent("E-secret", { value: "-----BEGIN PRIVATE KEY-----" })], + blobs: blobs(), + expectedBlobCount: 1, + encrypted: false, + scan: { + maxFindings: 1, + stopOnFinding: true + } + }); + + expect(manifest.scanner.status).toBe("blocked"); + expect(manifest.scanner.findings).toHaveLength(1); + expect(manifest.totals.blobs).toBe(1); + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "finding-limit" + }); + expect(blobIteratorStarted).toBe(false); + }); + + it("rejects a fully traversed blob source that disagrees with its declared total", async () => { + async function* oneBlob() { + yield { hash: "8".repeat(64), mime: "application/json", bytes: new Uint8Array() }; + } + + await expect( + buildPrivacyManifest({ + events: [], + blobs: oneBlob(), + expectedBlobCount: 2, + encrypted: false + }) + ).rejects.toThrow(/blob source count mismatch/i); + }); + + it("stops scanning when the deadline expires while awaiting a blob source", async () => { + const blobs: AsyncIterable<{ + hash: string; + mime: string; + bytes: Uint8Array; + }> = { + [Symbol.asyncIterator]() { + return { + next: () => new Promise(() => undefined) + }; + } + }; + + const startedAt = performance.now(); + const manifest = await buildPrivacyManifest({ + events: [], + blobs, + expectedBlobCount: 1, + encrypted: false, + scan: { deadlineMs: 10 } + }); + + expect(performance.now() - startedAt).toBeLessThan(250); + expect(manifest.totals.blobs).toBe(1); + expect(manifest.scanner.coverage).toMatchObject({ + complete: false, + incompleteReason: "deadline", + scannedBlobCount: 0 + }); + }); }); diff --git a/packages/pipeline/src/privacy.ts b/packages/pipeline/src/privacy.ts index 3cfc85e..67da5b6 100644 --- a/packages/pipeline/src/privacy.ts +++ b/packages/pipeline/src/privacy.ts @@ -5,6 +5,7 @@ import type { PrivacyManifestCategorySummary, PrivacyScannerFinding, PrivacyScannerFindingKind, + PrivacyScannerCoverage, PrivacyScannerResult, WebBlackboxEvent } from "@webblackbox/protocol"; @@ -14,11 +15,30 @@ import type { StoredBlob } from "./storage.js"; export type PrivacyManifestInput = { events: WebBlackboxEvent[]; - blobs: StoredBlob[]; + blobs: Iterable | AsyncIterable; + /** Exact total when a bounded scan may stop before consuming the blob iterable. */ + expectedBlobCount?: number; capturePolicy?: CapturePolicy; encrypted: boolean; + preEncryption?: boolean; transfer?: PrivacyManifest["transfer"]; generatedAt?: Date; + scan?: Partial; +}; + +export type PrivacyScanBlob = Pick & { + /** Indicates that a bounded reader stopped before returning the complete blob. */ + truncated?: boolean; + /** Number of source bytes observed before a bounded read stopped. */ + sourceByteLength?: number; +}; + +export type PrivacyScanOptions = { + maxTotalBytes: number; + maxTargetBytes: number; + maxFindings: number; + deadlineMs: number; + stopOnFinding: boolean; }; type ScanTarget = { @@ -32,6 +52,31 @@ type ScannerPattern = { validate?: (value: string) => boolean; }; +type ScanIncompleteReason = NonNullable; + +type EventScanText = { + text: string; + byteLength: number; + truncated: boolean; + deadlineExceeded: boolean; +}; + +type BlobScanText = { + text: string; + covered: boolean; + truncated: boolean; + deadlineExceeded: boolean; +}; + +const MEBIBYTE = 1024 * 1024; +const DEFAULT_PRIVACY_SCAN_OPTIONS: Readonly = Object.freeze({ + maxTotalBytes: 256 * MEBIBYTE, + maxTargetBytes: 8 * MEBIBYTE, + maxFindings: 1_000, + deadlineMs: 30_000, + stopOnFinding: false +}); + const SCANNER_PATTERNS: ScannerPattern[] = [ { kind: "private-key", @@ -83,16 +128,25 @@ const SCANNER_PATTERNS: ScannerPattern[] = [ export async function buildPrivacyManifest(input: PrivacyManifestInput): Promise { const generatedAt = input.generatedAt ?? new Date(); - const scanner = await scanPrivacyTargets([ - ...input.events.map((event) => ({ - path: `event:${event.id}`, - text: extractEventScanText(event) - })), - ...input.blobs.map((blob) => ({ - path: `blob:${blob.hash}`, - text: decodeBlobForScanning(blob) - })) - ]); + const inferredBlobCount = Array.isArray(input.blobs) ? input.blobs.length : undefined; + const expectedBlobCount = readOptionalNonNegativeSafeInteger( + input.expectedBlobCount ?? inferredBlobCount, + "expectedBlobCount" + ); + if ( + input.expectedBlobCount !== undefined && + inferredBlobCount !== undefined && + input.expectedBlobCount !== inferredBlobCount + ) { + throw new TypeError("Privacy scan expectedBlobCount does not match the blob array length."); + } + const { blobCount, scanner } = await scanPrivacyContent( + input.events, + input.blobs, + expectedBlobCount, + input.preEncryption !== false, + resolvePrivacyScanOptions(input.scan) + ); return { schemaVersion: 1, @@ -108,88 +162,302 @@ export async function buildPrivacyManifest(input: PrivacyManifestInput): Promise }, totals: { events: input.events.length, - blobs: input.blobs.length, + blobs: blobCount, privacyViolations: input.events.filter((event) => event.type === "privacy.violation").length } }; } -function extractEventScanText(event: WebBlackboxEvent): string { - if (event.type === "meta.config") { - return ""; +export function assertPrivacyScannerPassed(scanner: PrivacyScannerResult): void { + if (scanner.status === "blocked") { + const summary = scanner.findings + .slice(0, 5) + .map((finding) => `${finding.kind} in ${finding.path}`) + .join(", "); + throw new Error(`Privacy scanner blocked export: ${summary}`); } - const strings: string[] = []; - collectStringLeaves(event.data, strings); - collectStringLeaves(event.ref, strings); - collectStringLeaves(event.cdp, strings); - collectStringLeaves(event.frame, strings); - return strings.join("\n"); + if (scanner.coverage?.complete !== true) { + throw new Error( + `Privacy scanner did not complete export analysis (${scanner.coverage?.incompleteReason ?? "unknown reason"}).` + ); + } } -function collectStringLeaves(value: unknown, output: string[]): void { - if (typeof value === "string") { - output.push(value); - return; +async function scanPrivacyContent( + events: WebBlackboxEvent[], + blobs: Iterable | AsyncIterable, + expectedBlobCount: number | undefined, + preEncryption: boolean, + options: PrivacyScanOptions +): Promise<{ blobCount: number; scanner: PrivacyScannerResult }> { + const findings: PrivacyScannerFinding[] = []; + const coverage: PrivacyScannerCoverage = { + complete: true, + scannedEventCount: 0, + scannedBlobCount: 0, + opaqueBlobCount: 0, + inspectedBytes: 0 + }; + const deadline = monotonicNow() + options.deadlineMs; + let blobCount = 0; + let halted = false; + + const markIncomplete = (reason: ScanIncompleteReason, halt = false) => { + coverage.complete = false; + coverage.incompleteReason ??= reason; + halted ||= halt; + }; + + for (const event of events) { + if (deadlineExceeded(deadline)) { + markIncomplete("deadline", true); + break; + } + + const extracted = extractEventScanText(event, options.maxTargetBytes, deadline); + if (extracted.deadlineExceeded) { + markIncomplete("deadline", true); + break; + } + const eventFullyExtracted = !extracted.truncated; + if (!eventFullyExtracted) { + markIncomplete("target-byte-limit"); + } + + const targetBytes = extracted.byteLength; + if (coverage.inspectedBytes + targetBytes > options.maxTotalBytes) { + markIncomplete("total-byte-limit", true); + break; + } + coverage.inspectedBytes += targetBytes; + + const stopReason = await scanPrivacyTarget( + { path: `event:${event.id}`, text: extracted.text }, + findings, + options, + deadline + ); + if (stopReason) { + markIncomplete(stopReason, true); + break; + } + if (eventFullyExtracted) { + coverage.scannedEventCount += 1; + } } - if (!value || typeof value !== "object") { - return; + if (!halted || expectedBlobCount === undefined) { + const iterator = toAsyncIterator(blobs); + let iteratorFinished = false; + try { + while (!halted) { + const next = await readIteratorBeforeDeadline(iterator, deadline); + if (next.timedOut) { + markIncomplete("deadline", true); + break; + } + if (next.result.done) { + iteratorFinished = true; + break; + } + const blob = next.result.value; + blobCount += 1; + if (expectedBlobCount !== undefined && blobCount > expectedBlobCount) { + throw new Error( + `Privacy scan blob source exceeded expected count ${expectedBlobCount}: received at least ${blobCount}.` + ); + } + if (deadlineExceeded(deadline)) { + markIncomplete("deadline", true); + break; + } + if (!isPrivacyScannerTextMime(blob.mime)) { + coverage.opaqueBlobCount += 1; + continue; + } + + const sourceByteLength = blob.sourceByteLength ?? blob.bytes.byteLength; + if ( + blob.truncated || + blob.bytes.byteLength > options.maxTargetBytes || + sourceByteLength > options.maxTargetBytes + ) { + coverage.opaqueBlobCount += 1; + markIncomplete("target-byte-limit"); + continue; + } + + const decoded = decodeBlobForScanning(blob, options.maxTargetBytes, deadline); + const bounded = boundScanText(decoded.text, options.maxTargetBytes); + if (decoded.deadlineExceeded) { + coverage.opaqueBlobCount += 1; + markIncomplete("deadline", true); + break; + } + if (!decoded.covered || decoded.truncated || bounded.truncated) { + coverage.opaqueBlobCount += 1; + markIncomplete( + decoded.covered && (decoded.truncated || bounded.truncated) + ? "target-byte-limit" + : "decode-failed" + ); + } else { + coverage.scannedBlobCount += 1; + } + + if (deadlineExceeded(deadline)) { + if (decoded.covered && !decoded.truncated && !bounded.truncated) { + coverage.scannedBlobCount -= 1; + coverage.opaqueBlobCount += 1; + } + markIncomplete("deadline", true); + break; + } + if (coverage.inspectedBytes + bounded.byteLength > options.maxTotalBytes) { + if (decoded.covered && !decoded.truncated && !bounded.truncated) { + coverage.scannedBlobCount -= 1; + coverage.opaqueBlobCount += 1; + } + markIncomplete("total-byte-limit", true); + break; + } + coverage.inspectedBytes += bounded.byteLength; + + const stopReason = await scanPrivacyTarget( + { path: `blob:${blob.hash}`, text: bounded.text }, + findings, + options, + deadline + ); + if (stopReason) { + markIncomplete(stopReason, true); + } + } + } finally { + if (!iteratorFinished) { + closeIteratorWithoutWaiting(iterator); + } + } } - if (Array.isArray(value)) { - for (const item of value) { - collectStringLeaves(item, output); + if (expectedBlobCount !== undefined) { + if (!halted && blobCount !== expectedBlobCount) { + throw new Error( + `Privacy scan blob source count mismatch: expected ${expectedBlobCount}, received ${blobCount}.` + ); } - return; + if (blobCount > expectedBlobCount) { + throw new Error( + `Privacy scan blob source exceeded expected count ${expectedBlobCount}: received at least ${blobCount}.` + ); + } + blobCount = expectedBlobCount; } - for (const item of Object.values(value)) { - collectStringLeaves(item, output); + return { + blobCount, + scanner: { + scannedAt: new Date().toISOString(), + preEncryption, + status: findings.length > 0 ? "blocked" : "passed", + findings, + coverage + } + }; +} + +type DeadlineIteratorResult = + | { timedOut: true } + | { timedOut: false; result: IteratorResult }; + +function toAsyncIterator(source: Iterable | AsyncIterable): AsyncIterator { + const asyncIterator = (source as AsyncIterable)[Symbol.asyncIterator]?.(); + if (asyncIterator) { + return asyncIterator; } + + const iterator = (source as Iterable)[Symbol.iterator](); + return { + next: async () => iterator.next(), + return: iterator.return + ? async () => iterator.return?.() ?? { done: true, value: undefined } + : undefined + }; } -export function assertPrivacyScannerPassed(scanner: PrivacyScannerResult): void { - if (scanner.status === "blocked") { - const summary = scanner.findings - .slice(0, 5) - .map((finding) => `${finding.kind} in ${finding.path}`) - .join(", "); - throw new Error(`Privacy scanner blocked export: ${summary}`); +async function readIteratorBeforeDeadline( + iterator: AsyncIterator, + deadline: number +): Promise> { + const remainingMs = deadline - monotonicNow(); + if (remainingMs <= 0) { + return { timedOut: true }; + } + + const nextPromise = Promise.resolve(iterator.next()); + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise>((resolvePromise) => { + timeoutId = setTimeout(() => resolvePromise({ timedOut: true }), Math.max(1, remainingMs)); + }); + const result = await Promise.race>([ + nextPromise.then((next) => ({ timedOut: false, result: next })), + timeoutPromise + ]); + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + if (result.timedOut) { + void nextPromise.catch(() => undefined); } + return result; } -async function scanPrivacyTargets(targets: ScanTarget[]): Promise { - const findings: PrivacyScannerFinding[] = []; +function closeIteratorWithoutWaiting(iterator: AsyncIterator): void { + try { + const closing = iterator.return?.(); + if (closing) { + void Promise.resolve(closing).catch(() => undefined); + } + } catch { + // Deadline enforcement must not wait for or surface iterator cleanup failures. + } +} - for (const target of targets) { - if (!target.text) { - continue; +async function scanPrivacyTarget( + target: ScanTarget, + findings: PrivacyScannerFinding[], + options: PrivacyScanOptions, + deadline: number +): Promise { + if (!target.text) { + return null; + } + + for (const scanner of SCANNER_PATTERNS) { + if (deadlineExceeded(deadline)) { + return "deadline"; } + const matches = collectMatches(target.text, scanner); - for (const scanner of SCANNER_PATTERNS) { - const matches = collectMatches(target.text, scanner); + if (matches.length === 0) { + continue; + } - if (matches.length === 0) { - continue; - } + findings.push({ + kind: scanner.kind, + severity: "high", + path: target.path, + matchCount: matches.length, + sampleSha256: await sha256Hex(matches[0] ?? "") + }); - findings.push({ - kind: scanner.kind, - severity: "high", - path: target.path, - matchCount: matches.length, - sampleSha256: await sha256Hex(matches[0] ?? "") - }); + if (options.stopOnFinding || findings.length >= options.maxFindings) { + return "finding-limit"; } } - return { - scannedAt: new Date().toISOString(), - preEncryption: true, - status: findings.length > 0 ? "blocked" : "passed", - findings - }; + return null; } function collectMatches(text: string, scanner: ScannerPattern): string[] { @@ -249,19 +517,40 @@ function summarizePrivacyCategories(events: WebBlackboxEvent[]): PrivacyManifest return [...summaries.values()].sort((left, right) => left.category.localeCompare(right.category)); } -function decodeBlobForScanning(blob: StoredBlob): string { - if (!isLikelyTextBlob(blob.mime)) { - return ""; - } - +function decodeBlobForScanning( + blob: PrivacyScanBlob, + maxTargetBytes: number, + deadline: number +): BlobScanText { + let text: string; try { - return new TextDecoder("utf-8", { fatal: false }).decode(blob.bytes); + text = new TextDecoder("utf-8", { fatal: true }).decode(blob.bytes); } catch { - return ""; + return { text: "", covered: false, truncated: false, deadlineExceeded: false }; + } + + if (blob.mime.toLowerCase().includes("json")) { + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch { + return { text, covered: false, truncated: false, deadlineExceeded: false }; + } + + const semanticBudget = Math.max(0, maxTargetBytes - blob.bytes.byteLength - 1); + const semantic = extractStructuredScanText(parsed, semanticBudget, deadline); + return { + text: semantic.text.length > 0 ? `${text}\0${semantic.text}` : text, + covered: true, + truncated: semantic.truncated, + deadlineExceeded: semantic.deadlineExceeded + }; } + + return { text, covered: true, truncated: false, deadlineExceeded: false }; } -function isLikelyTextBlob(mime: string): boolean { +export function isPrivacyScannerTextMime(mime: string): boolean { const normalized = mime.toLowerCase(); return ( normalized.startsWith("text/") || @@ -272,6 +561,176 @@ function isLikelyTextBlob(mime: string): boolean { ); } +function extractEventScanText( + event: WebBlackboxEvent, + maxTargetBytes: number, + deadline: number +): EventScanText { + return extractStructuredScanText(event, maxTargetBytes, deadline); +} + +function extractStructuredScanText( + root: unknown, + maxTargetBytes: number, + deadline: number +): EventScanText { + const strings: string[] = []; + const stack: Array<{ value: unknown; key?: string; emitKey?: boolean }> = [{ value: root }]; + const seen = new WeakSet(); + const encoder = new TextEncoder(); + let byteLength = 0; + let visited = 0; + + const append = (fragment: string): boolean => { + const separatorBytes = strings.length > 0 ? 1 : 0; + const remaining = maxTargetBytes - byteLength - separatorBytes; + if (remaining < 0) { + return false; + } + + const encoded = encoder.encode(fragment); + if (encoded.byteLength <= remaining) { + strings.push(fragment); + byteLength += separatorBytes + encoded.byteLength; + return true; + } + if (remaining === 0) { + if (separatorBytes > 0) { + strings.push(""); + byteLength += separatorBytes; + } + return false; + } + + const target = new Uint8Array(remaining); + const { read, written } = encoder.encodeInto(fragment, target); + strings.push(fragment.slice(0, read)); + byteLength += separatorBytes + written; + return read === fragment.length; + }; + + while (stack.length > 0) { + visited += 1; + if (visited % 64 === 0 && deadlineExceeded(deadline)) { + return { + text: strings.join("\0"), + byteLength, + truncated: true, + deadlineExceeded: true + }; + } + + const current = stack.pop(); + const value = current?.value; + if (typeof value === "string") { + const target = + current?.key === undefined + ? JSON.stringify(value) + : `${JSON.stringify(current.key)}:${JSON.stringify(value)}`; + if (!append(target)) { + return { text: strings.join("\0"), byteLength, truncated: true, deadlineExceeded: false }; + } + continue; + } + if (current?.key !== undefined && current.emitKey && !append(JSON.stringify(current.key))) { + return { text: strings.join("\0"), byteLength, truncated: true, deadlineExceeded: false }; + } + if (!value || typeof value !== "object" || seen.has(value)) { + continue; + } + seen.add(value); + + if (Array.isArray(value)) { + for (let index = value.length - 1; index >= 0; index -= 1) { + stack.push({ value: value[index], key: current?.key, emitKey: false }); + } + continue; + } + const entries = Object.entries(value); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry) { + stack.push({ value: entry[1], key: entry[0], emitKey: true }); + } + } + } + + return { text: strings.join("\0"), byteLength, truncated: false, deadlineExceeded: false }; +} + +function resolvePrivacyScanOptions(input: Partial = {}): PrivacyScanOptions { + return { + maxTotalBytes: readPositiveSafeInteger( + input.maxTotalBytes, + DEFAULT_PRIVACY_SCAN_OPTIONS.maxTotalBytes, + "maxTotalBytes" + ), + maxTargetBytes: readPositiveSafeInteger( + input.maxTargetBytes, + DEFAULT_PRIVACY_SCAN_OPTIONS.maxTargetBytes, + "maxTargetBytes" + ), + maxFindings: readPositiveSafeInteger( + input.maxFindings, + DEFAULT_PRIVACY_SCAN_OPTIONS.maxFindings, + "maxFindings" + ), + deadlineMs: readPositiveSafeInteger( + input.deadlineMs, + DEFAULT_PRIVACY_SCAN_OPTIONS.deadlineMs, + "deadlineMs" + ), + stopOnFinding: input.stopOnFinding ?? DEFAULT_PRIVACY_SCAN_OPTIONS.stopOnFinding + }; +} + +function readPositiveSafeInteger( + value: number | undefined, + fallback: number, + label: string +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved <= 0) { + throw new TypeError(`Privacy scan option '${label}' must be a positive safe integer.`); + } + return resolved; +} + +function monotonicNow(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +function deadlineExceeded(deadline: number): boolean { + return monotonicNow() >= deadline; +} + +function boundScanText( + text: string, + maxBytes: number +): { text: string; byteLength: number; truncated: boolean } { + const encoded = new TextEncoder().encode(text); + if (encoded.byteLength <= maxBytes) { + return { text, byteLength: encoded.byteLength, truncated: false }; + } + + const target = new Uint8Array(maxBytes); + const { read, written } = new TextEncoder().encodeInto(text, target); + return { text: text.slice(0, read), byteLength: written, truncated: true }; +} + +function readOptionalNonNegativeSafeInteger( + value: number | undefined, + label: string +): number | undefined { + if (value === undefined) { + return undefined; + } + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError(`Privacy scan option '${label}' must be a non-negative safe integer.`); + } + return value; +} + function hasValidLuhnChecksum(value: string): boolean { const digits = value.replace(/\D/g, ""); diff --git a/packages/pipeline/src/storage.test.ts b/packages/pipeline/src/storage.test.ts index 0545121..b4cb823 100644 --- a/packages/pipeline/src/storage.test.ts +++ b/packages/pipeline/src/storage.test.ts @@ -4,10 +4,18 @@ import type { ChunkTimeIndexEntry, SessionMetadata } from "@webblackbox/protocol import { describe, expect, it, vi } from "vitest"; import { + assertPipelineResumeChangesWithinLimits, + deleteIndexedDbDatabase, derivePipelineStorageKey, EncryptedPipelineStorage, + getOrCreateIndexedDbPipelineStorageKey, IndexedDbPipelineStorage, MemoryPipelineStorage, + PIPELINE_DELIVERY_MAX_INDEXES, + PIPELINE_DELIVERY_MAX_STREAMS, + type PipelineDeliveryChange, + type PipelineDeliveryNoOutputChange, + type PipelineStorage, type StoredBlob, type StoredChunk } from "./storage.js"; @@ -64,6 +72,32 @@ function createBlob(hash: string, bytes: Uint8Array): StoredBlob { }; } +function deliveryChange( + eventIndex: number, + options: { + batchId?: string; + documentId?: string; + eventCount?: number; + eventId?: string; + fingerprint?: string; + frameId?: number; + } = {} +): PipelineDeliveryChange { + const base = { + tabId: 1, + frameId: options.frameId ?? 0, + documentId: options.documentId ?? "document-1", + batchId: options.batchId ?? "batch-1", + fingerprint: options.fingerprint ?? "d".repeat(64), + eventCount: options.eventCount ?? 2, + eventIndex + }; + + return options.eventId + ? { ...base, outcome: "event", eventId: options.eventId } + : { ...base, outcome: "no-output" }; +} + function createDbName(): string { return `wb-storage-test-${Date.now()}-${Math.random().toString(16).slice(2)}`; } @@ -103,7 +137,238 @@ 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("keeps one fail-closed durable delivery batch per document stream", async () => { + for (const storage of [ + new MemoryPipelineStorage(), + new IndexedDbPipelineStorage(createDbName()) + ]) { + const sid = `S-delivery-single-flight-${storage.constructor.name}`; + const query = { + tabId: 1, + frameId: 0, + documentId: "document-1", + batchId: "batch-1" + }; + const first = deliveryChange(0) as PipelineDeliveryNoOutputChange; + + await storage.putSession({ ...SESSION_A, sid }); + await storage.commitDeliveryProgress?.(sid, [first]); + expect(await storage.getDeliveryReceipt?.(sid, query)).toEqual({ + tabId: 1, + frameId: 0, + documentId: "document-1", + batchId: "batch-1", + fingerprint: "d".repeat(64), + eventCount: 2, + indexes: [{ eventIndex: 0, outcome: "no-output" }], + complete: false + }); + + await expect( + storage.commitDeliveryProgress?.(sid, [ + deliveryChange(1, { fingerprint: "f".repeat(64) }) as PipelineDeliveryNoOutputChange + ]) + ).rejects.toThrow(/reused with different content/i); + + await expect( + storage.commitDeliveryProgress?.(sid, [ + deliveryChange(0, { + batchId: "batch-2", + fingerprint: "e".repeat(64), + eventCount: 1 + }) as PipelineDeliveryNoOutputChange + ]) + ).rejects.toThrow(/incomplete batch/i); + + await storage.commitDeliveryProgress?.(sid, [ + deliveryChange(1) as PipelineDeliveryNoOutputChange + ]); + expect((await storage.getDeliveryReceipt?.(sid, query))?.complete).toBe(true); + + await storage.commitDeliveryProgress?.(sid, [ + deliveryChange(0, { + batchId: "batch-2", + fingerprint: "e".repeat(64), + eventCount: 1 + }) as PipelineDeliveryNoOutputChange + ]); + expect(await storage.getDeliveryReceipt?.(sid, query)).toBeUndefined(); + expect( + await storage.getDeliveryReceipt?.(sid, { + ...query, + batchId: "batch-2" + }) + ).toEqual(expect.objectContaining({ batchId: "batch-2", complete: true })); + } + }); + + it("persists all-no-output delivery completion at chunk sequence zero across restart", async () => { + const databaseName = createDbName(); + const sid = "S-delivery-no-output"; + const storage = new IndexedDbPipelineStorage(databaseName); + const changes = [0, 1].map( + (eventIndex) => deliveryChange(eventIndex) as PipelineDeliveryNoOutputChange + ); + + await storage.putSession({ ...SESSION_A, sid }); + await storage.commitDeliveryProgress(sid, changes); + + const restarted = new IndexedDbPipelineStorage(databaseName); + const receipt = await restarted.getDeliveryReceipt(sid, { + tabId: 1, + frameId: 0, + documentId: "document-1", + batchId: "batch-1" + }); + expect(receipt).toEqual(expect.objectContaining({ complete: true })); + expect(receipt?.indexes).toHaveLength(2); + expect(await restarted.listChunks(sid)).toEqual([]); + expect(await restarted.getResumeState(sid)).toBeUndefined(); + }); + + it("rolls back metadata-only delivery progress when its indexeddb transaction aborts asynchronously", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const sid = "S-delivery-metadata-rollback"; + const change = deliveryChange(0, { + documentId: "document-metadata-rollback", + batchId: "batch-metadata-rollback", + eventCount: 1 + }) as PipelineDeliveryNoOutputChange; + const query = { + tabId: change.tabId, + frameId: change.frameId, + documentId: change.documentId, + batchId: change.batchId + }; + 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 { + const request = + key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); + + if (this.name === "deliveryReceipts") { + const transaction = this.transaction; + queueMicrotask(() => { + try { + transaction.abort(); + } catch { + // A completion here would make the assertion below fail rather than masking the race. + } + }); + } + + return request; + }); + + try { + await expect(storage.commitDeliveryProgress(sid, [change])).rejects.toThrow( + /transaction (?:failed|aborted)/i + ); + } finally { + putSpy.mockRestore(); + } + + expect(await storage.getSession(sid)).toEqual(expect.objectContaining({ sid })); + expect(await storage.getDeliveryReceipt(sid, query)).toBeUndefined(); + + await storage.commitDeliveryProgress(sid, [change]); + expect(await storage.getDeliveryReceipt(sid, query)).toEqual( + expect.objectContaining({ complete: true }) + ); + }); + + it("bounds delivery streams and per-batch indexes without evicting incomplete receipts", async () => { + const storage = new MemoryPipelineStorage(); + const sid = "S-delivery-bounds"; + const changes = Array.from({ length: PIPELINE_DELIVERY_MAX_STREAMS }, (_, index) => + deliveryChange(0, { + batchId: `batch-${index}`, + documentId: `document-${index}`, + eventCount: 1 + }) + ) as PipelineDeliveryNoOutputChange[]; + + await storage.putSession({ ...SESSION_A, sid }); + await storage.commitDeliveryProgress(sid, changes); + await expect( + storage.commitDeliveryProgress(sid, [ + deliveryChange(0, { + batchId: "batch-overflow", + documentId: "document-overflow", + eventCount: 1 + }) as PipelineDeliveryNoOutputChange + ]) + ).rejects.toThrow(/too many document streams/i); + await expect( + storage.commitDeliveryProgress(sid, [ + deliveryChange(0, { + batchId: "batch-index-overflow", + documentId: "document-index-overflow", + eventCount: PIPELINE_DELIVERY_MAX_INDEXES + 1 + }) as PipelineDeliveryNoOutputChange + ]) + ).rejects.toThrow(/metadata-only delivery progress/i); + }); + it("supports memory storage CRUD and session-scoped blob ref-count cleanup", async () => { const storage = new MemoryPipelineStorage(); const hash = "f".repeat(64); @@ -194,6 +459,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)], @@ -229,18 +502,76 @@ 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); - 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(); await storage.deleteSession(sid, [hash, "b".repeat(64)]); }); + it("exposes delivery capabilities only when the inner storage supports the complete pair", async () => { + const key = await derivePipelineStorageKey("delivery-capability-passphrase", { + salt: Uint8Array.from([1, 3, 5, 7, 9, 11, 13, 15, 2, 4, 6, 8, 10, 12, 14, 16]) + }); + const createInner = (capabilities: "none" | "read-only" | "commit-only"): PipelineStorage => { + const inner = new MemoryPipelineStorage(); + + if (capabilities === "none" || capabilities === "commit-only") { + Object.defineProperty(inner, "getDeliveryReceipt", { value: undefined }); + } + if (capabilities === "none" || capabilities === "read-only") { + Object.defineProperty(inner, "commitDeliveryProgress", { value: undefined }); + } + + return inner; + }; + + for (const capabilities of ["none", "read-only", "commit-only"] as const) { + const storage = new EncryptedPipelineStorage(createInner(capabilities), { key: key.key }); + + expect(storage.getDeliveryReceipt).toBeUndefined(); + expect(storage.commitDeliveryProgress).toBeUndefined(); + } + }); + + it("delegates the complete delivery capability pair through encrypted storage", async () => { + const inner = new MemoryPipelineStorage(); + const key = await derivePipelineStorageKey("delivery-delegation-passphrase", { + salt: Uint8Array.from([16, 14, 12, 10, 8, 6, 4, 2, 15, 13, 11, 9, 7, 5, 3, 1]) + }); + const storage = new EncryptedPipelineStorage(inner, { key: key.key }); + const sid = "S-encrypted-delivery-delegation"; + const change = deliveryChange(0, { eventCount: 1 }) as PipelineDeliveryNoOutputChange; + + await storage.putSession({ ...SESSION_A, sid }); + await storage.commitDeliveryProgress?.(sid, [change]); + + expect( + await storage.getDeliveryReceipt?.(sid, { + tabId: change.tabId, + frameId: change.frameId, + documentId: change.documentId, + batchId: change.batchId + }) + ).toEqual( + expect.objectContaining({ + batchId: change.batchId, + complete: true, + indexes: [{ eventIndex: 0, outcome: "no-output" }] + }) + ); + }); + it("removes sid-tracked blob refs on indexeddb deleteSession without explicit blobHashes", async () => { const storage = new IndexedDbPipelineStorage(createDbName()); const sharedHash = "d".repeat(64); @@ -277,6 +608,497 @@ 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("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, 5, () => 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, 5, () => 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("atomically commits an output event chunk with its delivery journal index", async () => { + const storage = new IndexedDbPipelineStorage(createDbName()); + const sid = "S-delivery-atomic"; + const change = deliveryChange(0, { + eventCount: 1, + eventId: "E-1" + }); + const chunk: StoredChunk = { + ...createChunk(sid, "C-delivery", 1, "delivery-event"), + resumeDelta: { + version: 2, + sequenceWatermark: { event: 1, action: 0 }, + screenRecordingChanges: [], + deliveryChanges: [change] + } + }; + 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 === "deliveryReceipts") { + throw new Error("simulated delivery receipt write failure"); + } + + return key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key); + }); + + try { + await expect(storage.putChunk(chunk)).rejects.toThrow(/delivery receipt write failure/i); + } finally { + putSpy.mockRestore(); + } + + const query = { + tabId: 1, + frameId: 0, + documentId: "document-1", + batchId: "batch-1" + }; + expect(await storage.getChunk(sid, "C-delivery")).toBeUndefined(); + expect(await storage.getResumeState(sid)).toBeUndefined(); + expect(await storage.getDeliveryReceipt(sid, query)).toBeUndefined(); + + await storage.putChunk(chunk); + expect(await storage.getChunk(sid, "C-delivery")).toBeDefined(); + expect(await storage.getDeliveryReceipt(sid, query)).toEqual( + expect.objectContaining({ + complete: true, + indexes: [{ eventIndex: 0, outcome: "event", eventId: "E-1" }] + }) + ); + }); + + it("upgrades indexeddb v4 with v1 chunks and continues with v2 delivery receipts", async () => { + const databaseName = createDbName(); + const sid = "S-delivery-v4-upgrade"; + const legacyChunk: StoredChunk = { + ...createChunk(sid, "C-v1", 1, "legacy-v1-event"), + resumeDelta: { + version: 1, + sequenceWatermark: { event: 1, action: 0 }, + screenRecordingChanges: [] + } + }; + const db = await openRawDb(databaseName, 4, (raw) => { + const sessions = raw.createObjectStore("sessions", { keyPath: "key" }); + const chunks = raw.createObjectStore("chunks", { keyPath: "key" }); + raw.createObjectStore("blobs", { keyPath: "key" }); + const blobRefs = raw.createObjectStore("blobRefs", { keyPath: "key" }); + raw.createObjectStore("indexes", { keyPath: "key" }); + raw.createObjectStore("integrity", { keyPath: "key" }); + raw.createObjectStore("resumeMeta", { keyPath: "key" }); + const resumeRefs = raw.createObjectStore("resumeRefs", { keyPath: "key" }); + + void sessions; + chunks.createIndex("by-sid-seq", ["sid", "seq"], { unique: false }); + blobRefs.createIndex("by-sid", "sid", { unique: false }); + resumeRefs.createIndex("by-sid", "sid", { unique: false }); + resumeRefs.createIndex("by-sid-recording", ["sid", "recordingId"], { unique: false }); + }); + await writeRawRows(db, "sessions", [{ key: sid, value: { ...SESSION_A, sid } }]); + await writeRawRows(db, "chunks", [ + { + key: `${sid}:C-v1`, + sid, + seq: 1, + value: legacyChunk + } + ]); + await writeRawRows(db, "resumeMeta", [ + { + key: sid, + chunkSequence: 1, + recordingIds: [], + screenRecordingChunkCount: 0, + value: { event: 1, action: 0 } + } + ]); + db.close(); + + const storage = new IndexedDbPipelineStorage(databaseName); + expect((await storage.getChunk(sid, "C-v1"))?.resumeDelta?.version).toBe(1); + expect(await storage.getResumeState(sid, 1)).toEqual({ + sequenceWatermark: { event: 1, action: 0 }, + screenRecordings: [] + }); + + const change = deliveryChange(0, { eventCount: 1, eventId: "E-2" }); + await storage.putChunk({ + ...createChunk(sid, "C-v2", 2, "delivery-v2-event"), + resumeDelta: { + version: 2, + sequenceWatermark: { event: 2, action: 0 }, + screenRecordingChanges: [], + deliveryChanges: [change] + } + }); + + expect((await storage.listChunks(sid)).map((chunk) => chunk.meta.chunkId)).toEqual([ + "C-v1", + "C-v2" + ]); + expect( + await storage.getDeliveryReceipt(sid, { + tabId: change.tabId, + frameId: change.frameId, + documentId: change.documentId, + batchId: change.batchId + }) + ).toEqual( + expect.objectContaining({ + complete: true, + indexes: [{ eventIndex: 0, outcome: "event", eventId: "E-2" }] + }) + ); + }); + + 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"; + 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: {} + }; + const delivery = deliveryChange(0, { + documentId: "document-delete-rollback", + batchId: "batch-delete-rollback", + eventCount: 1 + }) as PipelineDeliveryNoOutputChange; + const deliveryQuery = { + tabId: delivery.tabId, + frameId: delivery.frameId, + documentId: delivery.documentId, + batchId: delivery.batchId + }; + + 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); + await storage.commitDeliveryProgress(sid, [delivery]); + + 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); + expect(await storage.getDeliveryReceipt(sid, deliveryQuery)).toEqual( + expect.objectContaining({ complete: true }) + ); + + 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(); + expect(await storage.getDeliveryReceipt(sid, deliveryQuery)).toBeUndefined(); + }); + it("supports legacy indexeddb layouts where chunks store has no sid/seq index", async () => { const sid = "S-legacy-layout"; const dbName = createDbName(); @@ -330,6 +1152,17 @@ describe("storage", () => { } ).recoverQuotaPressure.bind(storage); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const oldestDelivery = deliveryChange(0, { + documentId: "document-quota-oldest", + batchId: "batch-quota-oldest", + eventCount: 1 + }) as PipelineDeliveryNoOutputChange; + const oldestDeliveryQuery = { + tabId: oldestDelivery.tabId, + frameId: oldestDelivery.frameId, + documentId: oldestDelivery.documentId, + batchId: oldestDelivery.batchId + }; await expect(quotaRecovery("S-protected")).resolves.toBe(false); @@ -343,9 +1176,11 @@ describe("storage", () => { sid: "S-newest", startedAt: 2 }); + await storage.commitDeliveryProgress("S-oldest", [oldestDelivery]); await expect(quotaRecovery("S-newest")).resolves.toBe(true); expect(await storage.getSession("S-oldest")).toBeUndefined(); + expect(await storage.getDeliveryReceipt("S-oldest", oldestDeliveryQuery)).toBeUndefined(); expect(await storage.getSession("S-newest")).toEqual( expect.objectContaining({ sid: "S-newest" }) ); @@ -353,6 +1188,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; @@ -403,4 +1276,120 @@ 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.putSession({ ...SESSION_A, sid }); + 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(); + }); + + it("purges durable delivery receipts with the indexeddb database", async () => { + const databaseName = createDbName(); + const sid = "S-delivery-database-purge"; + const storage = new IndexedDbPipelineStorage(databaseName); + const change = deliveryChange(0, { + documentId: "document-database-purge", + batchId: "batch-database-purge", + eventCount: 1 + }) as PipelineDeliveryNoOutputChange; + const query = { + tabId: change.tabId, + frameId: change.frameId, + documentId: change.documentId, + batchId: change.batchId + }; + + await storage.putSession({ ...SESSION_A, sid }); + await storage.commitDeliveryProgress(sid, [change]); + expect(await storage.getDeliveryReceipt(sid, query)).toEqual( + expect.objectContaining({ complete: true }) + ); + + await deleteIndexedDbDatabase(databaseName); + + const reopened = new IndexedDbPipelineStorage(databaseName); + expect(await reopened.getDeliveryReceipt(sid, query)).toBeUndefined(); + expect(await reopened.getSession(sid)).toBeUndefined(); + }); }); diff --git a/packages/pipeline/src/storage.ts b/packages/pipeline/src/storage.ts index 5e751a4..f2f7810 100644 --- a/packages/pipeline/src/storage.ts +++ b/packages/pipeline/src/storage.ts @@ -12,8 +12,121 @@ 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 = { + event: number; + action: number; +}; + +export type ScreenRecordingChunkReference = { + index: number; + hash: string; + size?: number; +}; + +type ScreenRecordingChunkCheckpoint = { + hash: string; + size?: number; +}; + +export type ScreenRecordingResumeState = { + recordingId: string; + chunks: ScreenRecordingChunkReference[]; +}; + +export type PipelineResumeState = { + sequenceWatermark: RecorderSequenceWatermark; + screenRecordings: ScreenRecordingResumeState[]; +}; + +export type ScreenRecordingResumeChange = + | { + operation: "put"; + recordingId: string; + index: number; + hash: string; + size?: number; + } + | { + operation: "reset"; + recordingId: string; + } + | { + operation: "delete"; + recordingId: string; + }; + +export const PIPELINE_DELIVERY_MAX_STREAMS = 4_096; +export const PIPELINE_DELIVERY_MAX_INDEXES = 1_024; +export const PIPELINE_DELIVERY_MAX_CHANGES_PER_COMMIT = 4_096; + +export type PipelineDeliveryStream = { + tabId: number; + frameId: number; + documentId: string; +}; + +export type PipelineDeliveryBatch = PipelineDeliveryStream & { + batchId: string; + fingerprint: string; + eventCount: number; +}; + +export type PipelineDeliveryEventProvenance = PipelineDeliveryBatch & { + eventIndex: number; +}; + +export type PipelineDeliveryEventChange = PipelineDeliveryEventProvenance & { + outcome: "event"; + eventId: string; +}; + +export type PipelineDeliveryNoOutputChange = PipelineDeliveryEventProvenance & { + outcome: "no-output"; +}; + +export type PipelineDeliveryChange = PipelineDeliveryEventChange | PipelineDeliveryNoOutputChange; + +export type PipelineDeliveryReceiptIndex = + | { + eventIndex: number; + outcome: "event"; + eventId: string; + } + | { + eventIndex: number; + outcome: "no-output"; + }; + +export type PipelineDeliveryReceipt = PipelineDeliveryBatch & { + indexes: PipelineDeliveryReceiptIndex[]; + complete: boolean; +}; + +export type PipelineDeliveryReceiptQuery = PipelineDeliveryStream & { + batchId: string; +}; + +export type PipelineResumeDeltaV1 = { + version: 1; + sequenceWatermark: RecorderSequenceWatermark; + screenRecordingChanges: ScreenRecordingResumeChange[]; +}; + +export type PipelineResumeDeltaV2 = { + version: 2; + sequenceWatermark: RecorderSequenceWatermark; + screenRecordingChanges: ScreenRecordingResumeChange[]; + deliveryChanges: PipelineDeliveryChange[]; +}; + +export type PipelineResumeDelta = PipelineResumeDeltaV1 | PipelineResumeDeltaV2; + export type StoredBlob = { hash: string; mime: string; @@ -29,13 +142,37 @@ 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; 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; + getDeliveryReceipt?( + sid: string, + query: PipelineDeliveryReceiptQuery + ): Promise; + commitDeliveryProgress?( + sid: string, + changes: readonly PipelineDeliveryNoOutputChange[] + ): Promise; putBlob(blob: StoredBlob, sidHint?: string): Promise; getBlob(hash: string): Promise; listBlobs(): Promise; @@ -56,6 +193,10 @@ 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"; +const PIPELINE_RESUME_MAX_RECORDINGS = 32; +const PIPELINE_RESUME_MAX_SCREEN_CHUNKS = 500_000; export type PipelineStorageKeyOptions = { salt?: Uint8Array; @@ -72,7 +213,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(); @@ -81,6 +244,17 @@ 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 deliveryReceipts = new Map>(); + private readonly indexes = new Map(); private readonly integrity = new Map(); @@ -95,6 +269,42 @@ 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); + const deliveryReceipts = projectPipelineDeliveryReceipts( + this.deliveryReceipts.get(chunk.sid) ?? new Map(), + readPipelineDeliveryChanges(chunk.resumeDelta) + ); + existing.push(chunk); + this.chunks.set(chunk.sid, existing); + this.applyResumeDelta(chunk.sid, chunk.resumeDelta); + this.deliveryReceipts.set(chunk.sid, deliveryReceipts); + 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); } @@ -122,6 +332,70 @@ 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 getDeliveryReceipt( + sid: string, + query: PipelineDeliveryReceiptQuery + ): Promise { + requirePipelineDeliveryReceiptQuery(query); + const receipt = this.deliveryReceipts.get(sid)?.get(createPipelineDeliveryStreamKey(query)); + + return receipt?.batchId === query.batchId ? clonePipelineDeliveryReceipt(receipt) : undefined; + } + + public async commitDeliveryProgress( + sid: string, + changes: readonly PipelineDeliveryNoOutputChange[] + ): Promise { + if (!this.sessions.has(sid)) { + throw new Error("Cannot commit delivery progress for a missing pipeline session."); + } + + requirePipelineDeliveryNoOutputChanges(changes); + const projected = projectPipelineDeliveryReceipts( + this.deliveryReceipts.get(sid) ?? new Map(), + changes + ); + this.deliveryReceipts.set(sid, projected); + } + public async putBlob(blob: StoredBlob, sidHint?: string): Promise { const trackingSid = normalizeTrackingSid(sidHint); @@ -173,6 +447,10 @@ 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.deliveryReceipts.delete(sid); this.indexes.delete(sid); this.integrity.delete(sid); @@ -214,6 +492,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); + } } /** @@ -257,11 +543,108 @@ 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; + + public readonly getDeliveryReceipt?: NonNullable; + + public readonly commitDeliveryProgress?: NonNullable; + private readonly keyPromise: Promise; public constructor( @@ -269,6 +652,21 @@ 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" + }); + + if (storage.getDeliveryReceipt && storage.commitDeliveryProgress) { + this.getDeliveryReceipt = (sid, query) => storage.getDeliveryReceipt!(sid, query); + this.commitDeliveryProgress = (sid, changes) => storage.commitDeliveryProgress!(sid, changes); + } + } + + public async assertReady(): Promise { + await this.resolveKey(); + await this.storage.assertReady?.(); } public async putSession(metadata: SessionMetadata): Promise { @@ -315,6 +713,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( { @@ -371,7 +784,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 +801,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 +828,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 = { @@ -427,19 +855,59 @@ 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; +type DeliveryReceiptRow = { + key: string; + sid: string; + streamKey: string; + value: PipelineDeliveryReceipt; +}; + +const DB_VERSION = 5; 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"; +const DELIVERY_RECEIPTS_BY_SID_INDEX = "by-sid"; 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", @@ -460,19 +928,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 { @@ -525,82 +986,305 @@ export class IndexedDbPipelineStorage implements PipelineStorage { return row?.value; } - public async putBlob(blob: StoredBlob, sidHint?: string): Promise { - const trackingSid = normalizeTrackingSid(sidHint); + public async getResumeState( + sid: string, + chunkSequence?: number + ): Promise { + const db = await this.db(); - if (trackingSid && (await this.hasTrackedBlobHashForSession(trackingSid, blob.hash))) { - return; - } + 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; + } - const existing = await this.getBlob(blob.hash); + 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."); + } - if (existing) { - await this.put( - "blobs", - { - key: blob.hash, - value: { - ...existing, - refCount: existing.refCount + 1 + 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."); } - }, - { - allowQuotaRecovery: false + + 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); } - ); - if (trackingSid) { - await this.trackBlobHashForSession(trackingSid, blob.hash); + + return requirePipelineResumeState(createPipelineResumeState(meta.value, recordings)); } - return; + ); + } + + 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."); } - await this.put( - "blobs", - { - key: blob.hash, - value: blob - }, - { - allowQuotaRecovery: true, - protectedSid: sidHint + 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) + ); + } + } } ); - if (trackingSid) { - await this.trackBlobHashForSession(trackingSid, blob.hash); - } } - public async getBlob(hash: string): Promise { - const row = await this.get("blobs", hash); - return row?.value; - } + public async getDeliveryReceipt( + sid: string, + query: PipelineDeliveryReceiptQuery + ): Promise { + requirePipelineDeliveryReceiptQuery(query); + const row = await this.get( + "deliveryReceipts", + createPipelineDeliveryReceiptKey(sid, query) + ); - public async listBlobs(): Promise { - const rows = await this.getAll("blobs"); - return rows.map((row) => row.value); + if (!row || row.value.batchId !== query.batchId) { + return undefined; + } + + return requirePipelineDeliveryReceipt(row.value); } - public async putIndexes(sid: string, indexes: StoredIndexes): Promise { - await this.put( - "indexes", - { - key: sid, - value: indexes - }, - { - allowQuotaRecovery: true, - protectedSid: sid + public async commitDeliveryProgress( + sid: string, + changes: readonly PipelineDeliveryNoOutputChange[] + ): Promise { + requirePipelineDeliveryNoOutputChanges(changes); + const db = await this.db(); + + await runMultiStoreTransaction( + db, + ["sessions", "deliveryReceipts"], + "readwrite", + async (transaction) => { + const session = await requestToPromise( + transaction.objectStore("sessions").get(sid) + ); + + if (!session) { + throw new Error("Cannot commit delivery progress for a missing pipeline session."); + } + + await applyIndexedDbPipelineDeliveryChanges( + transaction.objectStore("deliveryReceipts"), + sid, + changes + ); } ); } - public async getIndexes(sid: string): Promise { - const row = await this.get("indexes", sid); - return row?.value ?? EMPTY_INDEXES; - } + public async putBlob(blob: StoredBlob, sidHint?: string): Promise { + const trackingSid = normalizeTrackingSid(sidHint); - public async putIntegrity(sid: string, manifest: HashesManifest): Promise { - await this.put( + if (trackingSid && !SHA256_HEX_PATTERN.test(blob.hash)) { + throw new Error("A session-tracked pipeline blob must have a SHA-256 hex hash."); + } + + let attempt = 0; + + 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 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(blobRefKey)) + ]); + + if (!session) { + throw new Error( + `Cannot attach pipeline blob ${blob.hash} to missing session ${trackingSid}.` + ); + } + + 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); + } + + return; + } + + blobsStore.put({ + key: blob.hash, + value: existingBlob + ? { + ...existingBlob.value, + refCount: existingBlob.value.refCount + 1 + } + : { + ...blob, + refCount: 1 + } + } satisfies BlobRow); + blobRefsStore.put({ + key: blobRefKey, + sid: trackingSid, + hash: blob.hash + } satisfies BlobRefRow); + } + ); + return; + } catch (error) { + if (!isQuotaExceededError(error) || attempt >= MAX_QUOTA_RECOVERY_ATTEMPTS) { + throw error; + } + + attempt += 1; + const recovered = await this.recoverQuotaPressure(trackingSid ?? undefined); + + if (!recovered) { + throw error; + } + } + } + } + + public async getBlob(hash: string): Promise { + const row = await this.get("blobs", hash); + return row?.value; + } + + public async listBlobs(): Promise { + const rows = await this.getAll("blobs"); + return rows.map((row) => row.value); + } + + public async putIndexes(sid: string, indexes: StoredIndexes): Promise { + await this.put( + "indexes", + { + key: sid, + value: indexes + }, + { + allowQuotaRecovery: true, + protectedSid: sid + } + ); + } + + public async getIndexes(sid: string): Promise { + const row = await this.get("indexes", sid); + return row?.value ?? EMPTY_INDEXES; + } + + public async putIntegrity(sid: string, manifest: HashesManifest): Promise { + await this.put( "integrity", { key: sid, @@ -619,24 +1303,264 @@ 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", + "resumeMeta", + "resumeRefs", + "deliveryReceipts" + ], + "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 resumeMetaStore = transaction.objectStore("resumeMeta"); + const resumeRefsStore = transaction.objectStore("resumeRefs"); + const deliveryReceiptsStore = transaction.objectStore("deliveryReceipts"); + const [session, legacyRefs, normalizedRefs, chunks] = await Promise.all([ + requestToPromise(sessionsStore.get(sid)), + requestToPromise(blobRefsStore.get(sid)), + listBlobRefsBySid(blobRefsStore, sid), + deleteChunksBySidInTransaction(chunksStore, sid), + deleteResumeRefsBySid(resumeRefsStore, sid), + deleteDeliveryReceiptsBySid(deliveryReceiptsStore, sid) + ]); + const inferredBlobHashes = collectBlobHashesFromChunks(chunks); + 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))) + ); - 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); + 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", "deliveryReceipts"], + "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); + + await applyIndexedDbPipelineDeliveryChanges( + transaction.objectStore("deliveryReceipts"), + chunk.sid, + readPipelineDeliveryChanges(delta) + ); + } + ); + 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; + } + } } } @@ -745,15 +1669,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")); @@ -771,7 +1690,10 @@ export class IndexedDbPipelineStorage implements PipelineStorage { "blobs", "blobRefs", "indexes", - "integrity" + "integrity", + "resumeMeta", + "resumeRefs", + "deliveryReceipts" ]) { if (!db.objectStoreNames.contains(storeName)) { db.createObjectStore(storeName, { keyPath: "key" }); @@ -784,9 +1706,44 @@ 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 + }); + } + + const deliveryReceiptsStore = transaction?.objectStore("deliveryReceipts"); + + if ( + deliveryReceiptsStore && + !deliveryReceiptsStore.indexNames.contains(DELIVERY_RECEIPTS_BY_SID_INDEX) + ) { + deliveryReceiptsStore.createIndex(DELIVERY_RECEIPTS_BY_SID_INDEX, "sid", { + unique: false + }); + } }; request.onsuccess = () => { + request.result.onversionchange = () => { + request.result.close(); + }; resolve(request.result); }; @@ -795,113 +1752,810 @@ export class IndexedDbPipelineStorage implements PipelineStorage { }; }); } +} - private async deleteChunksBySid(sid: string): Promise { - const db = await this.db(); +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; + const deliveryChanges = record.deliveryChanges; + const expectedKeys = record.version === 1 ? 3 : record.version === 2 ? 4 : 0; + + if ( + Object.keys(record).length !== expectedKeys || + expectedKeys === 0 || + !isRecorderSequenceWatermark(sequence) || + !Array.isArray(changes) || + changes.length > PIPELINE_RESUME_MAX_SCREEN_CHUNKS || + (record.version === 2 && + (!Array.isArray(deliveryChanges) || + deliveryChanges.length === 0 || + deliveryChanges.length > PIPELINE_DELIVERY_MAX_CHANGES_PER_COMMIT)) + ) { + return false; + } - 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 ( + changes.every(isScreenRecordingResumeChange) && + (record.version === 1 || + (deliveryChanges as unknown[]).every((candidate) => isPipelineDeliveryChange(candidate))) + ); +} + +export function isPipelineDeliveryEventProvenance( + value: unknown +): value is PipelineDeliveryEventProvenance { + const record = asStorageRecord(value); + + return Boolean( + record && + Object.keys(record).length === 7 && + isPipelineDeliveryBatch(record) && + Number.isSafeInteger(record.eventIndex) && + (record.eventIndex as number) >= 0 && + (record.eventIndex as number) < (record.eventCount as number) + ); +} + +export function isPipelineDeliveryChange(value: unknown): value is PipelineDeliveryChange { + const record = asStorageRecord(value); + + if (!record || !isPipelineDeliveryBatch(record)) { + return false; + } + + if ( + !Number.isSafeInteger(record.eventIndex) || + (record.eventIndex as number) < 0 || + (record.eventIndex as number) >= (record.eventCount as number) + ) { + return false; + } + + if (record.outcome === "no-output") { + return Object.keys(record).length === 8; + } + + return ( + record.outcome === "event" && + Object.keys(record).length === 9 && + isBoundedIdentifier(record.eventId, 128) + ); +} + +function isPipelineDeliveryBatch(record: Record): boolean { + return ( + Number.isSafeInteger(record.tabId) && + (record.tabId as number) >= 0 && + Number.isSafeInteger(record.frameId) && + (record.frameId as number) >= 0 && + isBoundedIdentifier(record.documentId, 512) && + isBoundedIdentifier(record.batchId, 128) && + typeof record.fingerprint === "string" && + SHA256_HEX_PATTERN.test(record.fingerprint) && + Number.isSafeInteger(record.eventCount) && + (record.eventCount as number) > 0 && + (record.eventCount as number) <= PIPELINE_DELIVERY_MAX_INDEXES + ); +} + +function isPipelineDeliveryReceipt(value: unknown): value is PipelineDeliveryReceipt { + const record = asStorageRecord(value); + + if ( + !record || + Object.keys(record).length !== 8 || + !isPipelineDeliveryBatch(record) || + typeof record.complete !== "boolean" || + !Array.isArray(record.indexes) || + record.indexes.length > (record.eventCount as number) + ) { + return false; + } + + const seen = new Set(); + let previous = -1; + + for (const candidate of record.indexes) { + const index = asStorageRecord(candidate); + + if ( + !index || + !Number.isSafeInteger(index.eventIndex) || + (index.eventIndex as number) < 0 || + (index.eventIndex as number) >= (record.eventCount as number) || + (index.eventIndex as number) <= previous || + seen.has(index.eventIndex as number) + ) { + return false; + } + + if (index.outcome === "no-output") { + if (Object.keys(index).length !== 2) { + return false; } + } else if ( + index.outcome !== "event" || + Object.keys(index).length !== 3 || + !isBoundedIdentifier(index.eventId, 128) + ) { + return false; + } - return requestToPromise(store.getAll()).then(async (rows) => { - for (const row of rows) { - if (row.value.sid !== sid) { - continue; - } + previous = index.eventIndex as number; + seen.add(previous); + } - await requestToPromise(store.delete(row.key)); - } - }); - }); + return record.complete === (record.indexes.length === record.eventCount); +} + +function requirePipelineDeliveryReceipt(value: unknown): PipelineDeliveryReceipt { + if (!isPipelineDeliveryReceipt(value)) { + throw new Error("Invalid pipeline delivery receipt."); } - private async resolveBlobHashesForSession(sid: string): Promise { - const tracked = await this.getTrackedBlobHashes(sid); + return clonePipelineDeliveryReceipt(value); +} - if (tracked.length > 0) { - return tracked; +function requirePipelineDeliveryReceiptQuery(query: PipelineDeliveryReceiptQuery): void { + const record = asStorageRecord(query); + + if ( + !record || + Object.keys(record).length !== 4 || + !Number.isSafeInteger(record.tabId) || + (record.tabId as number) < 0 || + !Number.isSafeInteger(record.frameId) || + (record.frameId as number) < 0 || + !isBoundedIdentifier(record.documentId, 512) || + !isBoundedIdentifier(record.batchId, 128) + ) { + throw new Error("Invalid pipeline delivery receipt query."); + } +} + +function requirePipelineDeliveryNoOutputChanges( + changes: readonly PipelineDeliveryNoOutputChange[] +): void { + if (changes.length > PIPELINE_DELIVERY_MAX_CHANGES_PER_COMMIT) { + throw new Error("Pipeline delivery progress has too many changes."); + } + + for (const change of changes) { + if (!isPipelineDeliveryChange(change) || change.outcome !== "no-output") { + throw new Error("Metadata-only delivery progress may contain only no-output changes."); } + } +} - const chunks = await this.listChunks(sid); - return [...collectBlobHashesFromChunks(chunks)]; +function readPipelineDeliveryChanges( + delta: PipelineResumeDelta +): readonly PipelineDeliveryChange[] { + return delta.version === 2 ? delta.deliveryChanges : []; +} + +function projectPipelineDeliveryReceipts( + current: ReadonlyMap, + changes: readonly PipelineDeliveryChange[] +): Map { + if (current.size > PIPELINE_DELIVERY_MAX_STREAMS) { + throw new Error("Pipeline delivery journal exceeds its stream limit."); + } + if (changes.length > PIPELINE_DELIVERY_MAX_CHANGES_PER_COMMIT) { + throw new Error("Pipeline delivery journal change set is too large."); } - private async trackBlobHashForSession(sid: string, hash: string): Promise { - if (!SHA256_HEX_PATTERN.test(hash)) { - return; + const projected = new Map(current); + + for (const change of changes) { + if (!isPipelineDeliveryChange(change)) { + throw new Error("Invalid pipeline delivery change."); } - const existing = await this.get("blobRefs", sid); - const next = mergeBlobHashes(existing?.value ?? [], [hash]); + const streamKey = createPipelineDeliveryStreamKey(change); + const existingValue = projected.get(streamKey); + const existing = existingValue ? requirePipelineDeliveryReceipt(existingValue) : undefined; + let receipt: PipelineDeliveryReceipt; - await this.put( - "blobRefs", - { - key: sid, - value: next - }, - { - allowQuotaRecovery: true, - protectedSid: sid + if (!existing || existing.batchId !== change.batchId) { + if (existing && !existing.complete) { + throw new Error("Pipeline delivery stream cannot replace an incomplete batch receipt."); + } + + receipt = { + tabId: change.tabId, + frameId: change.frameId, + documentId: change.documentId, + batchId: change.batchId, + fingerprint: change.fingerprint, + eventCount: change.eventCount, + indexes: [], + complete: false + }; + } else { + if ( + existing.fingerprint !== change.fingerprint || + existing.eventCount !== change.eventCount + ) { + throw new Error("Pipeline delivery batch identity was reused with different content."); } + + receipt = existing; + } + + const priorIndex = receipt.indexes.find( + (candidate) => candidate.eventIndex === change.eventIndex ); + const nextIndex: PipelineDeliveryReceiptIndex = + change.outcome === "event" + ? { eventIndex: change.eventIndex, outcome: "event", eventId: change.eventId } + : { eventIndex: change.eventIndex, outcome: "no-output" }; + + if (priorIndex) { + if ( + priorIndex.outcome !== nextIndex.outcome || + (priorIndex.outcome === "event" && + (nextIndex.outcome !== "event" || priorIndex.eventId !== nextIndex.eventId)) + ) { + throw new Error("Pipeline delivery event index was reused with a different outcome."); + } + } else { + receipt.indexes.push(nextIndex); + receipt.indexes.sort((left, right) => left.eventIndex - right.eventIndex); + receipt.complete = receipt.indexes.length === receipt.eventCount; + } + + projected.set(streamKey, receipt); + + if (projected.size > PIPELINE_DELIVERY_MAX_STREAMS) { + throw new Error("Pipeline delivery journal has too many document streams."); + } } - private async getTrackedBlobHashes(sid: string): Promise { - const row = await this.get("blobRefs", sid); - return normalizeBlobHashes(row?.value ?? []); + return projected; +} + +function clonePipelineDeliveryReceipt(receipt: PipelineDeliveryReceipt): PipelineDeliveryReceipt { + return { + tabId: receipt.tabId, + frameId: receipt.frameId, + documentId: receipt.documentId, + batchId: receipt.batchId, + fingerprint: receipt.fingerprint, + eventCount: receipt.eventCount, + indexes: receipt.indexes.map((index) => ({ ...index })), + complete: receipt.complete + }; +} + +function createPipelineDeliveryStreamKey(stream: PipelineDeliveryStream): string { + return JSON.stringify([stream.tabId, stream.frameId, stream.documentId]); +} + +function createPipelineDeliveryReceiptKey(sid: string, stream: PipelineDeliveryStream): string { + return JSON.stringify([sid, stream.tabId, stream.frameId, stream.documentId]); +} + +function isBoundedIdentifier(value: unknown, maxLength: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maxLength; +} + +function isScreenRecordingResumeChange(candidate: unknown): boolean { + const change = asStorageRecord(candidate); + + if (!change || !isValidRecordingId(change.recordingId)) { + return false; } - private async hasTrackedBlobHashForSession(sid: string, hash: string): Promise { - if (!SHA256_HEX_PATTERN.test(hash)) { + 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 tracked = await this.getTrackedBlobHashes(sid); - return tracked.includes(hash); + 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); + } } - private async deleteTrackedBlobHashes(sid: string): Promise { - const db = await this.db(); - await runTransaction(db, "blobRefs", "readwrite", (store) => { - return requestToPromise(store.delete(sid)); + 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) + ); } - private async decrementOrDeleteBlob(hash: string): Promise { - const existing = await this.getBlob(hash); + return requestToPromise( + store.index(BLOB_REFS_BY_SID_INDEX).getAll(IDBKeyRange.only(sid)) + ); +} - if (!existing) { - return; +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."); } - if (existing.refCount <= 1) { - const db = await this.db(); - await runTransaction(db, "blobs", "readwrite", (store) => { - return requestToPromise(store.delete(hash)); - }); - return; + 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"); +} + +async function applyIndexedDbPipelineDeliveryChanges( + store: IDBObjectStore, + sid: string, + changes: readonly PipelineDeliveryChange[] +): Promise { + if (changes.length === 0) { + return; + } + if (!store.indexNames.contains(DELIVERY_RECEIPTS_BY_SID_INDEX)) { + throw new Error("IndexedDB delivery-receipt index is unavailable."); + } + + const grouped = new Map(); + + for (const change of changes) { + if (!isPipelineDeliveryChange(change)) { + throw new Error("Invalid pipeline delivery change."); } - await this.put( - "blobs", - { - key: hash, - value: { - ...existing, - refCount: existing.refCount - 1 - } - }, - { - allowQuotaRecovery: false + const streamKey = createPipelineDeliveryStreamKey(change); + const group = grouped.get(streamKey) ?? []; + group.push(change); + grouped.set(streamKey, group); + } + + const existingCount = await requestToPromise( + store.index(DELIVERY_RECEIPTS_BY_SID_INDEX).count(IDBKeyRange.only(sid)) + ); + if (existingCount > PIPELINE_DELIVERY_MAX_STREAMS) { + throw new Error("Pipeline delivery journal exceeds its stream limit."); + } + + const groups = [...grouped.entries()]; + const existingRows = await Promise.all( + groups.map(([, group]) => { + const first = group[0]!; + return requestToPromise( + store.get(createPipelineDeliveryReceiptKey(sid, first)) + ); + }) + ); + let projectedCount = existingCount; + + for (let index = 0; index < groups.length; index += 1) { + const [streamKey, group] = groups[index]!; + const existingRow = existingRows[index]; + const current = existingRow + ? new Map([[streamKey, requirePipelineDeliveryReceipt(existingRow.value)]]) + : new Map(); + const projected = projectPipelineDeliveryReceipts(current, group); + const receipt = projected.get(streamKey); + + if (!receipt) { + throw new Error("Pipeline delivery receipt projection failed."); + } + + if (!existingRow) { + projectedCount += 1; + if (projectedCount > PIPELINE_DELIVERY_MAX_STREAMS) { + throw new Error("Pipeline delivery journal has too many document streams."); } - ); + } + + const first = group[0]!; + store.put({ + key: createPipelineDeliveryReceiptKey(sid, first), + sid, + streamKey, + value: receipt + } satisfies DeliveryReceiptRow); } } +function deleteDeliveryReceiptsBySid(store: IDBObjectStore, sid: string): Promise { + if (!store.indexNames.contains(DELIVERY_RECEIPTS_BY_SID_INDEX)) { + return Promise.reject(new Error("IndexedDB delivery-receipt index is unavailable.")); + } + + const request = store.index(DELIVERY_RECEIPTS_BY_SID_INDEX).openCursor(IDBKeyRange.only(sid)); + return deleteCursorRows(request, "IndexedDB delivery-receipt 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; @@ -1069,6 +2723,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, @@ -1090,6 +2822,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 = () => { @@ -1102,8 +2870,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")); }; @@ -1112,10 +2906,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(); }; 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/CHANGELOG.md b/packages/player-sdk/CHANGELOG.md index 81daa3a..c4557d0 100644 --- a/packages/player-sdk/CHANGELOG.md +++ b/packages/player-sdk/CHANGELOG.md @@ -1,5 +1,17 @@ # @webblackbox/player-sdk +## 0.7.0 + +### Minor Changes + +- b3cfda9: Add bounded transient blob reads and fail closed on malformed UTF-8, ambiguous ZIP entries, + non-canonical blobs, inconsistent indexes, and archives that exceed configured resource limits. + +### Patch Changes + +- Updated dependencies [b3cfda9] + - @webblackbox/protocol@0.7.0 + ## 0.6.0 ### Minor Changes 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/player-sdk/README.md b/packages/player-sdk/README.md index 35ec106..10b5517 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 @@ -119,6 +135,10 @@ if (blob) { console.log(blob.mime); // "image/webp" console.log(blob.bytes); // Uint8Array } + +// Trusted analyzers can enforce a per-read ceiling without populating the +// Player blob cache. Integrity and decryption checks are still applied. +const transient = await player.readBlobTransient("abc123...", 2 * 1024 * 1024); ``` ## Analysis APIs @@ -348,6 +368,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 dde0fd9..2d1b823 100644 --- a/packages/player-sdk/package.json +++ b/packages/player-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@webblackbox/player-sdk", "description": "Archive playback, querying, analysis, and code-generation SDK for WebBlackbox sessions.", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -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", @@ -49,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..8c023f3 --- /dev/null +++ b/packages/player-sdk/src/archive-resource-limits.test.ts @@ -0,0 +1,493 @@ +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 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 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)); + 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); + }); +}); + +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) { + 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; +} + +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 new file mode 100644 index 0000000..7cdd2f8 --- /dev/null +++ b/packages/player-sdk/src/archive-resource-limits.ts @@ -0,0 +1,677 @@ +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_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; + 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 physicalCentralDirectoryOffset = offset - centralDirectoryBytes; + const archiveZero = physicalCentralDirectoryOffset - centralDirectoryOffset; + if (!Number.isSafeInteger(archiveZero) || archiveZero < 0) { + return Number.POSITIVE_INFINITY; + } + + const physicalEntryCount = countPhysicalCentralDirectoryEntries( + view, + physicalCentralDirectoryOffset, + offset, + archiveZero + ); + 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, + zip64.recordOffset - zip64.centralDirectoryBytes - zip64.centralDirectoryOffset + ); + return physicalEntryCount === null + ? Number.POSITIVE_INFINITY + : Math.max(zip64.entryCount, physicalEntryCount); + } + + return null; +} + +function countPhysicalCentralDirectoryEntries( + view: DataView, + centralDirectoryOffset: number, + centralDirectoryEnd: number, + archiveZero: number +): number | null { + if ( + !Number.isSafeInteger(centralDirectoryOffset) || + !Number.isSafeInteger(centralDirectoryEnd) || + !Number.isSafeInteger(archiveZero) || + centralDirectoryOffset < 0 || + centralDirectoryEnd > view.byteLength || + archiveZero < 0 + ) { + return null; + } + + let count = 0; + let offset = centralDirectoryOffset; + const names = new Set(); + + 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); + const fileNameOffset = offset + 46; + 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; + } + + 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, + 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 +): { + entryCount: number; + centralDirectoryBytes: number; + centralDirectoryOffset: 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, + centralDirectoryOffset: 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); + const centralDirectoryOffset = view.getBigUint64(zip64Offset + 48, true); + if ( + entryCount > 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 + }; + } + + return { + entryCount: Number(entryCount), + centralDirectoryBytes: Number(centralDirectoryBytes), + centralDirectoryOffset: Number(centralDirectoryOffset), + 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..bba1863 --- /dev/null +++ b/packages/player-sdk/src/bounded-zip-reader.test.ts @@ -0,0 +1,219 @@ +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("stops a transient read when its caller-specific byte budget is crossed", async () => { + const fullBytes = 2 * 1024 * 1024; + const callerLimit = 1024 * 1024; + const zip = new JSZip(); + zip.file("large.bin", new Uint8Array(fullBytes)); + const source = await zip.generateAsync({ + type: "uint8array", + compression: "DEFLATE", + compressionOptions: { level: 9 } + }); + const loaded = await JSZip.loadAsync(source); + const reader = new BoundedZipReader(loaded, resolveArchiveResourceLimits()); + + const error = await reader + .readBounded("large.bin", callerLimit) + .catch((reason: unknown) => (reason instanceof Error ? reason : new Error(String(reason)))); + + expect(error).toMatchObject({ + name: "ArchiveEntryReadLimitError", + limit: callerLimit + }); + expect((error as Error & { actual: number }).actual).toBeGreaterThan(callerLimit); + expect((error as Error & { actual: number }).actual).toBeLessThan(fullBytes); + }); + + 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..677a745 --- /dev/null +++ b/packages/player-sdk/src/bounded-zip-reader.ts @@ -0,0 +1,401 @@ +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); + } + + /** @internal */ + public readBounded(path: string, maxBytes: number): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) { + throw new TypeError("Invalid archive entry read limit."); + } + + return this.readEntry(path, Math.min(maxBytes, this.limits.maxEntryUncompressedBytes)); + } + + private deleteRead(path: string, pending: Promise): void { + if (this.reads.get(path) === pending) { + this.reads.delete(path); + } + } + + private async readEntry(path: string, callerMaxBytes?: number): 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( + Math.min( + callerMaxBytes ?? this.limits.maxEntryUncompressedBytes, + 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; + if (callerMaxBytes !== undefined && nextBytes > callerMaxBytes) { + throw new ArchiveEntryReadLimitError(path, callerMaxBytes, nextBytes); + } + 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); + } +} + +/** @internal */ +export class ArchiveEntryReadLimitError extends Error { + public override readonly name = "ArchiveEntryReadLimitError"; + + public constructor( + public readonly path: string, + public readonly limit: number, + public readonly actual: number + ) { + super(`Archive entry '${path}' exceeds its read limit (${actual} > ${limit}).`); + } +} diff --git a/packages/player-sdk/src/index.test.ts b/packages/player-sdk/src/index.test.ts index 479e58f..326642b 100644 --- a/packages/player-sdk/src/index.test.ts +++ b/packages/player-sdk/src/index.test.ts @@ -1,11 +1,44 @@ 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"; +type HarExportLimits = Parameters[1]; + +const FIXTURE_BLOB_HASH = fixtureBlobHash(1); +const FIXTURE_PNG_HASH = fixtureBlobHash(2); +const FIXTURE_BIN_HASH = fixtureBlobHash(3); +const RICH_BODY_HASH = fixtureBlobHash(4); +const RICH_CPU_HASH = fixtureBlobHash(5); +const RICH_HEAP_HASH = fixtureBlobHash(6); +const RICH_DOM_FIRST_HASH = fixtureBlobHash(7); +const RICH_DOM_SECOND_HASH = fixtureBlobHash(8); +const LITE_DOM_FIRST_HASH = fixtureBlobHash(9); +const LITE_DOM_SECOND_HASH = fixtureBlobHash(10); + +function fixtureBlobHash(value: number): string { + return value.toString(16).padStart(64, "0"); +} + +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(); @@ -49,11 +82,27 @@ describe("WebBlackboxPlayer", () => { const searchResults = player.search("api"); expect(searchResults[0]?.eventId).toBe("E-3"); - const blob = await player.getBlob("blob1"); + const blob = await player.getBlob(FIXTURE_BLOB_HASH); expect(blob?.mime).toBe("image/webp"); 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(FIXTURE_BLOB_HASH); + const second = await player.getBlob(FIXTURE_BLOB_HASH); + + 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(FIXTURE_BLOB_HASH)).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); @@ -76,6 +125,107 @@ describe("WebBlackboxPlayer", () => { expect(preview.samples.some((sample) => sample.snippet.includes("…"))).toBe(true); }); + it("does not promote incomplete or legacy scanner coverage to passed", async () => { + const source = await createPrivacyFixtureArchive(); + const incompleteZip = await JSZip.loadAsync(source); + incompleteZip.file( + "privacy/manifest.json", + JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + categories: [], + scanner: { + scannedAt: new Date(0).toISOString(), + preEncryption: true, + status: "passed", + findings: [], + coverage: { + complete: false, + scannedEventCount: 0, + scannedBlobCount: 0, + opaqueBlobCount: 0, + inspectedBytes: 0, + incompleteReason: "deadline" + } + }, + encryption: { archive: "plaintext" }, + totals: { events: 1, blobs: 0, privacyViolations: 0 } + }) + ); + await writeIntegrityManifest(incompleteZip); + const incompletePlayer = await WebBlackboxPlayer.open( + await incompleteZip.generateAsync({ type: "uint8array" }) + ); + + expect(incompletePlayer.getPrivacyProtectionReport().scanner).toMatchObject({ + status: "unknown", + coverage: { + complete: false, + incompleteReason: "deadline" + } + }); + + const legacyZip = await JSZip.loadAsync(source); + legacyZip.file( + "privacy/manifest.json", + JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + categories: [], + scanner: { + scannedAt: new Date(0).toISOString(), + preEncryption: true, + status: "passed", + findings: [] + }, + encryption: { archive: "plaintext" }, + totals: { events: 1, blobs: 0, privacyViolations: 0 } + }) + ); + await writeIntegrityManifest(legacyZip); + const legacyPlayer = await WebBlackboxPlayer.open( + await legacyZip.generateAsync({ type: "uint8array" }) + ); + + expect(legacyPlayer.getPrivacyProtectionReport().scanner).toEqual({ + preEncryption: true, + status: "unknown", + findingCount: 0, + coverage: null + }); + }); + + it("rejects complete scanner coverage whose counts contradict archive totals", async () => { + const zip = await JSZip.loadAsync(await createPrivacyFixtureArchive()); + zip.file( + "privacy/manifest.json", + JSON.stringify({ + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + categories: [], + scanner: { + scannedAt: new Date(0).toISOString(), + preEncryption: true, + status: "passed", + findings: [], + coverage: { + complete: true, + scannedEventCount: 999, + scannedBlobCount: 999, + opaqueBlobCount: 0, + inspectedBytes: 0 + } + }, + encryption: { archive: "plaintext" }, + totals: { events: 1, blobs: 0, privacyViolations: 0 } + }) + ); + await writeIntegrityManifest(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow(/coverage exceeds archive totals/i); + }); + it("supports range-preloaded open via time index chunks", async () => { const bytes = await createTwoChunkFixtureArchive(); const player = await WebBlackboxPlayer.open(bytes, { @@ -102,6 +252,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; @@ -133,46 +388,64 @@ describe("WebBlackboxPlayer", () => { it("infers blob mime types by extension and rejects empty blob hashes", async () => { const fixture = await createFixtureArchive(); const zip = await JSZip.loadAsync(fixture); - zip.file("blobs/sha256-blobpng.png", Uint8Array.from([9])); - zip.file("blobs/sha256-blobbin.bin", Uint8Array.from([8])); + zip.file(`blobs/sha256-${FIXTURE_PNG_HASH}.png`, Uint8Array.from([9])); + zip.file(`blobs/sha256-${FIXTURE_BIN_HASH}.bin`, Uint8Array.from([8])); await writeIntegrityManifest(zip); const bytes = await zip.generateAsync({ type: "uint8array" }); const player = await WebBlackboxPlayer.open(bytes); await expect(player.getBlob(" ")).resolves.toBeNull(); - await expect(player.getBlob("blobpng")).resolves.toEqual( + await expect(player.getBlob(FIXTURE_PNG_HASH)).resolves.toEqual( expect.objectContaining({ mime: "image/png" }) ); - await expect(player.getBlob("blobbin")).resolves.toEqual( + await expect(player.getBlob(FIXTURE_BIN_HASH)).resolves.toEqual( expect.objectContaining({ mime: "application/octet-stream" }) ); }); - it("parses chunks lazily when queried", async () => { + it("reads bounded blobs transiently without retaining them in the Player cache", async () => { + const player = await WebBlackboxPlayer.open(await createFixtureArchive()); + const blobReads = (player as unknown as { blobReads: Map }).blobReads; + + await expect(player.readBlobTransient(FIXTURE_BLOB_HASH, 1024)).resolves.toMatchObject({ + mime: "image/webp" + }); + expect(blobReads.size).toBe(0); + + await expect(player.readBlobTransient(FIXTURE_BLOB_HASH, 1)).rejects.toMatchObject({ + name: "ArchiveEntryReadLimitError", + limit: 1 + }); + expect(blobReads.size).toBe(0); + }); + + 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" })]); + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow(/chunk-000002.*malformed JSON/i); + }); - expect(() => - player.query({ - range: { - monoStart: 90, - monoEnd: 120 - } - }) - ).toThrow(/chunk-000002/i); + it.each([ + { path: "manifest.json", marker: "https://example.com" }, + { path: "index/time.json", marker: "chunk-000001" }, + { path: "events/chunk-000001.ndjson", marker: "https://example.com" } + ])("rejects invalid UTF-8 in $path", async ({ path, marker }) => { + const zip = await JSZip.loadAsync(await createFixtureArchive()); + const file = zip.file(path); + if (!file) { + throw new Error(`Missing fixture archive path '${path}'.`); + } + const content = await file.async("uint8array"); + replaceFirstByteSequence(content, new TextEncoder().encode(marker), 0xff); + zip.file(path, content); + await writeIntegrityHashes(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow(/invalid UTF-8/i); }); it("memoizes full-range queries and derived analyzers", async () => { @@ -185,7 +458,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(); @@ -214,6 +487,23 @@ describe("WebBlackboxPlayer", () => { const player = await WebBlackboxPlayer.open(bytes); heapSamples.push(process.memoryUsage().heapUsed); + const waterfallSpy = vi.spyOn(player, "getNetworkWaterfall"); + try { + await expect( + player.exportHarWithLimits(undefined, createHarExportLimits({ maxEntries: 1 })) + ).rejects.toMatchObject({ + name: "HarExportResourceLimitError", + resource: "maxEntries", + limit: 1, + actual: 2, + completedEntries: 0, + bodyBytesRead: 0 + }); + expect(waterfallSpy).not.toHaveBeenCalled(); + } finally { + waterfallSpy.mockRestore(); + } + const searchResults = player.search("large-session-checkpoint", 25); heapSamples.push(process.memoryUsage().heapUsed); const networkWaterfall = player.getNetworkWaterfall(); @@ -227,8 +517,8 @@ describe("WebBlackboxPlayer", () => { heapSamples.push(process.memoryUsage().heapUsed); const domSnapshots = player.getDomSnapshots(); heapSamples.push(process.memoryUsage().heapUsed); - const firstScreenshot = await player.getBlob("large-shot-0000"); - const firstResponseBody = await player.getBlob("large-body-0000"); + const firstScreenshot = await player.getBlob(fixtureBlobHash(200_000)); + const firstResponseBody = await player.getBlob(fixtureBlobHash(100_000)); heapSamples.push(process.memoryUsage().heapUsed); const heapPeak = Math.max(...heapSamples); const heapBaseline = Math.min(...heapSamples); @@ -259,6 +549,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-${FIXTURE_BLOB_HASH}.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(FIXTURE_BLOB_HASH)).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"); @@ -270,7 +589,7 @@ describe("WebBlackboxPlayer", () => { expect(player.query({ types: ["network.request"] })).toHaveLength(1); - const blob = await player.getBlob("blob1"); + const blob = await player.getBlob(FIXTURE_BLOB_HASH); expect(Array.from(blob?.bytes ?? [])).toEqual([1, 2, 3]); }); @@ -298,6 +617,202 @@ 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 independently valid chunk ranges that regress across a chunk boundary", async () => { + const zip = await JSZip.loadAsync(await createTwoChunkFixtureArchive()); + const timeIndex = JSON.parse( + await zip.file("index/time.json")!.async("string") + ) as ChunkTimeIndexEntry[]; + const second = timeIndex[1]; + + if (!second) { + throw new Error("Missing second time-index fixture entry"); + } + + second.monoStart = 5; + zip.file("index/time.json", JSON.stringify(timeIndex)); + await writeIntegrityHashes(zip); + const bytes = await zip.generateAsync({ type: "uint8array" }); + + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow( + /monotonic ranges move backwards between chunks/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; @@ -329,13 +844,9 @@ describe("WebBlackboxPlayer", () => { await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow("integrity/hashes.json"); }); - it("does not resolve blobs that omit the sha256- prefix in path names", async () => { + it("rejects blobs that omit the sha256- prefix in path names", async () => { const bytes = await createArchiveWithDeprecatedBlobPath(); - const player = await WebBlackboxPlayer.open(bytes); - - await expect(player.getBlob("blob1")).resolves.toBeNull(); - await expect(player.getBlob("sha256-blob1")).resolves.toBeNull(); - await expect(player.getBlob("blobs/blob1.webp")).resolves.toBeNull(); + await expect(WebBlackboxPlayer.open(bytes)).rejects.toThrow(/invalid blob path/i); }); it("rejects archives with tampered manifest contents", async () => { @@ -354,12 +865,32 @@ describe("WebBlackboxPlayer", () => { it("rejects tampered blobs on demand", async () => { const bytes = await tamperArchiveFile( await createFixtureArchive(), - "blobs/sha256-blob1.webp", + `blobs/sha256-${FIXTURE_BLOB_HASH}.webp`, Uint8Array.from([9, 9, 9, 9]) ); const player = await WebBlackboxPlayer.open(bytes); - await expect(player.getBlob("blob1")).rejects.toThrow(/integrity mismatch/i); + await expect(player.getBlob(FIXTURE_BLOB_HASH)).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-${FIXTURE_BLOB_HASH}.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-${FIXTURE_BLOB_HASH}.webp`, 1); + + const player = await WebBlackboxPlayer.open(bytes, { + resourceLimits: { maxEntryUncompressedBytes: 1024 * 1024 } + }); + await expect(player.getBlob(FIXTURE_BLOB_HASH)).rejects.toMatchObject({ + name: "ArchiveResourceLimitError", + resource: "maxEntryUncompressedBytes" + }); }); it("rejects archives with undeclared event chunks", async () => { @@ -517,27 +1048,112 @@ describe("WebBlackboxPlayer", () => { expect(waterfall).toHaveLength(1); expect(waterfall[0]?.reqId).toBe("R-1"); expect(waterfall[0]?.status).toBe(200); - expect(waterfall[0]?.responseBodyHash).toBe("blob-body-1"); + expect(waterfall[0]?.responseBodyHash).toBe(RICH_BODY_HASH); const curl = player.generateCurl("R-1"); - expect(curl).toContain("curl 'https://example.com/api'"); - expect(curl).toContain("-X POST"); + expect(curl).toContain("curl --url 'https://example.com/api'"); + expect(curl).toContain("-X 'POST'"); const fetchSnippet = player.generateFetch("R-1"); 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("accounts the exact outer JSON-string encoding for bounded HAR output", async () => { + const player = await WebBlackboxPlayer.open(await createRichFixtureArchive()); + const getBlobSpy = vi.spyOn(player, "getBlob"); + const querySpy = vi.spyOn(player, "query"); + + try { + const generousLimits = createHarExportLimits(); + const har = await player.exportHarWithLimits(undefined, generousLimits); + const exactEncodedBytes = Buffer.byteLength(JSON.stringify(har), "utf8"); + + await expect( + player.exportHarWithLimits(undefined, { + ...generousLimits, + maxEncodedBytes: exactEncodedBytes + }) + ).resolves.toBe(har); + await expect( + player.exportHarWithLimits(undefined, { + ...generousLimits, + maxEncodedBytes: exactEncodedBytes - 1 + }) + ).rejects.toMatchObject({ + name: "HarExportResourceLimitError", + resource: "maxEncodedBytes", + limit: exactEncodedBytes - 1 + }); + expect(getBlobSpy).not.toHaveBeenCalled(); + expect(querySpy).not.toHaveBeenCalled(); + } finally { + getBlobSpy.mockRestore(); + querySpy.mockRestore(); + } + }); + + it("rejects query-field explosions before deriving HAR parameter objects", async () => { + const query = Array.from({ length: 1_025 }, (_, index) => `field${index}=x`).join("&"); + const player = await WebBlackboxPlayer.open( + await createRichFixtureArchive("POST", `https://example.com/api?${query}`) + ); + const getBlobSpy = vi.spyOn(player, "getBlob"); + + try { + await expect( + player.exportHarWithLimits(undefined, createHarExportLimits()) + ).rejects.toMatchObject({ + name: "HarExportResourceLimitError", + resource: "maxDerivedFields", + limit: 1_024, + actual: 1_025, + completedEntries: 0, + bodyBytesRead: 0 + }); + expect(getBlobSpy).not.toHaveBeenCalled(); + } finally { + getBlobSpy.mockRestore(); + } + }); + + 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("binds dash-leading request URLs to curl's URL option", async () => { + const bytes = await createRichFixtureArchive("GET", "--config"); + const player = await WebBlackboxPlayer.open(bytes); + const curl = player.generateCurl("R-1"); + + expect(curl?.split("\n", 1)[0]).toBe("curl --url '--config' \\"); + expect(curl).not.toMatch(/^curl\s+'--config'/); }); it("builds storage timeline, report, and playwright script", async () => { @@ -586,9 +1202,20 @@ 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"); + expect(domSnapshots[0]?.contentHash).toBe(RICH_DOM_FIRST_HASH); const domDiff = await player.compareDomSnapshots("E-18", "E-19"); expect(domDiff).not.toBeNull(); @@ -602,7 +1229,7 @@ describe("WebBlackboxPlayer", () => { it("diffs lite DOM snapshots stored as HTML blobs", async () => { const bytes = await createLiteDomFixtureArchive(); const player = await WebBlackboxPlayer.open(bytes); - const blob = await player.getBlob("dom-lite-1"); + const blob = await player.getBlob(LITE_DOM_FIRST_HASH); expect(blob?.mime).toBe("text/html"); @@ -657,7 +1284,10 @@ async function createFixtureArchive(): Promise { mono: 1, type: "meta.session.start", id: "E-1", - data: {} + data: { + url: "https://example.com", + mode: "full" + } }, { v: 1, @@ -684,7 +1314,9 @@ async function createFixtureArchive(): Promise { act: "A-1" }, data: { - url: "https://example.com/api" + reqId: "R-1", + url: "https://example.com/api", + method: "GET" } }, { @@ -700,6 +1332,7 @@ async function createFixtureArchive(): Promise { act: "A-1" }, data: { + reqId: "R-1", status: 200 } }, @@ -747,7 +1380,7 @@ async function createFixtureArchive(): Promise { zip.file("index/req.json", JSON.stringify([{ reqId: "R-1", eventIds: ["E-3", "E-4"] }])); zip.file("index/inv.json", JSON.stringify([{ term: "api", eventIds: ["E-3"] }])); zip.file("events/chunk-000001.ndjson", events.map((event) => JSON.stringify(event)).join("\n")); - zip.file("blobs/sha256-blob1.webp", new Uint8Array([1, 2, 3])); + zip.file(`blobs/sha256-${FIXTURE_BLOB_HASH}.webp`, new Uint8Array([1, 2, 3])); await writeIntegrityManifest(zip); @@ -816,6 +1449,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]" @@ -894,7 +1529,7 @@ async function createLargePressureArchive(): Promise { const mono = index * 125 + 10; const actionId = `A-large-${index.toString().padStart(4, "0")}`; const reqId = `R-large-${index.toString().padStart(4, "0")}`; - const bodyHash = `large-body-${index.toString().padStart(4, "0")}`; + const bodyHash = fixtureBlobHash(100_000 + index); const eventIds: string[] = []; const clickId = `E-large-${index.toString().padStart(4, "0")}-click`; @@ -999,7 +1634,7 @@ async function createLargePressureArchive(): Promise { invertedIndex[0]?.eventIds.push(consoleId); if (index % 12 === 0) { - const shotHash = `large-shot-${index.toString().padStart(4, "0")}`; + const shotHash = fixtureBlobHash(200_000 + index); events.push({ v: 1, sid, @@ -1526,7 +2161,10 @@ async function createArchiveWithDeprecatedBlobPath(): Promise { return zip.generateAsync({ type: "uint8array" }); } -async function createRichFixtureArchive(): Promise { +async function createRichFixtureArchive( + requestMethod = "POST", + requestUrl = "https://example.com/api" +): Promise { const zip = new JSZip(); const events: WebBlackboxEvent[] = [ { @@ -1570,8 +2208,8 @@ async function createRichFixtureArchive(): Promise { data: { requestId: "R-1", request: { - method: "POST", - url: "https://example.com/api", + method: requestMethod, + url: requestUrl, headers: { "content-type": "application/json", authorization: "Bearer token" @@ -1594,6 +2232,7 @@ async function createRichFixtureArchive(): Promise { data: { requestId: "R-1", response: { + url: requestUrl, status: 200, statusText: "OK", mimeType: "application/json", @@ -1616,7 +2255,7 @@ async function createRichFixtureArchive(): Promise { }, data: { reqId: "R-1", - contentHash: "blob-body-1", + contentHash: RICH_BODY_HASH, size: 42 } }, @@ -1732,7 +2371,7 @@ async function createRichFixtureArchive(): Promise { id: "E-18", data: { snapshotId: "D-1", - contentHash: "dom-hash-1", + contentHash: RICH_DOM_FIRST_HASH, source: "cdp", nodeCount: 5, reason: "interval" @@ -1748,7 +2387,7 @@ async function createRichFixtureArchive(): Promise { id: "E-19", data: { snapshotId: "D-2", - contentHash: "dom-hash-2", + contentHash: RICH_DOM_SECOND_HASH, source: "cdp", nodeCount: 5, reason: "freeze:error" @@ -1763,7 +2402,7 @@ async function createRichFixtureArchive(): Promise { type: "perf.cpu.profile", id: "E-20", data: { - profileHash: "cpu-hash-1", + profileHash: RICH_CPU_HASH, size: 128, reason: "freeze:error" } @@ -1777,7 +2416,7 @@ async function createRichFixtureArchive(): Promise { type: "perf.heap.snapshot", id: "E-21", data: { - snapshotHash: "heap-hash-1", + snapshotHash: RICH_HEAP_HASH, size: 256, reason: "freeze:error" } @@ -1816,16 +2455,16 @@ async function createRichFixtureArchive(): Promise { ); zip.file("index/inv.json", JSON.stringify([{ term: "unexpected", eventIds: ["E-16"] }])); zip.file("events/chunk-000001.ndjson", events.map((event) => JSON.stringify(event)).join("\n")); - zip.file("blobs/sha256-blob1.webp", new Uint8Array([1, 2, 3])); - zip.file("blobs/sha256-blob-body-1.json", new TextEncoder().encode('{"ok":true}')); - zip.file("blobs/sha256-cpu-hash-1.json", new TextEncoder().encode('{"nodes":[]}')); - zip.file("blobs/sha256-heap-hash-1.json", new TextEncoder().encode('{"snapshot":true}')); + zip.file(`blobs/sha256-${FIXTURE_BLOB_HASH}.webp`, new Uint8Array([1, 2, 3])); + zip.file(`blobs/sha256-${RICH_BODY_HASH}.json`, new TextEncoder().encode('{"ok":true}')); + zip.file(`blobs/sha256-${RICH_CPU_HASH}.json`, new TextEncoder().encode('{"nodes":[]}')); + zip.file(`blobs/sha256-${RICH_HEAP_HASH}.json`, new TextEncoder().encode('{"snapshot":true}')); zip.file( - "blobs/sha256-dom-hash-1.json", + `blobs/sha256-${RICH_DOM_FIRST_HASH}.json`, new TextEncoder().encode(JSON.stringify(createDomSnapshotPayload(["DIV", "P"]))) ); zip.file( - "blobs/sha256-dom-hash-2.json", + `blobs/sha256-${RICH_DOM_SECOND_HASH}.json`, new TextEncoder().encode(JSON.stringify(createDomSnapshotPayload(["DIV", "SPAN"]))) ); @@ -1857,7 +2496,7 @@ async function createLiteDomFixtureArchive(): Promise { id: "E-lite-1", data: { snapshotId: "D-lite-1", - contentHash: "dom-lite-1", + contentHash: LITE_DOM_FIRST_HASH, source: "html", nodeCount: 3, reason: "interval" @@ -1873,7 +2512,7 @@ async function createLiteDomFixtureArchive(): Promise { id: "E-lite-2", data: { snapshotId: "D-lite-2", - contentHash: "dom-lite-2", + contentHash: LITE_DOM_SECOND_HASH, source: "html", nodeCount: 4, reason: "interval" @@ -1911,11 +2550,11 @@ async function createLiteDomFixtureArchive(): Promise { zip.file("index/inv.json", JSON.stringify([])); zip.file("events/chunk-000001.ndjson", events.map((event) => JSON.stringify(event)).join("\n")); zip.file( - "blobs/sha256-dom-lite-1.html", + `blobs/sha256-${LITE_DOM_FIRST_HASH}.html`, new TextEncoder().encode("

                                                                                                                                                    ") ); zip.file( - "blobs/sha256-dom-lite-2.html", + `blobs/sha256-${LITE_DOM_SECOND_HASH}.html`, new TextEncoder().encode("
                                                                                                                                                    ") ); @@ -1934,7 +2573,27 @@ async function tamperArchiveFile( return zip.generateAsync({ type: "uint8array" }); } +function replaceFirstByteSequence( + target: Uint8Array, + sequence: Uint8Array, + replacement: number +): void { + for (let offset = 0; offset <= target.byteLength - sequence.byteLength; offset += 1) { + if (sequence.every((value, index) => target[offset + index] === value)) { + target[offset] = replacement; + return; + } + } + + throw new Error("Fixture byte sequence was not found."); +} + 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()) { @@ -1964,6 +2623,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(""); @@ -1992,6 +2753,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"); @@ -2140,3 +2934,40 @@ 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); + } + } + } +} + +function createHarExportLimits(overrides: Partial = {}): HarExportLimits { + return { + maxEntries: 100, + maxBodyBytes: 1024 * 1024, + maxTotalBodyBytes: 2 * 1024 * 1024, + maxEntryMetadataBytes: 256 * 1024, + maxHeaderFields: 512, + maxDerivedFields: 1_024, + maxHarBytes: 3 * 1024 * 1024, + maxEncodedBytes: 4 * 1024 * 1024, + ...overrides + }; +} diff --git a/packages/player-sdk/src/index.ts b/packages/player-sdk/src/index.ts index d6d17ad..953e205 100644 --- a/packages/player-sdk/src/index.ts +++ b/packages/player-sdk/src/index.ts @@ -8,11 +8,48 @@ import type { HashesManifest, InvertedIndexEntry, PrivacyManifest, + PrivacyScannerCoverage, RequestIndexEntry, WebBlackboxEvent, WebBlackboxEventType } from "@webblackbox/protocol"; -import { extractRequestId, inferBlobMime } from "@webblackbox/protocol"; +import { + assertArchiveChunk, + assertArchiveEventIndexes, + assertArchiveLayout, + extractRequestId, + inferBlobMime, + isCanonicalArchiveBlobPath, + parseArchivedEvent, + parseExportManifest, + parseHashesManifest, + parseInvertedIndex, + parsePrivacyManifest, + parseRequestIndex, + 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 { ArchiveEntryReadLimitError, 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 { ArchiveEntryReadLimitError, BoundedZipReader } from "./bounded-zip-reader.js"; /** Player lifecycle status. */ export type PlayerStatus = "idle" | "loaded"; @@ -24,6 +61,7 @@ export type PlayerOpenInput = ArrayBuffer | Uint8Array | Blob; export type PlayerOpenOptions = { passphrase?: string; range?: PlayerRange; + resourceLimits?: Partial; }; /** Monotonic-time query range in milliseconds. */ @@ -171,6 +209,7 @@ export type PrivacyProtectionReport = { preEncryption: boolean; status: "passed" | "blocked" | "unknown"; findingCount: number; + coverage: PrivacyScannerCoverage | null; }; }; @@ -402,14 +441,26 @@ type BlobRef = { mime: string; }; +/** Decrypted or plaintext blob content resolved from an archive hash. */ +export 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([ @@ -445,7 +496,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; @@ -454,6 +504,7 @@ type EventChunkSource = { monoStart: number; monoEnd: number; bytes: Uint8Array; + events: WebBlackboxEvent[]; }; type EventChunkDescriptor = { @@ -463,6 +514,13 @@ type EventChunkDescriptor = { monoStart: number; monoEnd: number; codec: ChunkCodec; + index: ChunkTimeIndexEntry; + selected: boolean; +}; + +type EventChunkReadResult = { + chunks: EventChunkSource[]; + events: WebBlackboxEvent[]; }; /** @@ -475,7 +533,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[]; @@ -497,18 +555,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; @@ -552,57 +613,92 @@ 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 = await readJson(zip, "integrity/hashes.json"); + const integrity = parseHashesManifest(await readJsonValue(zipReader, "integrity/hashes.json")); assertArchiveFileSet(zip, integrity); - await assertManifestIntegrity(zip, integrity); - const manifest = await readJson(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 = await readIntegrityArchiveJson( - zip, - integrity, - "index/time.json", - archiveKey, - encryptedFiles + const timeIndex = parseTimeIndex( + await readIntegrityArchiveJsonValue( + zipReader, + integrity, + "index/time.json", + archiveKey, + encryptedFiles + ) ); - const requestIndex = await readIntegrityArchiveJson( - zip, - integrity, + decodeBudget.commitIndex("index/time.json", timeIndex.length, 0); + const requestIndex = parseRequestIndex( + await readIntegrityArchiveJsonValue( + zipReader, + integrity, + "index/req.json", + archiveKey, + encryptedFiles + ) + ); + decodeBudget.commitIndex( "index/req.json", - archiveKey, - encryptedFiles + requestIndex.length, + countIndexEventReferences(requestIndex) ); - const invertedIndex = await readIntegrityArchiveJson( - zip, - integrity, + const invertedIndex = parseInvertedIndex( + await readIntegrityArchiveJsonValue( + zipReader, + integrity, + "index/inv.json", + archiveKey, + encryptedFiles + ) + ); + decodeBudget.commitIndex( "index/inv.json", - archiveKey, - encryptedFiles + invertedIndex.length, + countIndexEventReferences(invertedIndex) ); - const privacyManifest = await readOptionalIntegrityArchiveJson( - zip, + const privacyValue = await readOptionalIntegrityArchiveJsonValue( + zipReader, integrity, "privacy/manifest.json", archiveKey, encryptedFiles ); - const eventChunks = await readEventChunkSources( - zip, + const privacyManifest = privacyValue === null ? null : parsePrivacyManifest(privacyValue); + + assertArchiveLayout({ + paths: archiveFilePaths(zip), + manifest, + timeIndex, + requestIndex, + invertedIndex, + privacyManifest + }); + + const eventChunkResult = await readEventChunkSources( + zipReader, archiveKey, encryptedFiles, { range: options.range, - timeIndex, - defaultCodec: manifest.chunkCodec + timeIndex }, - integrity + integrity, + decodeBudget ); + assertArchiveEventIndexes(manifest, eventChunkResult.events, requestIndex, invertedIndex); + return new WebBlackboxPlayer( zip, + zipReader, { manifest, timeIndex, @@ -611,7 +707,7 @@ export class WebBlackboxPlayer { integrity, privacyManifest }, - eventChunks, + eventChunkResult.chunks, archiveKey, encryptedFiles ); @@ -790,7 +886,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) { @@ -827,27 +923,70 @@ 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 (!this.zipReader.has(blob.path)) { + return null; + } + + const cached = this.blobReads.get(blob.path); + if (cached) { + return clonePlayerBlob(await cached); + } + + 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); + } + + /** Reads and verifies one blob without retaining it in the Player cache. */ + public async readBlobTransient(hash: string, maxBytes: number): Promise { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) { + throw new TypeError("Transient blob read limit must be a positive safe integer."); + } - if (!file) { + const blob = resolveBlobByKey(this.blobsByHash, hash); + if (!blob || !this.zipReader.has(blob.path)) { return null; } - const rawBytes = await file.async("uint8array"); - await assertArchiveFileIntegrity(this.zip, this.archive.integrity, blob.path, rawBytes); + const encryptionOverhead = this.encryptedFiles[blob.path] ? 16 : 0; + const storedByteLimit = addSafeInteger(maxBytes, encryptionOverhead); + let rawBytes: Uint8Array; + + try { + rawBytes = await this.zipReader.readBounded(blob.path, storedByteLimit); + } catch (error) { + if (error instanceof ArchiveEntryReadLimitError) { + throw new ArchiveEntryReadLimitError( + blob.path, + maxBytes, + Math.max(0, error.actual - encryptionOverhead) + ); + } + throw error; + } + + await assertArchiveFileIntegrity(this.archive.integrity, blob.path, rawBytes); const bytes = await this.decryptArchiveFile(blob.path, rawBytes); + if (bytes.byteLength > maxBytes) { + throw new ArchiveEntryReadLimitError(blob.path, maxBytes, bytes.byteLength); + } - return { - mime: blob.mime, - bytes - }; + return { mime: blob.mime, bytes }; } /** Builds action-span aggregates and total counters for the selected range. */ @@ -944,13 +1083,22 @@ export class WebBlackboxPlayer { scanner: this.archive.privacyManifest ? { preEncryption: this.archive.privacyManifest.scanner.preEncryption, - status: this.archive.privacyManifest.scanner.status, - findingCount: this.archive.privacyManifest.scanner.findings.length + status: + this.archive.privacyManifest.scanner.status === "blocked" + ? "blocked" + : this.archive.privacyManifest.scanner.coverage?.complete === true + ? "passed" + : "unknown", + findingCount: this.archive.privacyManifest.scanner.findings.length, + coverage: this.archive.privacyManifest.scanner.coverage + ? { ...this.archive.privacyManifest.scanner.coverage } + : null } : { preEncryption: false, status: "unknown", - findingCount: 0 + findingCount: 0, + coverage: null } }; } @@ -1465,7 +1613,10 @@ export class WebBlackboxPlayer { return null; } - const lines = [`curl ${shellQuote(entry.url)} \\`, ` -X ${entry.method.toUpperCase()} \\`]; + const lines = [ + `curl --url ${shellQuote(entry.url)} \\`, + ` -X ${shellQuote(entry.method.toUpperCase())} \\` + ]; for (const [name, value] of Object.entries(entry.requestHeaders)) { lines.push(` -H ${shellQuote(`${name}: ${value}`)} \\`); @@ -1500,9 +1651,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 = { @@ -1670,7 +1828,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 +1867,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();" ]; @@ -1747,6 +1905,104 @@ export class WebBlackboxPlayer { return lines.join("\n"); } + /** @internal */ + public async exportHarWithLimits( + range: PlayerRange | undefined, + limits: HarExportLimits + ): Promise { + const resolvedLimits = resolveHarExportLimits(limits); + const networkBuckets = this.getNetworkBucketsWithLimit(range, resolvedLimits.maxEntries); + + const started = new Date(this.eventChunks[0]?.events[0]?.t ?? Date.now()).toISOString(); + const title = this.archive.manifest.site.title ?? this.archive.manifest.site.origin; + const titleBytes = utf8ByteLengthAtMost(title, resolvedLimits.maxEntryMetadataBytes); + if (titleBytes > resolvedLimits.maxEntryMetadataBytes) { + throw new HarExportResourceLimitError( + "maxEntryMetadataBytes", + resolvedLimits.maxEntryMetadataBytes, + titleBytes, + 0, + 0 + ); + } + + const writer = new BoundedHarJsonWriter(resolvedLimits); + writer.append( + `{"log":{"version":"1.2","creator":{"name":"WebBlackbox","version":"1.0.0"},"pages":[${JSON.stringify( + { + startedDateTime: started, + id: "page_1", + title, + pageTimings: { + onContentLoad: -1, + onLoad: -1 + } + } + )}],"entries":[`, + 0, + 0 + ); + + let completedEntries = 0; + let totalBodyBytes = 0; + + for (const bucket of networkBuckets) { + const entry = toNetworkEntry(bucket); + assertHarEntryMetadataBudget(entry, resolvedLimits, completedEntries, totalBodyBytes); + assertHarEntryFieldBudget(entry, resolvedLimits, completedEntries, totalBodyBytes); + + let responseBody: PlayerBlob | null = null; + if (entry.responseBodyHash) { + const remainingBodyBytes = resolvedLimits.maxTotalBodyBytes - totalBodyBytes; + if (remainingBodyBytes <= 0) { + throw new HarExportResourceLimitError( + "maxTotalBodyBytes", + resolvedLimits.maxTotalBodyBytes, + totalBodyBytes + 1, + completedEntries, + totalBodyBytes + ); + } + + const bodyLimit = Math.min(resolvedLimits.maxBodyBytes, remainingBodyBytes); + try { + responseBody = await this.readHarResponseBody(entry.responseBodyHash, bodyLimit); + } catch (error) { + if (!(error instanceof ArchiveEntryReadLimitError)) { + throw error; + } + + const resource = + bodyLimit < resolvedLimits.maxBodyBytes ? "maxTotalBodyBytes" : "maxBodyBytes"; + const actual = + resource === "maxTotalBodyBytes" ? totalBodyBytes + error.actual : error.actual; + throw new HarExportResourceLimitError( + resource, + resolvedLimits[resource], + actual, + completedEntries, + totalBodyBytes + Math.min(error.actual, bodyLimit) + ); + } + + if (responseBody) { + totalBodyBytes += responseBody.bytes.byteLength; + } + } + + const serializedEntry = JSON.stringify(toHarEntry(entry, responseBody ?? undefined)); + writer.append( + `${completedEntries === 0 ? "" : ","}${serializedEntry}`, + completedEntries, + totalBodyBytes + ); + completedEntries += 1; + } + + writer.append("]}}", completedEntries, totalBodyBytes); + return writer.finish(); + } + private async loadDomPaths(snapshot: DomSnapshotRef): Promise> { if (snapshot.contentHash) { const blob = await this.getBlob(snapshot.contentHash); @@ -1821,6 +2077,39 @@ export class WebBlackboxPlayer { throw new Error("Unable to decrypt archive content. The passphrase may be invalid."); } } + + private async readHarResponseBody(hash: string, maxBytes: number): Promise { + return this.readBlobTransient(hash, maxBytes); + } + + private getNetworkBucketsWithLimit( + range: PlayerRange | undefined, + maxEntries: number + ): MutableNetworkBucket[] { + const buckets = new Map(); + + for (const chunk of this.getChunksForRange(range)) { + for (const event of this.getChunkEvents(chunk)) { + if (!withinRange(event, range) || !NETWORK_EVENT_TYPES.has(event.type)) { + continue; + } + + const reqId = extractRequestId(event); + if (!reqId) { + continue; + } + if (!buckets.has(reqId) && buckets.size >= maxEntries) { + throw new HarExportResourceLimitError("maxEntries", maxEntries, maxEntries + 1, 0, 0); + } + + addNetworkEventToBuckets(buckets, event, reqId, false); + } + } + + return [...buckets.values()].sort( + (left, right) => left.startMono - right.startMono || left.reqId.localeCompare(right.reqId) + ); + } } /** Returns the default pre-open player status. */ @@ -1853,49 +2142,60 @@ function collectNetworkBuckets(events: WebBlackboxEvent[]): MutableNetworkBucket continue; } - const bucket = buckets.get(reqId) ?? { - reqId, - events: [], - startMono: event.mono, - endMono: event.mono, - startWallTime: event.t, - endWallTime: event.t - }; + addNetworkEventToBuckets(buckets, event, reqId); + } - bucket.events.push(event); - bucket.startMono = Math.min(bucket.startMono, event.mono); - bucket.endMono = Math.max(bucket.endMono, event.mono); - bucket.startWallTime = Math.min(bucket.startWallTime, event.t); - bucket.endWallTime = Math.max(bucket.endWallTime, event.t); + return [...buckets.values()]; +} - if (!bucket.actionId && event.ref?.act) { - bucket.actionId = event.ref.act; - } +function addNetworkEventToBuckets( + buckets: Map, + event: WebBlackboxEvent, + reqId: string, + includeEventId = true +): void { + const bucket = buckets.get(reqId) ?? { + reqId, + events: [], + startMono: event.mono, + endMono: event.mono, + startWallTime: event.t, + endWallTime: event.t + }; - if (event.type === "network.request" && !bucket.request) { - bucket.request = event; - } + if (includeEventId) { + bucket.events.push(event); + } + bucket.startMono = Math.min(bucket.startMono, event.mono); + bucket.endMono = Math.max(bucket.endMono, event.mono); + bucket.startWallTime = Math.min(bucket.startWallTime, event.t); + bucket.endWallTime = Math.max(bucket.endWallTime, event.t); - if (event.type === "network.response") { - bucket.response = event; - } + if (!bucket.actionId && event.ref?.act) { + bucket.actionId = event.ref.act; + } - if (event.type === "network.finished") { - bucket.finished = event; - } + if (event.type === "network.request" && !bucket.request) { + bucket.request = event; + } - if (event.type === "network.failed") { - bucket.failed = event; - } + if (event.type === "network.response") { + bucket.response = event; + } - if (event.type === "network.body") { - bucket.body = event; - } + if (event.type === "network.finished") { + bucket.finished = event; + } - buckets.set(reqId, bucket); + if (event.type === "network.failed") { + bucket.failed = event; } - return [...buckets.values()]; + if (event.type === "network.body") { + bucket.body = event; + } + + buckets.set(reqId, bucket); } function toNetworkEntry(bucket: MutableNetworkBucket): NetworkWaterfallEntry { @@ -2375,7 +2675,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 +2707,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); @@ -2419,7 +2738,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"]); @@ -2452,10 +2774,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 @@ -2473,6 +2792,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, @@ -2976,107 +3349,136 @@ async function resolveArchiveReadKey( } async function readEventChunkSources( - zip: JSZip, + zipReader: BoundedZipReader, archiveKey: CryptoKey | null, encryptedFiles: Record, options: { range?: PlayerRange; timeIndex?: ChunkTimeIndexEntry[]; - defaultCodec?: ChunkCodec; - } = {}, - integrity?: HashesManifest -): Promise { - const descriptors = buildEventChunkDescriptors(zip, options); + }, + integrity: HashesManifest, + decodeBudget: ArchiveDecodeBudget +): 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; - } - - 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); - - chunks.push({ + 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, decodeBudget); + if (descriptor.selected) { + chunk.events = chunkEvents; + } + + assertArchiveChunk({ + path, + index: descriptor.index, + encodedByteLength: decrypted.byteLength, + encodedSha256: await sha256Hex(decrypted), + events: chunkEvents }); + chunk.bytes = new Uint8Array(0); + + if (descriptor.selected) { + chunks.push(chunk); + } + + for (const event of chunkEvents) { + events.push(event); + } } - 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) })); } -function parseChunkEvents(chunk: EventChunkSource): WebBlackboxEvent[] { - const content = new TextDecoder().decode(chunk.bytes); - const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); +function parseChunkEvents( + chunk: EventChunkSource, + decodeBudget: ArchiveDecodeBudget +): WebBlackboxEvent[] { + const content = decodeArchiveUtf8(chunk.bytes, chunk.path); const events: WebBlackboxEvent[] = []; + let lineStart = 0; + let lineNumber = 0; + + 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; + } - for (const line of lines) { + decodeBudget.commitEvents(chunk.path, 1); + const line = content.slice(lineStart, lineEnd); try { - events.push(JSON.parse(line) as WebBlackboxEvent); + events.push(parseArchivedEvent(JSON.parse(line) as unknown, chunk.path, lineNumber)); } 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 ${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) && @@ -3118,30 +3520,48 @@ 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 { +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; } @@ -3150,7 +3570,9 @@ async function decodeChunkBytes(bytes: Uint8Array, codec: ChunkCodec): Promise { if (typeof DecompressionStream === "undefined" || typeof Blob === "undefined") { return null; @@ -3158,11 +3580,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; } } @@ -3170,99 +3605,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()]); + + if (!zlib || !nodeStream?.Readable?.toWeb) { + return null; + } - while (true) { - const { done, value } = await reader.read(); + const options = { chunkSize: 16 * 1024 }; + let codecStream: NodeJS.ReadWriteStream | null = null; - if (done) { - break; + 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" || @@ -3272,8 +3688,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; } @@ -3295,11 +3711,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; @@ -3430,60 +3841,100 @@ function updateActionStats(span: ActionSpan, event: WebBlackboxEvent): void { } } -async function readJson(zip: JSZip, path: string): Promise { - const content = await readZipFileText(zip, path); - return JSON.parse(content) as TValue; +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 readIntegrityArchiveJson( - zip: JSZip, +async function readJsonValue(zipReader: BoundedZipReader, path: string): Promise { + return readTransientZipEntry(zipReader, path, (bytes) => { + const content = decodeArchiveUtf8(bytes, path); + return parseArchiveJson(content, path); + }); +} + +async function readIntegrityArchiveJsonValue( + 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 JSON.parse(new TextDecoder().decode(bytes)) as TValue; +): Promise { + return readTransientZipEntry(zipReader, path, async (rawBytes) => { + await assertArchiveFileIntegrity(integrity, path, rawBytes); + const bytes = await decryptArchiveBytes(path, rawBytes, archiveKey, encryptedFiles); + return parseArchiveJson(decodeArchiveUtf8(bytes, path), path); + }); +} + +function decodeArchiveUtf8(bytes: Uint8Array, path: string): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`Invalid WebBlackbox archive: '${path}' contains invalid UTF-8.`); + } +} + +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 readOptionalIntegrityArchiveJson( - zip: JSZip, +async function readOptionalIntegrityArchiveJsonValue( + zipReader: BoundedZipReader, integrity: HashesManifest, path: string, archiveKey: CryptoKey | null, encryptedFiles: Record -): Promise { - if (!zip.file(path)) { +): Promise { + if (!zipReader.has(path)) { return null; } - return readIntegrityArchiveJson(zip, integrity, path, archiveKey, encryptedFiles); + return readIntegrityArchiveJsonValue(zipReader, integrity, path, archiveKey, encryptedFiles); } -async function readZipFileBytes(zip: JSZip, path: string): Promise { - const file = zip.file(path); - - if (!file) { - throw new Error(`Archive is missing required file: ${path}`); +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.`); } - - 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"); +function archiveFilePaths(zip: JSZip): string[] { + return Object.entries(zip.files) + .filter(([, file]) => !file.dir) + .map(([path]) => path); } -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"); @@ -3510,10 +3961,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]; @@ -3521,7 +3971,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}`); @@ -3529,6 +3979,9 @@ async function assertArchiveFileIntegrity( } function parseBlobPath(path: string): { hash: string; extension: string } | null { + if (!isCanonicalArchiveBlobPath(path)) { + return null; + } const prefixed = /^blobs\/sha256-([^.]+)\.(.+)$/.exec(path); if (!prefixed) { @@ -3705,29 +4158,321 @@ 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; + 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" + ); + } + + bytes = new Uint8Array(await input.arrayBuffer()); + } else { + throw new Error("Unsupported archive input type."); + } + + assertArchiveInputResourceLimits(bytes, resourceLimits); + return bytes; +} + +/** @internal */ +type HarExportLimits = { + maxEntries: number; + maxBodyBytes: number; + maxTotalBodyBytes: number; + maxEntryMetadataBytes: number; + maxHeaderFields: number; + maxDerivedFields: number; + maxHarBytes: number; + maxEncodedBytes: number; +}; + +/** @internal */ +type HarExportResourceName = keyof HarExportLimits; + +/** @internal */ +class HarExportResourceLimitError extends Error { + public override readonly name = "HarExportResourceLimitError"; + + public constructor( + public readonly resource: HarExportResourceName, + public readonly limit: number, + public readonly actual: number, + public readonly completedEntries: number, + public readonly bodyBytesRead: number + ) { + super( + `HAR limit ${resource} exceeded (${actual} > ${limit}; ` + + `entries=${completedEntries}; bodyBytes=${bodyBytesRead}).` + ); } +} + +class BoundedHarJsonWriter { + private readonly chunks: string[] = []; + + private rawBytes = 0; + + // JSON.stringify(harText) contributes surrounding quotes in addition to escaped contents. + private encodedBytes = 2; + + public constructor(private readonly limits: Readonly) {} + + public append(chunk: string, completedEntries: number, bodyBytesRead: number): void { + const rawChunkBytes = utf8ByteLengthAtMost( + chunk, + Math.max(0, this.limits.maxHarBytes - this.rawBytes) + ); + const nextRawBytes = addSafeInteger(this.rawBytes, rawChunkBytes); + if (nextRawBytes > this.limits.maxHarBytes) { + throw new HarExportResourceLimitError( + "maxHarBytes", + this.limits.maxHarBytes, + nextRawBytes, + completedEntries, + bodyBytesRead + ); + } - if (input instanceof ArrayBuffer) { - return new Uint8Array(input); + const encodedChunkBytes = jsonStringContentByteLengthAtMost( + chunk, + Math.max(0, this.limits.maxEncodedBytes - this.encodedBytes) + ); + const nextEncodedBytes = addSafeInteger(this.encodedBytes, encodedChunkBytes); + if (nextEncodedBytes > this.limits.maxEncodedBytes) { + throw new HarExportResourceLimitError( + "maxEncodedBytes", + this.limits.maxEncodedBytes, + nextEncodedBytes, + completedEntries, + bodyBytesRead + ); + } + + this.rawBytes = nextRawBytes; + this.encodedBytes = nextEncodedBytes; + this.chunks.push(chunk); + } + + public finish(): string { + return this.chunks.join(""); + } +} + +function resolveHarExportLimits(limits: HarExportLimits): HarExportLimits { + if (!limits || typeof limits !== "object") { + throw new TypeError("HAR export limits are required."); } - if (typeof Blob !== "undefined" && input instanceof Blob) { - return new Uint8Array(await input.arrayBuffer()); + const values = [ + limits.maxEntries, + limits.maxBodyBytes, + limits.maxTotalBodyBytes, + limits.maxEntryMetadataBytes, + limits.maxHeaderFields, + limits.maxDerivedFields, + limits.maxHarBytes, + limits.maxEncodedBytes + ]; + if (values.some((value) => !Number.isSafeInteger(value) || value <= 0)) { + throw new TypeError("HAR export limits must be positive safe integers."); } - throw new Error("Unsupported archive input type."); + return { ...limits }; +} + +function assertHarEntryMetadataBudget( + entry: NetworkWaterfallEntry, + limits: Readonly, + completedEntries: number, + bodyBytesRead: number +): void { + let metadataBytes = 0; + + const addString = (value: string | undefined): void => { + if (value === undefined) { + return; + } + + const addedBytes = utf8ByteLengthAtMost( + value, + Math.max(0, limits.maxEntryMetadataBytes - metadataBytes) + ); + metadataBytes = addSafeInteger(metadataBytes, addedBytes); + if (metadataBytes > limits.maxEntryMetadataBytes) { + throw new HarExportResourceLimitError( + "maxEntryMetadataBytes", + limits.maxEntryMetadataBytes, + metadataBytes, + completedEntries, + bodyBytesRead + ); + } + }; + + const addHeaders = (headers: Record): void => { + for (const [name, value] of Object.entries(headers)) { + addString(name); + addString(value); + } + }; + + addString(entry.method); + addString(entry.url); + addString(entry.statusText); + addString(entry.mimeType); + addString(entry.requestBodyText); + addHeaders(entry.requestHeaders); + addHeaders(entry.responseHeaders); +} + +function assertHarEntryFieldBudget( + entry: NetworkWaterfallEntry, + limits: Readonly, + completedEntries: number, + bodyBytesRead: number +): void { + const headerFields = + Object.keys(entry.requestHeaders).length + Object.keys(entry.responseHeaders).length; + if (headerFields > limits.maxHeaderFields) { + throw new HarExportResourceLimitError( + "maxHeaderFields", + limits.maxHeaderFields, + headerFields, + completedEntries, + bodyBytesRead + ); + } + + const queryStart = entry.url.indexOf("?"); + const queryEnd = entry.url.indexOf("#", queryStart + 1); + const derivedFields = + (queryStart < 0 ? 0 : countDelimitedFields(entry.url, "&", queryStart + 1, queryEnd)) + + countDelimitedFields(entry.requestHeaders.cookie ?? "", ";") + + countDelimitedFields(entry.responseHeaders["set-cookie"] ?? "", ";"); + if (derivedFields > limits.maxDerivedFields) { + throw new HarExportResourceLimitError( + "maxDerivedFields", + limits.maxDerivedFields, + derivedFields, + completedEntries, + bodyBytesRead + ); + } +} + +function countDelimitedFields( + value: string, + delimiter: string, + start = 0, + end = value.length +): number { + if (start < 0 || start >= value.length || start === end) { + return 0; + } + + let fields = 1; + const stop = end < 0 ? value.length : end; + for (let index = start; index < stop; index += 1) { + if (value[index] === delimiter) { + fields += 1; + } + } + return fields; +} + +function utf8ByteLengthAtMost(value: string, maxBytes: number): number { + let bytes = 0; + + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 3; + } + } else { + bytes += 3; + } + + if (bytes > maxBytes) { + return bytes; + } + } + + return bytes; +} + +function jsonStringContentByteLengthAtMost(value: string, maxBytes: number): number { + let bytes = 0; + + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code === 0x22 || code === 0x5c) { + bytes += 2; + } else if (code === 0x08 || code === 0x09 || code === 0x0a || code === 0x0c || code === 0x0d) { + bytes += 2; + } else if (code <= 0x1f) { + bytes += 6; + } else if (code <= 0x7f) { + bytes += 1; + } else if (code <= 0x7ff) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + bytes += 4; + index += 1; + } else { + bytes += 6; + } + } else if (code >= 0xdc00 && code <= 0xdfff) { + bytes += 6; + } else { + bytes += 3; + } + + if (bytes > maxBytes) { + return bytes; + } + } + + return bytes; +} + +function addSafeInteger(left: number, right: number): number { + const value = left + right; + return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER; } 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/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/CHANGELOG.md b/packages/protocol/CHANGELOG.md index 441ef35..ed4d29c 100644 --- a/packages/protocol/CHANGELOG.md +++ b/packages/protocol/CHANGELOG.md @@ -1,5 +1,13 @@ # @webblackbox/protocol +## 0.7.0 + +### Minor Changes + +- b3cfda9: Extend privacy-scan coverage metadata and reject non-canonical archive layouts, ambiguous blobs, + invalid event payloads, and regressing chunk timelines while keeping browser schema bundles + tree-shakeable. + ## 0.6.0 ### Minor Changes 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/protocol/README.md b/packages/protocol/README.md index 9877423..573b8b5 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 @@ -195,6 +203,9 @@ All types have corresponding Zod schemas for runtime validation: ```typescript import { + assertArchiveBlobPaths, + assertArchiveFilePaths, + isCanonicalArchiveBlobPath, validateEvent, validateEventData, validateMessage, @@ -204,6 +215,14 @@ import { getEventPayloadSchema } from "@webblackbox/protocol"; +// Reject files outside the closed archive layout, non-canonical 64-character +// lowercase SHA-256 blob paths, and duplicate logical blob hashes. +assertArchiveFilePaths(archivePaths); +const blobCount = assertArchiveBlobPaths(archivePaths); +if (!isCanonicalArchiveBlobPath(candidateBlobPath)) { + throw new Error("Invalid blob path"); +} + // Validate a full event (envelope + payload) const result = validateEvent(unknownEvent); if (result.success) { diff --git a/packages/protocol/package.json b/packages/protocol/package.json index c97cf30..e90af07 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@webblackbox/protocol", "description": "Shared event types, schemas, IDs, and configuration defaults for the WebBlackbox ecosystem.", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -40,9 +40,11 @@ "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" + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage" }, "dependencies": { "zod": "^4.1.12" diff --git a/packages/protocol/src/archive-validation.test.ts b/packages/protocol/src/archive-validation.test.ts new file mode 100644 index 0000000..39e498c --- /dev/null +++ b/packages/protocol/src/archive-validation.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; + +import type { + ChunkTimeIndexEntry, + ExportManifest, + PrivacyManifest, + WebBlackboxEvent +} from "./types.js"; + +import { assertArchiveChunk, assertArchiveLayout } from "./archive-validation.js"; +import { DEFAULT_REDACTION_PROFILE } from "./defaults.js"; + +const SHA256 = "0".repeat(64); + +describe("archive timeline validation", () => { + it("rejects wall-clock regressions inside an otherwise bounded chunk", () => { + const index = createIndexEntry(1, 100, 200, 1, 4, 4); + const events = [ + createEvent("E-1", 100, 1), + createEvent("E-2", 150, 2), + createEvent("E-3", 120, 3), + createEvent("E-4", 200, 4) + ]; + + expect(() => + assertArchiveChunk({ + path: "events/chunk-000001.ndjson", + index, + encodedByteLength: index.byteLength, + encodedSha256: index.sha256, + events + }) + ).toThrow(/events are not in wall-clock order/i); + }); + + it.each([ + { + label: "wall-clock", + second: createIndexEntry(2, 90, 120, 20, 30), + expected: /wall-clock ranges move backwards/i + }, + { + label: "monotonic", + second: createIndexEntry(2, 110, 120, 9, 30), + expected: /monotonic ranges move backwards/i + } + ])("rejects $label regressions between chunks", ({ second, expected }) => { + expect(() => assertTwoChunkLayout(second)).toThrow(expected); + }); + + it("accepts adjacent chunks that share their boundary timestamps", () => { + const second = createIndexEntry(2, 100, 120, 10, 30); + + expect(() => assertTwoChunkLayout(second)).not.toThrow(); + }); + + it.each([ + "blobs/private.json", + "blobs/sha256-nested/value.json", + "blobs/sha256-.json", + "blobs/sha256-not-a-digest.json", + `blobs/sha256-${"a".repeat(64)}.exe` + ])("rejects non-canonical blob path %s", (blobPath) => { + const manifest = createManifest(); + manifest.stats.blobCount = 1; + + expect(() => + assertArchiveLayout({ + paths: [ + "manifest.json", + "index/time.json", + "index/req.json", + "index/inv.json", + "events/chunk-000001.ndjson", + "events/chunk-000002.ndjson", + blobPath + ], + manifest, + timeIndex: [createIndexEntry(1, 50, 100, 1, 10), createIndexEntry(2, 100, 120, 10, 30)], + requestIndex: [], + invertedIndex: [], + privacyManifest: null + }) + ).toThrow(/invalid blob path/i); + }); + + it("rejects two blob paths that alias the same content digest", () => { + const manifest = createManifest(); + manifest.stats.blobCount = 2; + const hash = "a".repeat(64); + + expect(() => + assertArchiveLayout({ + paths: [ + "manifest.json", + "index/time.json", + "index/req.json", + "index/inv.json", + "events/chunk-000001.ndjson", + "events/chunk-000002.ndjson", + `blobs/sha256-${hash}.json`, + `blobs/sha256-${hash}.bin` + ], + manifest, + timeIndex: [createIndexEntry(1, 50, 100, 1, 10), createIndexEntry(2, 100, 120, 10, 30)], + requestIndex: [], + invertedIndex: [], + privacyManifest: null + }) + ).toThrow(/duplicate blob hash/i); + }); + + it.each(["secrets/private.json", "assets/nested/private.txt"])( + "rejects unsupported archive file %s", + (unsupportedPath) => { + expect(() => + assertArchiveLayout({ + paths: [ + "manifest.json", + "index/time.json", + "index/req.json", + "index/inv.json", + "events/chunk-000001.ndjson", + "events/chunk-000002.ndjson", + unsupportedPath + ], + manifest: createManifest(), + timeIndex: [createIndexEntry(1, 50, 100, 1, 10), createIndexEntry(2, 100, 120, 10, 30)], + requestIndex: [], + invertedIndex: [], + privacyManifest: null + }) + ).toThrow(/unsupported file/i); + } + ); + + it.each([ + { + label: "event counts beyond totals", + mutate: (privacy: PrivacyManifest) => { + privacy.scanner.coverage!.scannedEventCount = 3; + }, + expected: /coverage exceeds archive totals/i + }, + { + label: "complete coverage below totals", + mutate: (privacy: PrivacyManifest) => { + privacy.scanner.coverage!.scannedEventCount = 1; + }, + expected: /coverage is inconsistent/i + }, + { + label: "incomplete coverage without a reason", + mutate: (privacy: PrivacyManifest) => { + privacy.scanner.coverage!.complete = false; + }, + expected: /coverage is inconsistent/i + } + ])("rejects $label", ({ mutate, expected }) => { + const privacy = createPrivacyManifest(); + mutate(privacy); + + expect(() => assertPrivacyLayout(privacy)).toThrow(expected); + }); + + it("rejects privacy scanner status that contradicts its findings", () => { + const privacy = createPrivacyManifest(); + privacy.scanner.findings.push({ + kind: "private-key", + severity: "high", + path: "event:E-1", + matchCount: 1, + sampleSha256: SHA256 + }); + + expect(() => assertPrivacyLayout(privacy)).toThrow(/status does not match its findings/i); + }); +}); + +function assertPrivacyLayout(privacyManifest: PrivacyManifest): void { + assertArchiveLayout({ + paths: [ + "manifest.json", + "index/time.json", + "index/req.json", + "index/inv.json", + "privacy/manifest.json", + "events/chunk-000001.ndjson", + "events/chunk-000002.ndjson" + ], + manifest: createManifest(), + timeIndex: [createIndexEntry(1, 50, 100, 1, 10), createIndexEntry(2, 100, 120, 10, 30)], + requestIndex: [], + invertedIndex: [], + privacyManifest + }); +} + +function createPrivacyManifest(): PrivacyManifest { + return { + schemaVersion: 1, + generatedAt: new Date(0).toISOString(), + categories: [], + scanner: { + scannedAt: new Date(0).toISOString(), + preEncryption: true, + status: "passed", + findings: [], + coverage: { + complete: true, + scannedEventCount: 2, + scannedBlobCount: 0, + opaqueBlobCount: 0, + inspectedBytes: 1 + } + }, + encryption: { archive: "plaintext" }, + totals: { events: 2, blobs: 0, privacyViolations: 0 } + }; +} + +function assertTwoChunkLayout(second: ChunkTimeIndexEntry): void { + const entries = [createIndexEntry(1, 50, 100, 1, 10), second]; + + assertArchiveLayout({ + paths: [ + "manifest.json", + "index/time.json", + "index/req.json", + "index/inv.json", + "events/chunk-000001.ndjson", + "events/chunk-000002.ndjson" + ], + manifest: createManifest(), + timeIndex: entries, + requestIndex: [], + invertedIndex: [], + privacyManifest: null + }); +} + +function createIndexEntry( + seq: number, + tStart: number, + tEnd: number, + monoStart: number, + monoEnd: number, + eventCount = 1 +): ChunkTimeIndexEntry { + return { + chunkId: `chunk-${String(seq).padStart(6, "0")}`, + seq, + tStart, + tEnd, + monoStart, + monoEnd, + eventCount, + byteLength: 1, + codec: "none", + sha256: SHA256 + }; +} + +function createManifest(): ExportManifest { + return { + protocolVersion: 1, + createdAt: new Date(0).toISOString(), + mode: "lite", + site: { + origin: "https://example.com" + }, + chunkCodec: "none", + redactionProfile: DEFAULT_REDACTION_PROFILE, + stats: { + eventCount: 2, + chunkCount: 2, + blobCount: 0, + durationMs: 70 + } + }; +} + +function createEvent(id: string, t: number, mono: number): WebBlackboxEvent { + return { + v: 1, + sid: "S-archive-validation", + tab: 1, + t, + mono, + type: "user.marker", + id, + data: {} + }; +} diff --git a/packages/protocol/src/archive-validation.ts b/packages/protocol/src/archive-validation.ts new file mode 100644 index 0000000..98458bc --- /dev/null +++ b/packages/protocol/src/archive-validation.ts @@ -0,0 +1,503 @@ +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 SUPPORTED_ARCHIVE_METADATA_PATHS = new Set([ + ...REQUIRED_ARCHIVE_PATHS, + "integrity/hashes.json", + "privacy/manifest.json" +]); + +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$/; +const BLOB_PATH_PATTERN = /^blobs\/sha256-([a-f0-9]{64})\.(bin|html|json|mp4|png|webm|webp)$/; + +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}'`); + } + } + + assertArchiveFilePaths(input.paths); + + assertEncryptionCoverage(input.manifest, input.paths); + assertTimeIndexLayout(input.manifest, input.timeIndex, input.paths); + assertSecondaryIndexes(input.requestIndex, input.invertedIndex); + assertPrivacyConsistency(input.manifest, input.privacyManifest); + + const blobCount = assertArchiveBlobPaths(input.paths); + + if (input.manifest.stats.blobCount !== blobCount) { + throw archiveError("manifest blob count does not match archive contents"); + } +} + +/** Validates canonical blob names and returns the number of unique content digests. */ +export function assertArchiveBlobPaths(paths: readonly string[]): number { + const blobPaths = paths.filter((path) => path.startsWith("blobs/")); + const blobHashes = new Set(); + + for (const path of blobPaths) { + const match = BLOB_PATH_PATTERN.exec(path); + const hash = match?.[1]; + if (!hash) { + throw archiveError(`archive contains invalid blob path '${path}'`); + } + if (blobHashes.has(hash)) { + throw archiveError(`archive contains duplicate blob hash '${hash}'`); + } + blobHashes.add(hash); + } + + return blobPaths.length; +} + +/** Rejects files outside the closed archive layout before any private entry must be decoded. */ +export function assertArchiveFilePaths(paths: string[]): void { + for (const path of paths) { + if (path.startsWith("blobs/")) { + if (!isCanonicalArchiveBlobPath(path)) { + throw archiveError(`archive contains invalid blob path '${path}'`); + } + continue; + } + if (SUPPORTED_ARCHIVE_METADATA_PATHS.has(path) || EVENT_PATH_PATTERN.test(path)) { + continue; + } + throw archiveError(`archive contains unsupported file '${path}'`); + } +} + +/** Returns whether a path uses the canonical archive blob namespace understood by readers. */ +export function isCanonicalArchiveBlobPath(path: string): boolean { + return BLOB_PATH_PATTERN.test(path); +} + +/** 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 previousTime = Number.NEGATIVE_INFINITY; + 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.t < previousTime) { + throw archiveError(`chunk '${input.path}' events are not in wall-clock order`); + } + + 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); + previousTime = event.t; + 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 previousTimeEnd = Number.NEGATIVE_INFINITY; + let previousMonoEnd = Number.NEGATIVE_INFINITY; + 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"); + } + + if (entry.tStart < previousTimeEnd) { + throw archiveError("time index wall-clock ranges move backwards between chunks"); + } + + if (entry.monoStart < previousMonoEnd) { + throw archiveError("time index monotonic ranges move backwards between chunks"); + } + + chunkIds.add(entry.chunkId); + sequences.add(entry.seq); + indexedPaths.add(path); + previousSequence = entry.seq; + previousTimeEnd = entry.tEnd; + previousMonoEnd = entry.monoEnd; + 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"); + } + + const scanner = privacyManifest.scanner; + if ( + (scanner.status === "passed" && scanner.findings.length > 0) || + (scanner.status === "blocked" && scanner.findings.length === 0) + ) { + throw archiveError("privacy scanner status does not match its findings"); + } + + const coverage = scanner.coverage; + if (!coverage) { + return; + } + const accountedBlobCount = coverage.scannedBlobCount + coverage.opaqueBlobCount; + if ( + coverage.scannedEventCount > privacyManifest.totals.events || + accountedBlobCount > privacyManifest.totals.blobs + ) { + throw archiveError("privacy scanner coverage exceeds archive totals"); + } + if ( + coverage.complete !== (coverage.incompleteReason === undefined) || + (coverage.complete && + (coverage.scannedEventCount !== privacyManifest.totals.events || + accountedBlobCount !== privacyManifest.totals.blobs)) + ) { + throw archiveError("privacy scanner coverage is inconsistent with archive totals"); + } +} + +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/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/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/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 60dfb99..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", () => { @@ -63,6 +66,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/index.ts b/packages/protocol/src/index.ts index 9bd300b..fea39ae 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,4 +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/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1162dd5..c5337a0 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,12 +1,32 @@ -import { z } from "zod"; +import { + array as zArray, + custom as zCustom, + discriminatedUnion as zDiscriminatedUnion, + enum as zEnum, + literal as zLiteral, + number as zNumber, + object as zObject, + string as zString +} from "zod"; import { chunkCodecSchema, - eventEnvelopeSchema, freezeReasonSchema, - recorderConfigSchema + recorderConfigSchema, + webBlackboxEventSchema } from "./schemas.js"; +const z = { + array: zArray, + custom: zCustom, + discriminatedUnion: zDiscriminatedUnion, + enum: zEnum, + literal: zLiteral, + number: zNumber, + object: zObject, + string: zString +}; + const arrayBufferSchema = z.custom((value) => value instanceof ArrayBuffer, { message: "Expected ArrayBuffer" }); @@ -53,7 +73,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..32febb3 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -1,6 +1,21 @@ -import { z } from "zod"; +import { + array as zArray, + boolean as zBoolean, + enum as zEnum, + literal as zLiteral, + null as zNull, + number as zNumber, + object as zObject, + record as zRecord, + string as zString, + union as zUnion, + unknown as zUnknown +} from "zod"; +import type { infer as ZodInfer, ZodType } from "zod"; import { + ARCHIVE_PBKDF2_MAX_ITERATIONS, + ARCHIVE_PBKDF2_MIN_ITERATIONS, CAPTURE_MODES, CHUNK_CODECS, EVENT_LEVELS, @@ -10,10 +25,32 @@ import { WEBBLACKBOX_PROTOCOL_VERSION } from "./constants.js"; +const z = { + array: zArray, + boolean: zBoolean, + enum: zEnum, + literal: zLiteral, + null: zNull, + number: zNumber, + object: zObject, + record: zRecord, + string: zString, + union: zUnion, + unknown: zUnknown +}; + 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 +251,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 +311,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 +322,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(); @@ -301,12 +367,26 @@ export const privacyScannerFindingSchema = z }) .strict(); +export const privacyScannerCoverageSchema = z + .object({ + complete: z.boolean(), + scannedEventCount: z.number().int().nonnegative(), + scannedBlobCount: z.number().int().nonnegative(), + opaqueBlobCount: z.number().int().nonnegative(), + inspectedBytes: z.number().int().nonnegative(), + incompleteReason: z + .enum(["deadline", "decode-failed", "finding-limit", "target-byte-limit", "total-byte-limit"]) + .optional() + }) + .strict(); + export const privacyScannerResultSchema = z .object({ scannedAt: z.string().datetime(), preEncryption: z.boolean(), status: z.enum(["passed", "blocked"]), - findings: z.array(privacyScannerFindingSchema) + findings: z.array(privacyScannerFindingSchema), + coverage: privacyScannerCoverageSchema.optional() }) .strict(); @@ -404,9 +484,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 +499,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 +510,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 +546,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 +583,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 +698,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, @@ -610,31 +757,34 @@ const specializedDataSchemas = { "perf.vitals": perfVitalsDataSchema } as const; -export function getEventPayloadSchema(type: z.infer): z.ZodType { +export function getEventPayloadSchema(type: ZodInfer): ZodType { const knownSchema = specializedDataSchemas[type as keyof typeof specializedDataSchemas]; return knownSchema ?? genericStrictDataSchema; } export function validateEventData( - type: z.infer, + type: ZodInfer, payload: unknown ) { 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); } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index b7db7ae..1def3a5 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -257,11 +257,26 @@ export type PrivacyScannerFinding = { sampleSha256: string; }; +export type PrivacyScannerCoverage = { + complete: boolean; + scannedEventCount: number; + scannedBlobCount: number; + opaqueBlobCount: number; + inspectedBytes: number; + incompleteReason?: + | "deadline" + | "decode-failed" + | "finding-limit" + | "target-byte-limit" + | "total-byte-limit"; +}; + export type PrivacyScannerResult = { scannedAt: string; preEncryption: boolean; status: "passed" | "blocked"; findings: PrivacyScannerFinding[]; + coverage?: PrivacyScannerCoverage; }; export type PrivacyManifestCategorySummary = { 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/CHANGELOG.md b/packages/recorder/CHANGELOG.md index d74e426..fe8c0fc 100644 --- a/packages/recorder/CHANGELOG.md +++ b/packages/recorder/CHANGELOG.md @@ -1,5 +1,14 @@ # @webblackbox/recorder +## 0.7.0 + +### Patch Changes + +- b3cfda9: Tighten capture privacy for editable keys, storage names, WebSocket payloads, and masked screenshots, + and keep delayed events from corrupting action correlation or root-cause precursor windows. +- Updated dependencies [b3cfda9] + - @webblackbox/protocol@0.7.0 + ## 0.6.0 ### Minor Changes 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/recorder/package.json b/packages/recorder/package.json index 26ce60f..feca0d3 100644 --- a/packages/recorder/package.json +++ b/packages/recorder/package.json @@ -1,7 +1,7 @@ { "name": "@webblackbox/recorder", "description": "Browser event recorder and normalization engine for WebBlackbox capture pipelines.", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -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/src/action-span.test.ts b/packages/recorder/src/action-span.test.ts index 87e264c..0129522 100644 --- a/packages/recorder/src/action-span.test.ts +++ b/packages/recorder/src/action-span.test.ts @@ -121,6 +121,27 @@ describe("ActionSpanTracker", () => { expect(lateEvent.ref?.act).toBeUndefined(); }); + it("does not attach delayed pre-action events to a future action", () => { + const tracker = new ActionSpanTracker(100); + const firstAction = tracker.assign(createEvent("user.click", 10)); + tracker.assign( + createEvent("network.request", 20, { + reqId: "R-delayed" + }) + ); + const futureAction = tracker.assign(createEvent("user.click", 200)); + const delayedConsole = tracker.assign(createEvent("console.entry", 150)); + const delayedResponse = tracker.assign( + createEvent("network.response", 150, { + reqId: "R-delayed" + }) + ); + + expect(delayedConsole.ref?.act).toBeUndefined(); + expect(delayedResponse.ref?.act).toBe(firstAction.ref?.act); + expect(delayedResponse.ref?.act).not.toBe(futureAction.ref?.act); + }); + it("returns original event for non-action events without mapping", () => { const tracker = new ActionSpanTracker(50); const event = createEvent("console.entry", 500, { diff --git a/packages/recorder/src/action-span.ts b/packages/recorder/src/action-span.ts index c3075c1..7c2b9c2 100644 --- a/packages/recorder/src/action-span.ts +++ b/packages/recorder/src/action-span.ts @@ -46,7 +46,9 @@ export class ActionSpanTracker { const activeAction = this.currentAction; const shouldAttachCurrentAction = Boolean( - activeAction && event.mono <= activeAction.expiresAtMono + activeAction && + event.mono >= activeAction.startedAtMono && + event.mono <= activeAction.expiresAtMono ); let actionId = shouldAttachCurrentAction ? activeAction?.id : undefined; @@ -114,4 +116,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 99c39ef..b11ea8c 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(); @@ -792,6 +811,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; @@ -815,6 +1046,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", @@ -978,6 +1225,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", @@ -1316,6 +1580,57 @@ describe("recorder", () => { ]); }); + it("excludes delayed future-timeline events from root-cause suspects", () => { + const recorder = new WebBlackboxRecorder( + TEST_CONFIG, + {}, + undefined, + createDefaultRecorderPlugins() + ); + + const futureNetworkFailure = recorder.ingest( + createRawEvent({ + source: "cdp", + rawType: "Network.loadingFailed", + sid: "S-root-cause-delayed", + tabId: 11, + t: 300, + mono: 300, + payload: { + requestId: "R-future", + errorText: "net::ERR_FAILED" + } + }) + ); + expect(futureNetworkFailure.event?.type).toBe("network.failed"); + const delayedError = recorder.ingest( + createRawEvent({ + rawType: "pageError", + sid: "S-root-cause-delayed", + tabId: 11, + t: 200, + mono: 200, + payload: { + message: "earlier sampled error" + } + }) + ); + const delayedPayload = delayedError.event?.data as + | { + aiRootCause?: { + suspects?: Array<{ type?: string; eventId?: string }>; + }; + } + | undefined; + + expect(delayedPayload?.aiRootCause?.suspects).toEqual([ + { + type: "runtime", + reason: "No strong precursor signal was found; inspect nearby console and runtime events." + } + ]); + }); + it("isolates plugin hook exceptions without dropping ingest flow", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); const recorder = new WebBlackboxRecorder(TEST_CONFIG, {}, undefined, [ 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/plugins.ts b/packages/recorder/src/plugins.ts index 427593f..21d6f85 100644 --- a/packages/recorder/src/plugins.ts +++ b/packages/recorder/src/plugins.ts @@ -100,7 +100,9 @@ export function createAiRootCausePlugin(windowMs = 10_000): RecorderPlugin { } const windowStart = event.mono - windowMs; - const scoped = recent.filter((item) => item.id !== event.id && item.mono >= windowStart); + const scoped = recent.filter( + (item) => item.id !== event.id && item.mono >= windowStart && item.mono <= event.mono + ); const suspects: Array<{ type: string; reason: string; eventId?: string }> = []; const networkIssue = scoped.find((item) => { diff --git a/packages/recorder/src/recorder.ts b/packages/recorder/src/recorder.ts index 8a8d4ee..90309ca 100644 --- a/packages/recorder/src/recorder.ts +++ b/packages/recorder/src/recorder.ts @@ -16,12 +16,71 @@ 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", + "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; }; +export type RecorderSequenceState = { + event: number; + action: number; +}; + export class WebBlackboxRecorder { private readonly idFactory = new EventIdFactory(); @@ -60,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, - redactedPayload, + policyBoundPayload, this.config.capturePolicy ); const violation = evaluateCapturePolicy( normalized.eventType, - redactedPayload, + policyBoundPayload, privacy, this.config.capturePolicy ); @@ -130,6 +197,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; @@ -206,8 +285,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, @@ -261,7 +340,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"; } @@ -287,6 +366,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 +435,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 +479,8 @@ function classifyCategory(eventType: WebBlackboxEventType): PrivacyClassificatio } function classifySensitivity( - eventType: WebBlackboxEventType + eventType: WebBlackboxEventType, + payload: unknown ): PrivacyClassification["sensitivity"] { if (eventType === "privacy.violation") { return "medium"; @@ -383,6 +488,7 @@ function classifySensitivity( if ( eventType === "user.input" || + readEditableKeydownContext(eventType, payload) !== null || eventType === "dom.snapshot" || eventType === "network.body" || eventType === "screen.screenshot" || @@ -492,6 +598,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); @@ -519,6 +713,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 @@ -537,6 +733,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/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; +} 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/CHANGELOG.md b/packages/webblackbox/CHANGELOG.md index 304625c..f8fa690 100644 --- a/packages/webblackbox/CHANGELOG.md +++ b/packages/webblackbox/CHANGELOG.md @@ -1,5 +1,21 @@ # webblackbox +## 0.7.0 + +### Minor Changes + +- b3cfda9: Add capability-bound direct capture control with bounded replay reconciliation, inert duplicate + bootstrap, and complete final Lite DOM, storage, and screenshot artifacts. + +### Patch Changes + +- Updated dependencies [b3cfda9] +- Updated dependencies [b3cfda9] +- Updated dependencies [b3cfda9] + - @webblackbox/protocol@0.7.0 + - @webblackbox/pipeline@0.7.0 + - @webblackbox/recorder@0.7.0 + ## 0.6.0 ### Minor Changes 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. 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..3829516 100644 --- a/packages/webblackbox/package.json +++ b/packages/webblackbox/package.json @@ -1,7 +1,7 @@ { "name": "webblackbox", "description": "Browser-side lite capture SDK for recording, exporting, and embedding WebBlackbox sessions.", - "version": "0.6.0", + "version": "0.7.0", "type": "module", "sideEffects": false, "main": "./dist/index.js", @@ -61,11 +61,14 @@ "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" + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage" }, "devDependencies": { + "fake-indexeddb": "^6.2.5", "jsdom": "^26.1.0" }, "dependencies": { diff --git a/packages/webblackbox/src/injected-hooks.test.ts b/packages/webblackbox/src/injected-hooks.test.ts index a903e6e..cb9352c 100644 --- a/packages/webblackbox/src/injected-hooks.test.ts +++ b/packages/webblackbox/src/injected-hooks.test.ts @@ -5,20 +5,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; import { + createAuthenticatedInjectedCaptureConfig, + createAuthenticatedInjectedCaptureDrainRequest, + createInjectedLiteCaptureDirectController, INJECTED_CAPTURE_CONFIG_EVENT, + INJECTED_CAPTURE_DRAIN_EVENT, INJECTED_MESSAGE_SOURCE, + parseInjectedCaptureArmedMessage, + parseInjectedCaptureConfig, + parseInjectedCaptureDrainedMessage, + parseInjectedCaptureWindowMessage, + type InjectedCaptureArmedMessage, type InjectedCaptureConfig, type InjectedCaptureWindowMessage, - installInjectedLiteCaptureHooks + type InjectedDirectDrainResult, + installInjectedLiteCaptureHooks, + verifyAuthenticatedInjectedCaptureControlAck } from "./injected-hooks.js"; type CaptureEventMessage = { + activationId: string; rawType: string; payload: Record; t: number; mono: number; }; +const ACTIVATION_A = `a1_${"a".repeat(64)}`; +const ACTIVATION_B = `a1_${"b".repeat(64)}`; + const DETAILED_TEST_CAPTURE_POLICY: CapturePolicy = { ...DEFAULT_CAPTURE_POLICY, mode: "debug", @@ -39,21 +54,29 @@ function delay(ms: number): Promise { describe("injected-hooks", () => { const originalFetch = window.fetch; const captured: CaptureEventMessage[] = []; + const armed: string[] = []; beforeEach(() => { captured.length = 0; + armed.length = 0; vi.spyOn(console, "info").mockImplementation(() => undefined); vi.spyOn(console, "log").mockImplementation(() => undefined); vi.spyOn(window, "postMessage").mockImplementation((message: unknown) => { - const row = message as InjectedCaptureWindowMessage; + const row = message as InjectedCaptureWindowMessage | InjectedCaptureArmedMessage; if (row?.source !== INJECTED_MESSAGE_SOURCE) { return; } + if (row.kind === "injected-armed") { + armed.push(row.activationId); + return; + } + if (row.kind === "capture-event") { captured.push({ + activationId: row.activationId, rawType: row.rawType, payload: row.payload, t: row.t, @@ -70,11 +93,411 @@ describe("injected-hooks", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); window.fetch = originalFetch; localStorage.clear(); sessionStorage.clear(); }); + it("accepts only bounded observation messages from the page-world bridge", () => { + const validEvent = { + activationId: ACTIVATION_A, + rawType: "console", + payload: { method: "info", redacted: true }, + t: Date.now(), + mono: performance.timeOrigin + performance.now() + }; + + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-events", + activationId: ACTIVATION_A, + 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", + activationId: ACTIVATION_A, + 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("strictly binds configs, acknowledgements, and batches to one activation", () => { + const event = { + activationId: ACTIVATION_A, + rawType: "console", + payload: { method: "info", redacted: true }, + t: 1, + mono: 1 + }; + + expect( + parseInjectedCaptureConfig({ + active: true, + activationId: ACTIVATION_A, + bodyCaptureMaxBytes: 4_096, + capturePolicy: DEFAULT_CAPTURE_POLICY + }) + ).toMatchObject({ active: true, activationId: ACTIVATION_A }); + expect( + parseInjectedCaptureConfig({ + active: true, + activationId: ACTIVATION_A, + unexpected: true + }) + ).toBeNull(); + expect(parseInjectedCaptureConfig({ active: true, activationId: "a1_short" })).toBeNull(); + + expect( + parseInjectedCaptureArmedMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-armed", + activationId: ACTIVATION_A + }) + ).toBe(ACTIVATION_A); + expect( + parseInjectedCaptureArmedMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-armed", + activationId: ACTIVATION_A, + events: [] + }) + ).toBeNull(); + + expect( + parseInjectedCaptureWindowMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-events", + activationId: ACTIVATION_A, + events: [event, { ...event, activationId: ACTIVATION_B }] + }) + ).toBeNull(); + }); + + it("flushes queued events on deactivation but drops stale replacement flushes", async () => { + vi.useFakeTimers(); + const flag = "__WB_TEST_INJECTED_ACTIVATION_RESET__"; + + try { + installInjectedLiteCaptureHooks({ flag, active: false }); + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { active: true, activationId: ACTIVATION_A } + }) + ); + console.info("queued-under-a"); + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { active: false, activationId: ACTIVATION_A } + }) + ); + await vi.runAllTimersAsync(); + + expect(captured.some((event) => event.activationId === ACTIVATION_A)).toBe(true); + captured.length = 0; + + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { active: true, activationId: ACTIVATION_A } + }) + ); + console.info("replaced-a"); + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { active: true, activationId: ACTIVATION_B } + }) + ); + console.info("active-b"); + await vi.runAllTimersAsync(); + + expect(captured.some((event) => event.activationId === ACTIVATION_A)).toBe(false); + expect(captured.some((event) => event.activationId === ACTIVATION_B)).toBe(true); + expect(armed).toEqual(expect.arrayContaining([ACTIVATION_A, ACTIVATION_B])); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it("flushes MAIN events before emitting the close-only drain barrier", async () => { + vi.useFakeTimers(); + const flag = "__WB_TEST_INJECTED_DRAIN__"; + + try { + installInjectedLiteCaptureHooks({ flag, active: false }); + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { active: true, activationId: ACTIVATION_A } + }) + ); + console.info("queued-before-drain"); + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_DRAIN_EVENT, { + detail: { activationId: ACTIVATION_A } + }) + ); + await vi.runAllTimersAsync(); + + expect(captured.some((event) => event.activationId === ACTIVATION_A)).toBe(true); + expect(window.postMessage).toHaveBeenCalledWith( + { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-drained", + activationId: ACTIVATION_A + }, + "*" + ); + expect( + parseInjectedCaptureDrainedMessage({ + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-drained", + activationId: ACTIVATION_A + }) + ).toBe(ACTIVATION_A); + } finally { + vi.clearAllTimers(); + vi.useRealTimers(); + } + }); + + it("rejects a page-preempted drain and accepts only the HMAC-authenticated barrier", async () => { + const secret = "c".repeat(64); + const flag = "__WB_TEST_AUTHENTICATED_DRAIN__"; + let forgedAck: unknown; + const preempt = (event: Event) => { + const detail = (event as CustomEvent>).detail; + forgedAck = { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-drained-authenticated", + activationId: detail.activationId, + nonce: detail.nonce, + proof: "0".repeat(64) + }; + window.postMessage(forgedAck, "*"); + }; + window.addEventListener(INJECTED_CAPTURE_DRAIN_EVENT, preempt); + + try { + expect( + installInjectedLiteCaptureHooks({ + flag, + active: false, + captureNetwork: false, + controlSecret: secret + }) + ).toBe(secret); + const config = await createAuthenticatedInjectedCaptureConfig(secret, { + active: true, + activationId: ACTIVATION_A, + bodyCaptureMaxBytes: 0, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + }); + window.dispatchEvent(new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { detail: config })); + await delay(10); + console.info("authenticated-tail"); + const drain = await createAuthenticatedInjectedCaptureDrainRequest(secret, ACTIVATION_A); + window.dispatchEvent(new CustomEvent(INJECTED_CAPTURE_DRAIN_EVENT, { detail: drain })); + await delay(10); + + expect( + await verifyAuthenticatedInjectedCaptureControlAck(forgedAck, secret, { + kind: "injected-drained-authenticated", + activationId: ACTIVATION_A, + nonce: drain.nonce + }) + ).toBe(false); + const authenticatedAck = vi + .mocked(window.postMessage) + .mock.calls.map(([message]) => message) + .filter( + (message) => (message as { kind?: unknown }).kind === "injected-drained-authenticated" + ) + .at(-1); + expect( + await verifyAuthenticatedInjectedCaptureControlAck(authenticatedAck, secret, { + kind: "injected-drained-authenticated", + activationId: ACTIVATION_A, + nonce: drain.nonce + }) + ).toBe(true); + expect(captured.some((event) => event.activationId === ACTIVATION_A)).toBe(true); + } finally { + window.removeEventListener(INJECTED_CAPTURE_DRAIN_EVENT, preempt); + } + }); + + it("locks control closed at the nonce limit and never accepts an evicted active replay", async () => { + const secret = "d".repeat(64); + installInjectedLiteCaptureHooks({ + flag: "__WB_TEST_CONTROL_NONCE_LIMIT__", + active: false, + captureNetwork: false, + controlSecret: secret, + controlNonceLimit: 2 + }); + const first = await createAuthenticatedInjectedCaptureConfig(secret, { + active: true, + activationId: ACTIVATION_A + }); + const second = await createAuthenticatedInjectedCaptureConfig(secret, { + active: false, + activationId: ACTIVATION_A + }); + const overflow = await createAuthenticatedInjectedCaptureConfig(secret, { + active: true, + activationId: ACTIVATION_B + }); + + for (const detail of [first, second, overflow, first]) { + window.dispatchEvent(new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { detail })); + await delay(5); + } + captured.length = 0; + console.info("must-remain-locked"); + await delay(10); + + expect(captured).toEqual([]); + }); + + it("keeps asynchronous network completions bound to their producing activation", async () => { + let resolveFetch!: (response: Response) => void; + window.fetch = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ) as typeof fetch; + installInjectedLiteCaptureHooks({ + flag: "__WB_TEST_INJECTED_ASYNC_ACTIVATION__", + active: true, + activationId: ACTIVATION_A, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + }); + + const request = window.fetch("https://example.test/deferred"); + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { + active: true, + activationId: ACTIVATION_B, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + } + }) + ); + resolveFetch( + new Response("ok", { + status: 200, + headers: { "content-type": "text/plain" } + }) + ); + await request; + await delay(10); + + expect( + captured.some( + (event) => + event.rawType === "fetch" || + event.rawType === "fetchError" || + event.rawType === "networkBody" + ) + ).toBe(false); + }); + + 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__"; @@ -108,6 +531,111 @@ describe("injected-hooks", () => { expect((event?.payload as { args?: unknown }).args).toBeUndefined(); }); + it("keeps the installed state when a duplicate bootstrap supplies different options", async () => { + const flag = "__WB_TEST_INJECTED_DUPLICATE_BOOTSTRAP__"; + const configEvents = vi.fn(); + window.fetch = vi.fn(async () => { + return new Response('{"ok":true}', { + status: 200, + headers: { + "content-type": "application/json" + } + }); + }) as typeof fetch; + window.addEventListener(INJECTED_CAPTURE_CONFIG_EVENT, configEvents); + + try { + installInjectedLiteCaptureHooks({ + flag, + active: true, + activationId: ACTIVATION_A, + bodyCaptureMaxBytes: 64 * 1024, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + }); + const installedFetch = window.fetch; + const installedConsoleInfo = console.info; + + installInjectedLiteCaptureHooks({ + flag, + active: false, + activationId: ACTIVATION_B, + bodyCaptureMaxBytes: 0, + capturePolicy: DEFAULT_CAPTURE_POLICY, + captureNetwork: false + }); + + expect(window.fetch).toBe(installedFetch); + expect(console.info).toBe(installedConsoleInfo); + expect(configEvents).not.toHaveBeenCalled(); + + console.info("still-active-after-duplicate-bootstrap"); + await window.fetch("https://example.test/api/duplicate-bootstrap"); + + for (let attempt = 0; attempt < 20; attempt += 1) { + if (captured.some((message) => message.rawType === "networkBody")) { + break; + } + + await delay(5); + } + + expect( + captured.filter( + (message) => + message.rawType === "notice" && + (message.payload as { message?: unknown }).message === "injected-ready" + ) + ).toHaveLength(1); + expect(captured.some((message) => message.rawType === "console")).toBe(true); + expect(captured.some((message) => message.rawType === "networkBody")).toBe(true); + + const consoleCountBeforeInactive = captured.filter( + (message) => message.rawType === "console" + ).length; + const bodyCountBeforeInactive = captured.filter( + (message) => message.rawType === "networkBody" + ).length; + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { + active: false, + activationId: ACTIVATION_A, + bodyCaptureMaxBytes: 0 + } + }) + ); + console.info("inactive-after-dynamic-config"); + await window.fetch("https://example.test/api/inactive-after-config"); + await delay(10); + + expect(captured.filter((message) => message.rawType === "console")).toHaveLength( + consoleCountBeforeInactive + ); + expect(captured.filter((message) => message.rawType === "networkBody")).toHaveLength( + bodyCountBeforeInactive + ); + + window.dispatchEvent( + new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { + detail: { + active: true, + activationId: ACTIVATION_A, + bodyCaptureMaxBytes: 64 * 1024 + } + }) + ); + console.info("active-after-dynamic-config"); + await delay(10); + + expect(captured.filter((message) => message.rawType === "console")).toHaveLength( + consoleCountBeforeInactive + 1 + ); + expect(configEvents).toHaveBeenCalledTimes(2); + } finally { + window.removeEventListener(INJECTED_CAPTURE_CONFIG_EVENT, configEvents); + } + }); + it("captures fetch start/end and sampled response body", async () => { const flag = "__WB_TEST_INJECTED_FETCH__"; @@ -186,6 +714,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__"; @@ -198,7 +747,7 @@ describe("injected-hooks", () => { }); }) as typeof fetch; - installInjectedLiteCaptureHooks({ flag }); + installInjectedLiteCaptureHooks({ flag, activationId: ACTIVATION_A }); await window.fetch("https://example.test/api/disabled"); await delay(20); @@ -208,6 +757,8 @@ describe("injected-hooks", () => { window.dispatchEvent( new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { detail: { + active: true, + activationId: ACTIVATION_A, bodyCaptureMaxBytes: 64 * 1024, capturePolicy: DETAILED_TEST_CAPTURE_POLICY } @@ -247,7 +798,8 @@ describe("injected-hooks", () => { window.dispatchEvent( new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { detail: { - active: true + active: true, + activationId: ACTIVATION_A } }) ); @@ -261,7 +813,8 @@ describe("injected-hooks", () => { window.dispatchEvent( new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { detail: { - active: false + active: false, + activationId: ACTIVATION_A } }) ); @@ -393,4 +946,289 @@ describe("injected-hooks", () => { "demo-pass" ]); }); + + it("keeps direct control privileged, monotonic, replayable, and free of DOM crypto control", () => { + const secret = "e".repeat(64); + const wrongSecret = "f".repeat(64); + const addEventListener = vi.spyOn(window, "addEventListener"); + const getRandomValues = vi + .spyOn(globalThis.crypto, "getRandomValues") + .mockImplementation(() => { + throw new Error("page crypto must not be used by direct control"); + }); + const jsonStringify = vi.spyOn(JSON, "stringify"); + const OriginalTextEncoder = globalThis.TextEncoder; + const textEncoder = vi.fn(() => { + throw new Error("page TextEncoder must not be used by direct control"); + }); + vi.stubGlobal("TextEncoder", textEncoder); + + const controller = createInjectedLiteCaptureDirectController({ replayEventLimit: 2 }); + expect(controller.bootstrap(secret)).toEqual({ + kind: "installed", + phase: "inactive", + revision: 0 + }); + expect(controller.bootstrap(secret)).toEqual({ + kind: "existing", + phase: "inactive", + revision: 0 + }); + expect(controller.bootstrap(wrongSecret)).toEqual({ kind: "rejected" }); + expect( + addEventListener.mock.calls.some( + ([type]) => + String(type) === INJECTED_CAPTURE_CONFIG_EVENT || + String(type) === INJECTED_CAPTURE_DRAIN_EVENT + ) + ).toBe(false); + + const poisonCommand: Record = {}; + Object.defineProperty(poisonCommand, "kind", { + get(): never { + throw new Error("wrong capability must not inspect the command"); + } + }); + expect(() => controller.control(wrongSecret, poisonCommand)).not.toThrow(); + expect(controller.control(wrongSecret, poisonCommand)).toEqual({ + kind: "rejected", + reason: "capability" + }); + expect( + controller.control(secret, { + kind: "activate", + revision: 1, + config: { active: true, activationId: ACTIVATION_A }, + extra: true + }) + ).toEqual({ kind: "rejected", reason: "invalid-command" }); + + expect( + controller.control(secret, { + kind: "activate", + revision: 1, + config: { + active: true, + activationId: ACTIVATION_A, + bodyCaptureMaxBytes: 0, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + } + }) + ).toEqual({ kind: "activated", activationId: ACTIVATION_A, revision: 1 }); + expect(controller.control(secret, { kind: "probe" })).toMatchObject({ + kind: "probed", + phase: "active", + revision: 1, + activationId: ACTIVATION_A + }); + expect(getRandomValues).not.toHaveBeenCalled(); + expect(textEncoder).not.toHaveBeenCalled(); + expect(jsonStringify).not.toHaveBeenCalled(); + + getRandomValues.mockRestore(); + vi.stubGlobal("TextEncoder", OriginalTextEncoder); + console.info("direct-tail-1"); + console.info("direct-tail-2"); + const firstDrain = controller.control(secret, { + kind: "drain", + revision: 2, + activationId: ACTIVATION_A + }) as InjectedDirectDrainResult; + expect(firstDrain).toMatchObject({ + kind: "drained", + activationId: ACTIVATION_A, + revision: 2, + producedThrough: 2, + truncatedBefore: 0, + degraded: false, + replayed: false + }); + expect(firstDrain.events.map((event) => event.seq)).toEqual([1, 2]); + expect(firstDrain.events.every((event) => event.activationId === ACTIVATION_A)).toBe(true); + + expect( + controller.control(secret, { + kind: "drain", + revision: 2, + activationId: ACTIVATION_A + }) + ).toEqual({ kind: "rejected", reason: "stale" }); + expect( + controller.control(secret, { + kind: "drain", + revision: 3, + activationId: ACTIVATION_A + }) + ).toMatchObject({ + kind: "drained", + replayed: true, + producedThrough: 2, + truncatedBefore: 0 + }); + + expect( + controller.control(secret, { + kind: "activate", + revision: 4, + config: { + active: true, + activationId: ACTIVATION_B, + bodyCaptureMaxBytes: 0, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + } + }) + ).toEqual({ kind: "activated", activationId: ACTIVATION_B, revision: 4 }); + expect( + controller.control(secret, { + kind: "drain", + revision: 5, + activationId: ACTIVATION_A + }) + ).toEqual({ kind: "rejected", reason: "activation-mismatch" }); + expect(controller.control(secret, { kind: "probe" })).toMatchObject({ + kind: "probed", + phase: "active", + revision: 4, + activationId: ACTIVATION_B + }); + + console.info("direct-overflow-1"); + console.info("direct-overflow-2"); + console.info("direct-overflow-3"); + const overflowDrain = controller.control(secret, { + kind: "drain", + revision: 5, + activationId: ACTIVATION_B + }) as InjectedDirectDrainResult; + expect(overflowDrain).toMatchObject({ + producedThrough: 3, + truncatedBefore: 1, + degraded: true, + replayed: false + }); + expect(overflowDrain.events.map((event) => event.seq)).toEqual([2, 3]); + + let nextRevision = 6; + for (let lifecycle = 0; lifecycle < 520; lifecycle += 1) { + const activationId = `a1_${lifecycle.toString(16).padStart(64, "0")}`; + expect( + controller.control(secret, { + kind: "activate", + revision: nextRevision, + config: { active: true, activationId } + }) + ).toMatchObject({ kind: "activated", activationId }); + nextRevision += 1; + expect( + controller.control(secret, { + kind: "drain", + revision: nextRevision, + activationId + }) + ).toMatchObject({ kind: "drained", activationId, degraded: false }); + nextRevision += 1; + } + + expect(controller.control(secret, { kind: "probe" })).toMatchObject({ + kind: "probed", + phase: "drained", + revision: nextRevision - 1 + }); + }); + + it("clears the controlled activation on direct deactivation", () => { + const secret = "8".repeat(64); + const controller = createInjectedLiteCaptureDirectController(); + expect(controller.bootstrap(secret)).toMatchObject({ kind: "installed" }); + expect( + controller.control(secret, { + kind: "activate", + revision: 1, + config: { active: true, activationId: ACTIVATION_A } + }) + ).toMatchObject({ kind: "activated" }); + expect( + controller.control(secret, { + kind: "deactivate", + revision: 2, + activationId: ACTIVATION_A + }) + ).toMatchObject({ kind: "deactivated" }); + expect(controller.control(secret, { kind: "probe" })).toMatchObject({ + kind: "probed", + phase: "inactive", + revision: 2, + activationId: null + }); + }); + + it("atomically replaces an active activation at a higher revision", () => { + const secret = "7".repeat(64); + const controller = createInjectedLiteCaptureDirectController(); + controller.bootstrap(secret); + expect( + controller.control(secret, { + kind: "activate", + revision: 1, + config: { active: true, activationId: ACTIVATION_A } + }) + ).toMatchObject({ kind: "activated", activationId: ACTIVATION_A }); + console.info("old-activation-tail"); + expect( + controller.control(secret, { + kind: "activate", + revision: 2, + config: { active: true, activationId: ACTIVATION_B } + }) + ).toMatchObject({ kind: "activated", activationId: ACTIVATION_B }); + expect( + controller.control(secret, { + kind: "drain", + revision: 3, + activationId: ACTIVATION_A + }) + ).toEqual({ kind: "rejected", reason: "activation-mismatch" }); + expect(controller.control(secret, { kind: "probe" })).toMatchObject({ + phase: "active", + activationId: ACTIVATION_B, + producedThrough: 0, + truncatedBefore: 0 + }); + }); + + it("bounds direct replay by estimated serialized bytes as well as event count", () => { + const secret = "9".repeat(64); + const controller = createInjectedLiteCaptureDirectController({ + replayByteLimit: 128, + replayEventLimit: 256 + }); + + expect(controller.bootstrap(secret)).toMatchObject({ kind: "installed" }); + expect( + controller.control(secret, { + kind: "activate", + revision: 1, + config: { + active: true, + activationId: ACTIVATION_A, + capturePolicy: DETAILED_TEST_CAPTURE_POLICY + } + }) + ).toMatchObject({ kind: "activated" }); + + console.info("x".repeat(1_000)); + const result = controller.control(secret, { + kind: "drain", + revision: 2, + activationId: ACTIVATION_A + }) as InjectedDirectDrainResult; + + expect(result).toMatchObject({ + kind: "drained", + producedThrough: 1, + truncatedBefore: 1, + degraded: true + }); + expect(result.events).toEqual([]); + }); }); diff --git a/packages/webblackbox/src/injected-hooks.ts b/packages/webblackbox/src/injected-hooks.ts index 37157be..430bad6 100644 --- a/packages/webblackbox/src/injected-hooks.ts +++ b/packages/webblackbox/src/injected-hooks.ts @@ -1,4 +1,5 @@ import { + capturePolicySchema, DEFAULT_CAPTURE_POLICY, sanitizeUrlForPrivacy, type CapturePolicy @@ -16,6 +17,20 @@ 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 INJECTED_ACTIVATION_ID_PATTERN = /^a1_[0-9a-f]{64}$/; +const INJECTED_CONTROL_SECRET_PATTERN = /^[0-9a-f]{64}$/; +const INJECTED_CONTROL_NONCE_PATTERN = /^n1_[0-9a-f]{64}$/; +const INJECTED_CONTROL_PROOF_PATTERN = /^[0-9a-f]{64}$/; +const INJECTED_CONTROL_RANDOM_BYTES = 32; +const INJECTED_CONTROL_SEEN_NONCES_MAX = 512; +const INJECTED_DIRECT_REPLAY_EVENT_LIMIT = 256; +const INJECTED_DIRECT_REPLAY_BYTE_LIMIT = 1024 * 1024; +const INJECTED_DIRECT_REPLAY_BYTE_LIMIT_MAX = 16 * 1024 * 1024; +const INJECTED_DIRECT_MAX_REVISION = 9_007_199_254_740_991; const NETWORK_HEADER_ALLOWLIST = new Set([ "accept", "accept-language", @@ -36,18 +51,117 @@ export const INJECTED_MESSAGE_SOURCE = "webblackbox-injected"; /** DOM event used to push runtime capture config into the injected page world. */ export const INJECTED_CAPTURE_CONFIG_EVENT = "webblackbox:injected-config"; +export const INJECTED_CAPTURE_DRAIN_EVENT = "webblackbox:injected-drain"; export type InjectedCaptureConfig = { - active?: boolean; + active: boolean; + activationId: string; bodyCaptureMaxBytes?: number; capturePolicy?: CapturePolicy; }; +export type AuthenticatedInjectedCaptureConfig = InjectedCaptureConfig & { + nonce: string; + proof: string; +}; + +export type AuthenticatedInjectedCaptureDrainRequest = { + activationId: string; + nonce: string; + proof: string; +}; + +export type AuthenticatedInjectedCaptureControlAck = { + source: typeof INJECTED_MESSAGE_SOURCE; + kind: "injected-armed-authenticated" | "injected-drained-authenticated"; + activationId: string; + nonce: string; + proof: string; +}; + +export type InjectedCaptureEvent = { + activationId: string; + /** Privileged direct-controller sequence. Legacy standalone events omit it. */ + seq?: number; + rawType: string; + payload: CapturePayload; + t: number; + mono: number; +}; + +export type InjectedDirectCaptureEvent = InjectedCaptureEvent & { + seq: number; +}; + +export type InjectedDirectCommand = + | { + kind: "probe"; + } + | { + kind: "activate"; + revision: number; + config: InjectedCaptureConfig; + } + | { + kind: "deactivate" | "drain"; + revision: number; + activationId: string; + }; + +export type InjectedDirectControllerPhase = "inactive" | "active" | "drained"; + +export type InjectedDirectBootstrapResult = + | { + kind: "installed" | "existing"; + phase: InjectedDirectControllerPhase; + revision: number; + } + | { + kind: "rejected" | "unavailable"; + }; + +export type InjectedDirectDrainResult = { + kind: "drained"; + activationId: string; + revision: number; + events: InjectedDirectCaptureEvent[]; + producedThrough: number; + /** Highest sequence no longer retained; `0` means the replay window is complete. */ + truncatedBefore: number; + degraded: boolean; + replayed: boolean; +}; + +export type InjectedDirectControlResult = + | { + kind: "probed"; + phase: InjectedDirectControllerPhase; + revision: number; + activationId: string | null; + producedThrough: number; + truncatedBefore: number; + } + | { + kind: "activated" | "deactivated"; + activationId: string; + revision: number; + } + | InjectedDirectDrainResult + | { + kind: "rejected"; + reason: "activation-mismatch" | "capability" | "invalid-command" | "stale"; + } + | { + kind: "unavailable"; + }; + /** Message contract emitted by injected hooks into the page window. */ export type InjectedCaptureWindowMessage = | { source: typeof INJECTED_MESSAGE_SOURCE; kind: "capture-event"; + activationId: string; + seq?: number; rawType: string; payload: CapturePayload; t: number; @@ -56,144 +170,1205 @@ export type InjectedCaptureWindowMessage = | { source: typeof INJECTED_MESSAGE_SOURCE; kind: "capture-events"; - events: Array<{ - rawType: string; - payload: CapturePayload; - t: number; - mono: number; - }>; + activationId: string; + events: InjectedCaptureEvent[]; + }; + +export type InjectedCaptureArmedMessage = { + source: typeof INJECTED_MESSAGE_SOURCE; + kind: "injected-armed"; + activationId: string; +}; + +export type InjectedCaptureDrainedMessage = { + source: typeof INJECTED_MESSAGE_SOURCE; + kind: "injected-drained"; + activationId: string; +}; + +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 || + !isInjectedActivationId(message.activationId) + ) { + return null; + } + + const candidates = parseInjectedMessageCandidates(message); + + 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 || + !hasExactKeys(event, ["activationId", "seq", "rawType", "payload", "t", "mono"]) || + event.activationId !== message.activationId || + (event.seq !== undefined && !isInjectedDirectSequence(event.seq)) || + 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({ + activationId: message.activationId, + ...(event.seq === undefined ? {} : { seq: event.seq }), + rawType: event.rawType, + payload: safePayload, + t: event.t, + mono: event.mono + }); + } + + return events; +} + +/** Strictly parses the MAIN-world acknowledgement for one exact activation. */ +export function parseInjectedCaptureArmedMessage(value: unknown): string | null { + try { + const message = asPlainRecord(value); + + return message && + hasExactKeys(message, ["source", "kind", "activationId"]) && + message.source === INJECTED_MESSAGE_SOURCE && + message.kind === "injected-armed" && + isInjectedActivationId(message.activationId) + ? message.activationId + : null; + } catch { + return null; + } +} + +/** Strictly parses the close-only MAIN-world drain barrier. */ +export function parseInjectedCaptureDrainedMessage(value: unknown): string | null { + try { + const message = asPlainRecord(value); + + return message && + hasExactKeys(message, ["source", "kind", "activationId"]) && + message.source === INJECTED_MESSAGE_SOURCE && + message.kind === "injected-drained" && + isInjectedActivationId(message.activationId) + ? message.activationId + : null; + } catch { + return null; + } +} + +/** Strictly parses capture-control state before it reaches MAIN-world hooks. */ +export function parseInjectedCaptureConfig(value: unknown): InjectedCaptureConfig | null { + try { + const config = asPlainRecord(value); + + if ( + !config || + !hasExactKeys(config, ["active", "activationId", "bodyCaptureMaxBytes", "capturePolicy"]) || + typeof config.active !== "boolean" || + !isInjectedActivationId(config.activationId) || + (config.bodyCaptureMaxBytes !== undefined && + (typeof config.bodyCaptureMaxBytes !== "number" || + !Number.isFinite(config.bodyCaptureMaxBytes) || + config.bodyCaptureMaxBytes < 0)) + ) { + return null; + } + + const policy = + config.capturePolicy === undefined + ? undefined + : capturePolicySchema.safeParse(config.capturePolicy); + + if (policy && !policy.success) { + return null; + } + + return { + active: config.active, + activationId: config.activationId, + ...(config.bodyCaptureMaxBytes === undefined + ? {} + : { bodyCaptureMaxBytes: config.bodyCaptureMaxBytes }), + ...(policy === undefined ? {} : { capturePolicy: policy.data }) + }; + } catch { + return null; + } +} + +/** Returns true only for versioned, fixed-width activation identities. */ +export function isInjectedActivationId(value: unknown): value is string { + return typeof value === "string" && INJECTED_ACTIVATION_ID_PATTERN.test(value); +} + +/** Creates a document-scoped control key that must travel only over extension-owned channels. */ +export function createInjectedControlSecret(): string { + return createInjectedControlRandomHex(""); +} + +export async function createAuthenticatedInjectedCaptureConfig( + secret: string, + config: InjectedCaptureConfig +): Promise { + const parsed = parseInjectedCaptureConfig(config); + if (!parsed || !isInjectedControlSecret(secret)) { + throw new Error("Invalid injected capture control config or secret."); + } + + const nonce = createInjectedControlRandomHex("n1_"); + const proof = await signInjectedControlPayload( + secret, + serializeInjectedConfigControl("request", parsed, nonce) + ); + return { ...parsed, nonce, proof }; +} + +export async function createAuthenticatedInjectedCaptureDrainRequest( + secret: string, + activationId: string +): Promise { + if (!isInjectedControlSecret(secret) || !isInjectedActivationId(activationId)) { + throw new Error("Invalid injected drain control identity."); + } + + const nonce = createInjectedControlRandomHex("n1_"); + return { + activationId, + nonce, + proof: await signInjectedControlPayload( + secret, + serializeInjectedDrainControl("request", activationId, nonce) + ) + }; +} + +export async function verifyAuthenticatedInjectedCaptureControlAck( + value: unknown, + secret: string, + expected: { + kind: AuthenticatedInjectedCaptureControlAck["kind"]; + activationId: string; + nonce: string; + } +): Promise { + const message = parseAuthenticatedInjectedCaptureControlAck(value); + if ( + !message || + !isInjectedControlSecret(secret) || + message.kind !== expected.kind || + message.activationId !== expected.activationId || + message.nonce !== expected.nonce + ) { + return false; + } + + const direction = message.kind === "injected-armed-authenticated" ? "armed" : "drained"; + return verifyInjectedControlPayload( + secret, + serializeInjectedDrainControl(direction, message.activationId, message.nonce), + message.proof + ); +} + +function isInjectedControlSecret(value: unknown): value is string { + return typeof value === "string" && INJECTED_CONTROL_SECRET_PATTERN.test(value); +} + +function isInjectedControlNonce(value: unknown): value is string { + return typeof value === "string" && INJECTED_CONTROL_NONCE_PATTERN.test(value); +} + +function createInjectedControlRandomHex(prefix: "" | "n1_"): string { + if (!globalThis.crypto?.getRandomValues) { + throw new Error("Cryptographic randomness is unavailable for injected capture control."); + } + + const bytes = new Uint8Array(INJECTED_CONTROL_RANDOM_BYTES); + globalThis.crypto.getRandomValues(bytes); + return `${prefix}${bytesToHex(bytes)}`; +} + +function parseAuthenticatedInjectedCaptureConfig( + value: unknown +): AuthenticatedInjectedCaptureConfig | null { + const message = asPlainRecord(value); + if ( + !message || + !hasAllExactKeys(message, [ + "active", + "activationId", + "bodyCaptureMaxBytes", + "capturePolicy", + "nonce", + "proof" + ]) || + !isInjectedControlNonce(message.nonce) || + typeof message.proof !== "string" || + !INJECTED_CONTROL_PROOF_PATTERN.test(message.proof) + ) { + return null; + } + + const config = parseInjectedCaptureConfig({ + active: message.active, + activationId: message.activationId, + bodyCaptureMaxBytes: message.bodyCaptureMaxBytes, + capturePolicy: message.capturePolicy + }); + return config ? { ...config, nonce: message.nonce, proof: message.proof } : null; +} + +function parseAuthenticatedInjectedCaptureDrainRequest( + value: unknown +): AuthenticatedInjectedCaptureDrainRequest | null { + const message = asPlainRecord(value); + return message && + hasAllExactKeys(message, ["activationId", "nonce", "proof"]) && + isInjectedActivationId(message.activationId) && + isInjectedControlNonce(message.nonce) && + typeof message.proof === "string" && + INJECTED_CONTROL_PROOF_PATTERN.test(message.proof) + ? { + activationId: message.activationId, + nonce: message.nonce, + proof: message.proof + } + : null; +} + +function parseAuthenticatedInjectedCaptureControlAck( + value: unknown +): AuthenticatedInjectedCaptureControlAck | null { + const message = asPlainRecord(value); + return message && + hasAllExactKeys(message, ["source", "kind", "activationId", "nonce", "proof"]) && + message.source === INJECTED_MESSAGE_SOURCE && + (message.kind === "injected-armed-authenticated" || + message.kind === "injected-drained-authenticated") && + isInjectedActivationId(message.activationId) && + isInjectedControlNonce(message.nonce) && + typeof message.proof === "string" && + INJECTED_CONTROL_PROOF_PATTERN.test(message.proof) + ? { + source: INJECTED_MESSAGE_SOURCE, + kind: message.kind, + activationId: message.activationId, + nonce: message.nonce, + proof: message.proof + } + : null; +} + +function serializeInjectedConfigControl( + direction: "request", + config: InjectedCaptureConfig, + nonce: string +): string { + return JSON.stringify([ + "webblackbox-injected-control-v1", + direction, + "config", + config.activationId, + nonce, + config.active, + config.bodyCaptureMaxBytes ?? null, + canonicalizeInjectedControlValue(config.capturePolicy ?? null) + ]); +} + +function serializeInjectedDrainControl( + direction: "request" | "armed" | "drained", + activationId: string, + nonce: string +): string { + return JSON.stringify(["webblackbox-injected-control-v1", direction, activationId, nonce]); +} + +function canonicalizeInjectedControlValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeInjectedControlValue); + } + const record = asPlainRecord(value); + if (record) { + return Object.fromEntries( + Object.keys(record) + .sort() + .map((key) => [key, canonicalizeInjectedControlValue(record[key])]) + ); + } + return value; +} + +async function signInjectedControlPayload(secret: string, payload: string): Promise { + const cryptoApi = requireInjectedControlCrypto(); + const key = await cryptoApi.subtle.importKey( + "raw", + toInjectedControlArrayBuffer(hexToBytes(secret)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signature = await cryptoApi.subtle.sign( + "HMAC", + key, + toInjectedControlArrayBuffer(new TextEncoder().encode(payload)) + ); + return bytesToHex(new Uint8Array(signature)); +} + +async function verifyInjectedControlPayload( + secret: string, + payload: string, + proof: string +): Promise { + if (!isInjectedControlSecret(secret) || !INJECTED_CONTROL_PROOF_PATTERN.test(proof)) { + return false; + } + try { + const cryptoApi = requireInjectedControlCrypto(); + const key = await cryptoApi.subtle.importKey( + "raw", + toInjectedControlArrayBuffer(hexToBytes(secret)), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"] + ); + return cryptoApi.subtle.verify( + "HMAC", + key, + toInjectedControlArrayBuffer(hexToBytes(proof)), + toInjectedControlArrayBuffer(new TextEncoder().encode(payload)) + ); + } catch { + return false; + } +} + +function requireInjectedControlCrypto(): Crypto { + const cryptoApi = globalThis.crypto; + if (!cryptoApi?.subtle) { + throw new Error("Web Crypto is unavailable for injected capture control."); + } + return cryptoApi; +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + } + return bytes; +} + +function bytesToHex(bytes: Uint8Array): string { + return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +function toInjectedControlArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return Uint8Array.from(bytes).buffer; +} + +function createInjectedActivationId(): string | null { + if (!globalThis.crypto?.getRandomValues) { + return null; + } + + const bytes = new Uint8Array(32); + globalThis.crypto.getRandomValues(bytes); + return `a1_${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`; +} + +function parseInjectedMessageCandidates(message: Record): unknown[] | null { + if (message.kind === "capture-event") { + if ( + !hasExactKeys(message, [ + "source", + "kind", + "activationId", + "seq", + "rawType", + "payload", + "t", + "mono" + ]) + ) { + return null; + } + + return [ + { + activationId: message.activationId, + seq: message.seq, + rawType: message.rawType, + payload: message.payload, + t: message.t, + mono: message.mono + } + ]; + } + + if ( + message.kind !== "capture-events" || + !hasExactKeys(message, ["source", "kind", "activationId", "events"]) || + !Array.isArray(message.events) + ) { + return null; + } + + return message.events; +} + +function isInjectedDirectSequence(value: unknown): value is number { + return ( + typeof value === "number" && + value > 0 && + value <= INJECTED_DIRECT_MAX_REVISION && + value % 1 === 0 + ); +} + +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; +} + +function hasExactKeys(record: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Object.keys(record).every((key) => allowed.has(key)); +} + +function hasAllExactKeys( + record: Record, + expectedKeys: readonly string[] +): boolean { + return Object.keys(record).length === expectedKeys.length && hasExactKeys(record, expectedKeys); +} + +function cloneInjectedControlDetail(value: unknown): unknown | null { + try { + return structuredClone(value); + } catch { + return null; + } +} + +/** Options for installing browser-side injected hooks. */ +export type InjectedHooksOptions = { + /** Global flag name used to prevent duplicate hook installation. */ + flag?: string; + /** Enables event emission after hooks are installed. */ + active?: boolean; + /** Exact activation identity attached to every MAIN-world observation. */ + activationId?: string; + /** Per-response capture budget; `0` disables response-body sampling. */ + bodyCaptureMaxBytes?: number; + /** Active capture policy used to keep page-world hooks fail-closed. */ + capturePolicy?: CapturePolicy; + /** Disables fetch/xhr/EventSource monkeypatching when browser-side capture is available. */ + captureNetwork?: boolean; + /** Secret delivered only through scripting results and extension-owned isolated messaging. */ + controlSecret?: string; + /** Generates the control secret inside the installed MAIN-world closure. */ + secureControl?: boolean; + /** Testable fail-closed nonce budget; production defaults to the fixed maximum. */ + controlNonceLimit?: number; +}; + +type InjectedDirectReplaySnapshot = { + events: InjectedDirectCaptureEvent[]; + producedThrough: number; + truncatedBefore: number; +}; + +type InjectedDirectHookRuntime = { + activate(config: InjectedCaptureConfig): void; + deactivate(activationId: string): void; + drain(activationId: string): InjectedDirectReplaySnapshot; + snapshot(): InjectedDirectReplaySnapshot; +}; + +type InjectedHooksInternalOptions = InjectedHooksOptions & { + directControl?: { + attach(runtime: InjectedDirectHookRuntime): void; + replayByteLimit: number; + replayEventLimit: number; + }; +}; + +export type InjectedLiteCaptureDirectControllerOptions = { + captureNetwork?: boolean; + replayByteLimit?: number; + replayEventLimit?: number; +}; + +export type InjectedLiteCaptureDirectController = Readonly<{ + bootstrap(secret: unknown): InjectedDirectBootstrapResult; + control(secret: unknown, command: unknown): InjectedDirectControlResult; +}>; + +/** + * Creates one document/module-instance direct controller. The capability never + * enters the hook runtime, page crypto, DOM control events, or window messages. + */ +export function createInjectedLiteCaptureDirectController( + options: InjectedLiteCaptureDirectControllerOptions = {} +): InjectedLiteCaptureDirectController { + const replayByteLimit = resolveInjectedDirectReplayByteLimit(options.replayByteLimit); + const replayEventLimit = resolveInjectedDirectReplayEventLimit(options.replayEventLimit); + let capability: string | null = null; + let runtime: InjectedDirectHookRuntime | null = null; + let phase: InjectedDirectControllerPhase = "inactive"; + let revision = 0; + let controlledActivationId: string | null = null; + + return { + bootstrap(candidate): InjectedDirectBootstrapResult { + if (capability !== null) { + if (!matchesInjectedDirectCapability(capability, candidate)) { + return { kind: "rejected" }; + } + return runtime ? { kind: "existing", phase, revision } : { kind: "unavailable" }; + } + + if (!isInjectedDirectCapability(candidate) || typeof window === "undefined") { + return typeof window === "undefined" && isInjectedDirectCapability(candidate) + ? { kind: "unavailable" } + : { kind: "rejected" }; + } + + capability = candidate; + try { + installInjectedLiteCaptureHooksInternal({ + active: false, + bodyCaptureMaxBytes: 0, + captureNetwork: options.captureNetwork === true, + directControl: { + attach(attachedRuntime): void { + runtime = attachedRuntime; + }, + replayByteLimit, + replayEventLimit + } + }); + } catch { + capability = null; + runtime = null; + return { kind: "unavailable" }; + } + + if (!runtime) { + capability = null; + return { kind: "unavailable" }; + } + + return { kind: "installed", phase, revision }; + }, + + control(candidate, rawCommand): InjectedDirectControlResult { + if (capability === null || !matchesInjectedDirectCapability(capability, candidate)) { + return { kind: "rejected", reason: "capability" }; + } + + if (!runtime) { + return { kind: "unavailable" }; + } + + const command = parseInjectedDirectCommand(rawCommand); + if (!command) { + return { kind: "rejected", reason: "invalid-command" }; + } + + if (command.kind === "probe") { + const replay = runtime.snapshot(); + return { + kind: "probed", + phase, + revision, + activationId: controlledActivationId, + producedThrough: replay.producedThrough, + truncatedBefore: replay.truncatedBefore + }; + } + + if (command.revision <= revision) { + return { kind: "rejected", reason: "stale" }; + } + + if (command.kind === "activate") { + runtime.activate(command.config); + phase = "active"; + revision = command.revision; + controlledActivationId = command.config.activationId; + return { + kind: "activated", + activationId: command.config.activationId, + revision + }; + } + + if (controlledActivationId !== command.activationId) { + return { kind: "rejected", reason: "activation-mismatch" }; + } + + if (command.kind === "deactivate") { + runtime.deactivate(command.activationId); + phase = "inactive"; + revision = command.revision; + controlledActivationId = null; + return { kind: "deactivated", activationId: command.activationId, revision }; + } + + if (phase !== "active" && phase !== "drained") { + return { kind: "rejected", reason: "activation-mismatch" }; + } + + const replayed = phase === "drained"; + const replay = replayed ? runtime.snapshot() : runtime.drain(command.activationId); + phase = "drained"; + revision = command.revision; + return { + kind: "drained", + activationId: command.activationId, + revision, + events: replay.events, + producedThrough: replay.producedThrough, + truncatedBefore: replay.truncatedBefore, + degraded: replay.truncatedBefore > 0, + replayed + }; + } + }; +} + +const injectedLiteCaptureDirectController = createInjectedLiteCaptureDirectController(); + +/** Extension ESM entrypoint target; alias this export to `bootstrap`. */ +export function bootstrapInjectedLiteCaptureDirect(secret: unknown): InjectedDirectBootstrapResult { + return injectedLiteCaptureDirectController.bootstrap(secret); +} + +/** Extension ESM entrypoint target; alias this export to `control`. */ +export function controlInjectedLiteCaptureDirect( + secret: unknown, + command: unknown +): InjectedDirectControlResult { + return injectedLiteCaptureDirectController.control(secret, command); +} + +function resolveInjectedDirectReplayEventLimit(value: unknown): number { + return typeof value === "number" && value > 0 && value <= 4_096 && value % 1 === 0 + ? value + : INJECTED_DIRECT_REPLAY_EVENT_LIMIT; +} + +function resolveInjectedDirectReplayByteLimit(value: unknown): number { + return typeof value === "number" && + value > 0 && + value <= INJECTED_DIRECT_REPLAY_BYTE_LIMIT_MAX && + value % 1 === 0 + ? value + : INJECTED_DIRECT_REPLAY_BYTE_LIMIT; +} + +function isInjectedDirectCapability(value: unknown): value is string { + if (typeof value !== "string" || value.length !== 64) { + return false; + } + for (let index = 0; index < 64; index += 1) { + const character = value[index] ?? ""; + if (!((character >= "0" && character <= "9") || (character >= "a" && character <= "f"))) { + return false; + } + } + return true; +} + +/** Always scans all 64 positions before accepting or rejecting a candidate. */ +function matchesInjectedDirectCapability(expected: string, candidate: unknown): boolean { + const candidateString = typeof candidate === "string" ? candidate : ""; + let mismatch = typeof candidate === "string" && candidateString.length === 64 ? 0 : 1; + for (let index = 0; index < 64; index += 1) { + mismatch |= expected[index] === candidateString[index] ? 0 : 1; + } + return mismatch === 0; +} + +function parseInjectedDirectCommand(value: unknown): InjectedDirectCommand | null { + const command = asPlainRecord(value); + if (!command || typeof command.kind !== "string") { + return null; + } + if (command.kind === "probe") { + return hasAllExactKeys(command, ["kind"]) ? { kind: "probe" } : null; + } + if (!isInjectedDirectRevision(command.revision)) { + return null; + } + if (command.kind === "activate") { + if (!hasAllExactKeys(command, ["kind", "revision", "config"])) { + return null; } - | { - source: typeof INJECTED_MESSAGE_SOURCE; - kind: "marker"; - message?: string; - t: number; - mono: number; - }; + const config = parseInjectedCaptureConfig(command.config); + return config?.active === true + ? { kind: "activate", revision: command.revision, config } + : null; + } + if (command.kind !== "deactivate" && command.kind !== "drain") { + return null; + } + return hasAllExactKeys(command, ["kind", "revision", "activationId"]) && + isInjectedActivationId(command.activationId) + ? { + kind: command.kind, + revision: command.revision, + activationId: command.activationId + } + : null; +} -/** Options for installing browser-side injected hooks. */ -export type InjectedHooksOptions = { - /** Global flag name used to prevent duplicate hook installation. */ - flag?: string; - /** Enables event emission after hooks are installed. */ - active?: boolean; - /** Per-response capture budget; `0` disables response-body sampling. */ - bodyCaptureMaxBytes?: number; - /** Active capture policy used to keep page-world hooks fail-closed. */ - capturePolicy?: CapturePolicy; - /** Disables fetch/xhr/EventSource monkeypatching when browser-side capture is available. */ - captureNetwork?: boolean; -}; +function isInjectedDirectRevision(value: unknown): value is number { + return ( + typeof value === "number" && + value > 0 && + value <= INJECTED_DIRECT_MAX_REVISION && + value % 1 === 0 + ); +} /** * Installs lightweight console/error/network/storage hooks into the current page. * Hooks emit capture events via `window.postMessage`. */ -export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = {}): void { +export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = {}): string | null { + return installInjectedLiteCaptureHooksInternal(options); +} + +function installInjectedLiteCaptureHooksInternal( + options: InjectedHooksInternalOptions +): string | null { if (typeof window === "undefined") { - return; + return null; } const flag = options.flag ?? DEFAULT_FLAG; const windowFlags = window as unknown as Record; + const directControl = options.directControl; + + // Bootstrap options are installation-only. Runtime changes must use the config event. + if (!directControl && windowFlags[flag]) { + return null; + } + + if (options.secureControl === true && options.controlSecret !== undefined) { + return null; + } + + const controlSecret = + options.secureControl === true ? createInjectedControlSecret() : options.controlSecret; + if (controlSecret !== undefined && !isInjectedControlSecret(controlSecret)) { + return null; + } + + if (!directControl) { + windowFlags[flag] = true; + } + let networkRequestSeq = 0; let bodyWindowStartedAt = Date.now(); let bodyWindowCount = 0; let bodyWindowBytes = 0; + let noisyConsoleWindowStartedAt = Date.now(); + let noisyConsoleWindowCount = 0; let emitFlushTimer = 0; let captureActive = options.active !== false; + let activationId = captureActive + ? isInjectedActivationId(options.activationId) + ? options.activationId + : createInjectedActivationId() + : null; + captureActive = captureActive && activationId !== null; let capturePolicy = options.capturePolicy ?? DEFAULT_CAPTURE_POLICY; - const pendingCaptureEvents: Array<{ - rawType: string; - payload: CapturePayload; - t: number; - mono: number; - }> = []; + const pendingCaptureEvents: InjectedCaptureEvent[] = []; + const directReplayEvents: InjectedDirectCaptureEvent[] = []; + const directReplayEventBytes: number[] = []; + let directReplayBytes = 0; + let directProducedThrough = 0; + let directTruncatedBefore = 0; let configuredNetworkBodyCaptureMaxBytes = normalizeConfiguredBodyCaptureMaxBytes( options.bodyCaptureMaxBytes ); let networkBodyCaptureMaxBytes = resolveNetworkBodyCaptureMaxBytes(); const captureNetwork = options.captureNetwork !== false; + const seenControlNonces = new Set(); + const controlNonceLimit = + Number.isSafeInteger(options.controlNonceLimit) && + (options.controlNonceLimit as number) > 0 && + (options.controlNonceLimit as number) <= INJECTED_CONTROL_SEEN_NONCES_MAX + ? (options.controlNonceLimit as number) + : INJECTED_CONTROL_SEEN_NONCES_MAX; + let controlLocked = false; + let controlTail = Promise.resolve(); + + if (!directControl) { + window.addEventListener(INJECTED_CAPTURE_CONFIG_EVENT, (event: Event) => { + const rawDetail = cloneInjectedControlDetail((event as CustomEvent).detail); + if (!rawDetail) { + return; + } - if (windowFlags[flag]) { - const detail: InjectedCaptureConfig = {}; + if (!controlSecret) { + const detail = parseInjectedCaptureConfig(rawDetail); + if (detail) { + applyCaptureConfig(detail); + postInjectedArmed(detail.activationId); + } + return; + } - if (Object.prototype.hasOwnProperty.call(options, "active")) { - detail.active = options.active; - } + controlTail = controlTail + .then(async () => { + const detail = parseAuthenticatedInjectedCaptureConfig(rawDetail); + if ( + controlLocked || + !detail || + seenControlNonces.has(detail.nonce) || + !(await verifyInjectedControlPayload( + controlSecret, + serializeInjectedConfigControl("request", detail, detail.nonce), + detail.proof + )) + ) { + return; + } - if (Object.prototype.hasOwnProperty.call(options, "bodyCaptureMaxBytes")) { - detail.bodyCaptureMaxBytes = options.bodyCaptureMaxBytes; - } + if (!rememberControlNonce(detail.nonce)) return; + applyCaptureConfig(detail); + await postAuthenticatedInjectedControlAck( + "injected-armed-authenticated", + detail.activationId, + detail.nonce + ); + }) + .catch(lockControlChannel); + }); - if (Object.prototype.hasOwnProperty.call(options, "capturePolicy")) { - detail.capturePolicy = options.capturePolicy; - } + window.addEventListener(INJECTED_CAPTURE_DRAIN_EVENT, (event: Event) => { + const rawDetail = cloneInjectedControlDetail((event as CustomEvent).detail); + if (!rawDetail) { + return; + } + + if (!controlSecret) { + const detail = asPlainRecord(rawDetail); + const requestedActivationId = detail?.activationId; + if ( + detail && + hasAllExactKeys(detail, ["activationId"]) && + isInjectedActivationId(requestedActivationId) && + activationId === requestedActivationId + ) { + flushPendingCaptureEvents(requestedActivationId); + resetActivationState(); + postInjectedDrained(requestedActivationId); + } + return; + } + + controlTail = controlTail + .then(async () => { + const detail = parseAuthenticatedInjectedCaptureDrainRequest(rawDetail); + if ( + controlLocked || + !detail || + seenControlNonces.has(detail.nonce) || + activationId !== detail.activationId || + !(await verifyInjectedControlPayload( + controlSecret, + serializeInjectedDrainControl("request", detail.activationId, detail.nonce), + detail.proof + )) + ) { + return; + } - if (Object.keys(detail).length > 0) { - window.dispatchEvent( - new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { - detail + if (!rememberControlNonce(detail.nonce)) return; + flushPendingCaptureEvents(detail.activationId); + resetActivationState(); + await postAuthenticatedInjectedControlAck( + "injected-drained-authenticated", + detail.activationId, + detail.nonce + ); }) - ); + .catch(lockControlChannel); + }); + } + + directControl?.attach({ + activate(config): void { + if (activationId !== config.activationId) { + resetDirectReplayState(); + } + applyCaptureConfig(config); + }, + deactivate(requestedActivationId): void { + if (activationId === requestedActivationId) { + flushPendingCaptureEvents(requestedActivationId); + resetActivationState(); + } + }, + drain(requestedActivationId): InjectedDirectReplaySnapshot { + if (activationId === requestedActivationId) { + flushPendingCaptureEvents(requestedActivationId); + resetActivationState(); + } + return snapshotDirectReplay(); + }, + snapshot(): InjectedDirectReplaySnapshot { + return snapshotDirectReplay(); } + }); - return; + installConsoleHooks(); + installErrorHooks(); + installStorageHooks(); + if (captureNetwork) { + installNetworkHooks(); } + installIndexedDbHooks(); - windowFlags[flag] = true; + emit("notice", { + message: "injected-ready", + href: readCurrentPageUrl() + }); - window.addEventListener(INJECTED_CAPTURE_CONFIG_EVENT, (event: Event) => { - const detail = (event as CustomEvent).detail; + if (!controlSecret && captureActive && activationId) { + postInjectedArmed(activationId); + } + + function applyCaptureConfig(detail: InjectedCaptureConfig): void { + if (!detail.active) { + if (activationId === detail.activationId) { + flushPendingCaptureEvents(detail.activationId); + resetActivationState(); + } + return; + } - if (typeof detail?.active === "boolean") { - captureActive = detail.active; + if (activationId !== detail.activationId) { + resetActivationState(); } - if (detail?.capturePolicy) { + if (detail.capturePolicy) { capturePolicy = detail.capturePolicy; } - if (Object.prototype.hasOwnProperty.call(detail ?? {}, "bodyCaptureMaxBytes")) { + if (Object.prototype.hasOwnProperty.call(detail, "bodyCaptureMaxBytes")) { configuredNetworkBodyCaptureMaxBytes = normalizeConfiguredBodyCaptureMaxBytes( - detail?.bodyCaptureMaxBytes + detail.bodyCaptureMaxBytes ); } + activationId = detail.activationId; + captureActive = true; networkBodyCaptureMaxBytes = resolveNetworkBodyCaptureMaxBytes(); - }); + } - installConsoleHooks(); - installErrorHooks(); - installStorageHooks(); - if (captureNetwork) { - installNetworkHooks(); + function rememberControlNonce(nonce: string): boolean { + if (seenControlNonces.size >= controlNonceLimit) { + controlLocked = true; + resetActivationState(); + return false; + } + seenControlNonces.add(nonce); + return true; } - installIndexedDbHooks(); - emit("notice", { - message: "injected-ready", - href: readCurrentPageUrl() - }); + function lockControlChannel(): void { + controlLocked = true; + resetActivationState(); + } + + async function postAuthenticatedInjectedControlAck( + kind: AuthenticatedInjectedCaptureControlAck["kind"], + acknowledgedActivationId: string, + nonce: string + ): Promise { + if (!controlSecret) return; + const direction = kind === "injected-armed-authenticated" ? "armed" : "drained"; + const message: AuthenticatedInjectedCaptureControlAck = { + source: INJECTED_MESSAGE_SOURCE, + kind, + activationId: acknowledgedActivationId, + nonce, + proof: await signInjectedControlPayload( + controlSecret, + serializeInjectedDrainControl(direction, acknowledgedActivationId, nonce) + ) + }; + window.postMessage(message, "*"); + } + + function emit( + rawType: string, + payload: CapturePayload, + expectedActivationId: string | null = activationId + ): void { + const eventActivationId = expectedActivationId; - function emit(rawType: string, payload: CapturePayload): void { - if (!captureActive) { + if (!captureActive || !eventActivationId || activationId !== eventActivationId) { return; } - pendingCaptureEvents.push({ + const seq = directControl ? (directProducedThrough += 1) : undefined; + const captureEvent: InjectedCaptureEvent = { + activationId: eventActivationId, + ...(seq === undefined ? {} : { seq }), rawType, payload, t: Date.now(), mono: monotonicTime() - }); + }; + pendingCaptureEvents.push(captureEvent); + if (directControl && seq !== undefined) { + directReplayEvents.push(captureEvent as InjectedDirectCaptureEvent); + const eventBytes = estimateInjectedDirectEventBytes(captureEvent); + directReplayEventBytes.push(eventBytes); + directReplayBytes += eventBytes; + while ( + directReplayEvents.length > directControl.replayEventLimit || + directReplayBytes > directControl.replayByteLimit + ) { + const removed = directReplayEvents.shift(); + directReplayBytes -= directReplayEventBytes.shift() ?? 0; + if (removed) { + directTruncatedBefore = removed.seq; + } + } + } if (pendingCaptureEvents.length >= EMIT_FLUSH_MAX_EVENTS) { - flushPendingCaptureEvents(); + flushPendingCaptureEvents(eventActivationId); return; } - schedulePendingCaptureFlush(); + schedulePendingCaptureFlush(eventActivationId); } function emitPrivacyViolation(blockedRawType: string, reason: string): void { @@ -205,24 +1380,38 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = }); } - function schedulePendingCaptureFlush(): void { + function schedulePendingCaptureFlush(expectedActivationId: string): void { if (emitFlushTimer > 0) { return; } - emitFlushTimer = window.setTimeout(() => { + const timer = window.setTimeout(() => { + if (emitFlushTimer !== timer) { + return; + } + emitFlushTimer = 0; - flushPendingCaptureEvents(); + flushPendingCaptureEvents(expectedActivationId); }, 0); + emitFlushTimer = timer; } - function flushPendingCaptureEvents(): void { + function flushPendingCaptureEvents(expectedActivationId: string): void { if (emitFlushTimer > 0) { clearTimeout(emitFlushTimer); emitFlushTimer = 0; } - if (pendingCaptureEvents.length === 0) { + if ( + !captureActive || + activationId !== expectedActivationId || + pendingCaptureEvents.length === 0 + ) { + return; + } + + if (pendingCaptureEvents.some((event) => event.activationId !== expectedActivationId)) { + pendingCaptureEvents.length = 0; return; } @@ -233,6 +1422,8 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = ? { source: INJECTED_MESSAGE_SOURCE, kind: "capture-event", + activationId: expectedActivationId, + ...(first.seq === undefined ? {} : { seq: first.seq }), rawType: first.rawType, payload: first.payload, t: first.t, @@ -241,12 +1432,107 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = : { source: INJECTED_MESSAGE_SOURCE, kind: "capture-events", + activationId: expectedActivationId, events }; window.postMessage(message, "*"); } + function resetDirectReplayState(): void { + directReplayEvents.length = 0; + directReplayEventBytes.length = 0; + directReplayBytes = 0; + directProducedThrough = 0; + directTruncatedBefore = 0; + } + + function snapshotDirectReplay(): InjectedDirectReplaySnapshot { + return { + events: directReplayEvents.map((event) => ({ ...event })), + producedThrough: directProducedThrough, + truncatedBefore: directTruncatedBefore + }; + } + + function estimateInjectedDirectEventBytes(event: InjectedCaptureEvent): number { + return estimateInjectedDirectValueBytes(event, 0); + } + + function estimateInjectedDirectValueBytes(value: unknown, depth: number): number { + if (depth > INJECTED_MESSAGE_MAX_DEPTH + 2) { + return 16; + } + if (value === null || value === undefined || typeof value === "boolean") { + return 8; + } + if (typeof value === "number") { + return 16; + } + if (typeof value === "string") { + return 2 * value.length + 8; + } + if (Array.isArray(value)) { + let bytes = 16; + for (const entry of value) { + bytes += estimateInjectedDirectValueBytes(entry, depth + 1); + } + return bytes; + } + const record = asPlainRecord(value); + if (!record) { + return 32; + } + let bytes = 16; + for (const key of Object.keys(record)) { + bytes += 2 * key.length + 8; + try { + bytes += estimateInjectedDirectValueBytes(record[key], depth + 1); + } catch { + bytes += 32; + } + } + return bytes; + } + + function resetActivationState(): void { + captureActive = false; + activationId = null; + capturePolicy = DEFAULT_CAPTURE_POLICY; + configuredNetworkBodyCaptureMaxBytes = 0; + networkBodyCaptureMaxBytes = 0; + + if (emitFlushTimer > 0) { + clearTimeout(emitFlushTimer); + emitFlushTimer = 0; + } + + pendingCaptureEvents.length = 0; + bodyWindowStartedAt = Date.now(); + bodyWindowCount = 0; + bodyWindowBytes = 0; + noisyConsoleWindowStartedAt = Date.now(); + noisyConsoleWindowCount = 0; + } + + function postInjectedArmed(armedActivationId: string): void { + const message: InjectedCaptureArmedMessage = { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-armed", + activationId: armedActivationId + }; + window.postMessage(message, "*"); + } + + function postInjectedDrained(drainedActivationId: string): void { + const message: InjectedCaptureDrainedMessage = { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-drained", + activationId: drainedActivationId + }; + window.postMessage(message, "*"); + } + function monotonicTime(): number { return performance.timeOrigin + performance.now(); } @@ -300,9 +1586,6 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = "clear" ] as const; const consoleRecord = console as unknown as Record unknown>; - let noisyWindowStartedAt = Date.now(); - let noisyWindowCount = 0; - for (const method of consoleMethods) { const original = consoleRecord[method]; @@ -321,15 +1604,15 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = const now = Date.now(); - if (now - noisyWindowStartedAt >= 1_000) { - noisyWindowStartedAt = now; - noisyWindowCount = 0; + if (now - noisyConsoleWindowStartedAt >= 1_000) { + noisyConsoleWindowStartedAt = now; + noisyConsoleWindowCount = 0; } if (NOISY_CONSOLE_METHODS.has(method)) { - noisyWindowCount += 1; + noisyConsoleWindowCount += 1; - if (noisyWindowCount > NOISY_CONSOLE_MAX_PER_SEC) { + if (noisyConsoleWindowCount > NOISY_CONSOLE_MAX_PER_SEC) { return Reflect.apply(original, console, args); } } @@ -603,7 +1886,9 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = const originalFetch = window.fetch.bind(window); window.fetch = async (...args: Parameters): Promise => { - if (!captureActive) { + const requestActivationId = activationId; + + if (!captureActive || !requestActivationId) { return originalFetch(...args); } @@ -611,51 +1896,63 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = const reqId = nextRequestId("fetch"); const startedMono = monotonicTime(); - emit("fetch", { - phase: "start", - reqId, - requestId: reqId, - method: requestMeta.method, - url: sanitizeOptionalUrl(requestMeta.url), - headers: shouldCaptureNetworkHeaders() ? requestMeta.headers : undefined, - postDataSize: requestMeta.postDataSize - }); + emit( + "fetch", + { + phase: "start", + reqId, + requestId: reqId, + method: requestMeta.method, + url: sanitizeOptionalUrl(requestMeta.url), + headers: shouldCaptureNetworkHeaders() ? requestMeta.headers : undefined, + postDataSize: requestMeta.postDataSize + }, + requestActivationId + ); try { const response = await originalFetch(...args); const contentType = normalizeContentType(response.headers.get("content-type")); const encodedDataLength = parseHeaderInt(response.headers.get("content-length")); - emit("fetch", { - phase: "end", - reqId, - requestId: reqId, - method: requestMeta.method, - url: sanitizeOptionalUrl(requestMeta.url), - status: response.status, - statusText: response.statusText, - ok: response.ok, - redirected: response.redirected, - responseUrl: sanitizeOptionalUrl(response.url), - mimeType: contentType, - headers: shouldCaptureNetworkHeaders() ? readHeaders(response.headers) : undefined, - encodedDataLength, - duration: monotonicTime() - startedMono - }); + emit( + "fetch", + { + phase: "end", + reqId, + requestId: reqId, + method: requestMeta.method, + url: sanitizeOptionalUrl(requestMeta.url), + status: response.status, + statusText: response.statusText, + ok: response.ok, + redirected: response.redirected, + responseUrl: sanitizeOptionalUrl(response.url), + mimeType: contentType, + headers: shouldCaptureNetworkHeaders() ? readHeaders(response.headers) : undefined, + encodedDataLength, + duration: monotonicTime() - startedMono + }, + requestActivationId + ); - void emitFetchResponseBody(reqId, requestMeta, response); + void emitFetchResponseBody(reqId, requestMeta, response, requestActivationId); return response; } catch (error) { - emit("fetchError", { - reqId, - requestId: reqId, - method: requestMeta.method, - url: sanitizeOptionalUrl(requestMeta.url), - duration: monotonicTime() - startedMono, - message: error instanceof Error ? error.message : String(error), - errorText: error instanceof Error ? error.message : String(error) - }); + emit( + "fetchError", + { + reqId, + requestId: reqId, + method: requestMeta.method, + url: sanitizeOptionalUrl(requestMeta.url), + duration: monotonicTime() - startedMono, + message: error instanceof Error ? error.message : String(error), + errorText: error instanceof Error ? error.message : String(error) + }, + requestActivationId + ); throw error; } @@ -684,6 +1981,7 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = __wbUrl?: string; __wbStartedMono?: number; __wbFailed?: boolean; + __wbActivationId?: string; } ).__wbReqId = nextRequestId("xhr"); ( @@ -713,6 +2011,11 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = __wbFailed?: boolean; } ).__wbFailed = false; + ( + this as XMLHttpRequest & { + __wbActivationId?: string; + } + ).__wbActivationId = activationId ?? undefined; const openArgs: unknown[] = [method, url]; @@ -744,19 +2047,29 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = __wbUrl?: string; __wbStartedMono?: number; __wbFailed?: boolean; + __wbActivationId?: string; }; xhr.__wbStartedMono = monotonicTime(); const reqId = xhr.__wbReqId ?? nextRequestId("xhr"); + const requestActivationId = xhr.__wbActivationId ?? activationId; xhr.__wbReqId = reqId; - emit("xhr", { - phase: "start", - reqId, - requestId: reqId, - method: xhr.__wbMethod ?? "GET", - url: sanitizeOptionalUrl(xhr.__wbUrl) ?? "unknown", - postDataSize: estimateBodyLength(body) - }); + if (!requestActivationId || requestActivationId !== activationId) { + return xhrSend.call(this, body as unknown as XMLHttpRequestBodyInit | null | undefined); + } + + emit( + "xhr", + { + phase: "start", + reqId, + requestId: reqId, + method: xhr.__wbMethod ?? "GET", + url: sanitizeOptionalUrl(xhr.__wbUrl) ?? "unknown", + postDataSize: estimateBodyLength(body) + }, + requestActivationId + ); const emitXhrFailure = (reason: string) => { if (xhr.__wbFailed) { @@ -765,15 +2078,19 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = xhr.__wbFailed = true; - emit("fetchError", { - reqId, - requestId: reqId, - method: xhr.__wbMethod ?? "GET", - url: sanitizeOptionalUrl(xhr.__wbUrl) ?? "unknown", - duration: monotonicTime() - (xhr.__wbStartedMono ?? monotonicTime()), - message: reason, - errorText: reason - }); + emit( + "fetchError", + { + reqId, + requestId: reqId, + method: xhr.__wbMethod ?? "GET", + url: sanitizeOptionalUrl(xhr.__wbUrl) ?? "unknown", + duration: monotonicTime() - (xhr.__wbStartedMono ?? monotonicTime()), + message: reason, + errorText: reason + }, + requestActivationId + ); }; this.addEventListener( @@ -803,25 +2120,29 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = () => { const contentType = normalizeContentType(this.getResponseHeader("content-type")); - emit("xhr", { - phase: "end", - reqId, - requestId: reqId, - method: xhr.__wbMethod ?? "GET", - url: sanitizeOptionalUrl(this.responseURL || xhr.__wbUrl) ?? "unknown", - status: this.status, - statusText: this.statusText, - ok: this.status >= 200 && this.status < 400, - headers: shouldCaptureNetworkHeaders() - ? parseXhrResponseHeaders(this.getAllResponseHeaders()) - : undefined, - mimeType: contentType, - encodedDataLength: parseHeaderInt(this.getResponseHeader("content-length")), - failed: Boolean(xhr.__wbFailed), - duration: monotonicTime() - (xhr.__wbStartedMono ?? monotonicTime()) - }); + emit( + "xhr", + { + phase: "end", + reqId, + requestId: reqId, + method: xhr.__wbMethod ?? "GET", + url: sanitizeOptionalUrl(this.responseURL || xhr.__wbUrl) ?? "unknown", + status: this.status, + statusText: this.statusText, + ok: this.status >= 200 && this.status < 400, + headers: shouldCaptureNetworkHeaders() + ? parseXhrResponseHeaders(this.getAllResponseHeaders()) + : undefined, + mimeType: contentType, + encodedDataLength: parseHeaderInt(this.getResponseHeader("content-length")), + failed: Boolean(xhr.__wbFailed), + duration: monotonicTime() - (xhr.__wbStartedMono ?? monotonicTime()) + }, + requestActivationId + ); - void emitXhrResponseBody(reqId, xhr, contentType); + void emitXhrResponseBody(reqId, xhr, contentType, requestActivationId); }, { once: true } ); @@ -843,46 +2164,64 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = return; } + const streamActivationId = activationId; + + if (!streamActivationId) { + return; + } + const url = sanitizeUrlForPrivacy( typeof args[0] === "string" ? args[0] : String(args[0]) ); const streamId = nextRequestId("sse"); - emit("sse", { - phase: "open", - url, - streamId, - requestId: streamId - }); + emit( + "sse", + { + phase: "open", + url, + streamId, + requestId: streamId + }, + streamActivationId + ); this.addEventListener("message", (event) => { const data = typeof event.data === "string" ? event.data.slice(0, 800) : safeSerialize(event.data); - emit("sse", { - phase: "message", - url, - streamId, - requestId: streamId, - eventType: event.type, - lastEventId: event.lastEventId, - ...(capturePolicy.categories.network === "body-allowlist" - ? { data } - : { - dataRedacted: true, - dataSize: typeof event.data === "string" ? event.data.length : undefined - }) - }); + emit( + "sse", + { + phase: "message", + url, + streamId, + requestId: streamId, + eventType: event.type, + lastEventId: event.lastEventId, + ...(capturePolicy.categories.network === "body-allowlist" + ? { data } + : { + dataRedacted: true, + dataSize: typeof event.data === "string" ? event.data.length : undefined + }) + }, + streamActivationId + ); }); this.addEventListener("error", () => { - emit("sse", { - phase: "error", - url, - streamId, - requestId: streamId, - readyState: this.readyState - }); + emit( + "sse", + { + phase: "error", + url, + streamId, + requestId: streamId, + readyState: this.readyState + }, + streamActivationId + ); }); } } @@ -898,8 +2237,13 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = method: string; url: string; }, - response: Response + response: Response, + expectedActivationId: string ): Promise { + if (activationId !== expectedActivationId) { + return; + } + if (response.type === "opaque" || response.type === "opaqueredirect") { return; } @@ -924,7 +2268,7 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = const sampled = await readFetchBodySample(response, maxBodyCaptureBytes); - if (!sampled || sampled.body.length === 0) { + if (activationId !== expectedActivationId || !sampled || sampled.body.length === 0) { return; } @@ -932,27 +2276,31 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = return; } - emit("networkBody", { - source: "fetch", - reqId, - requestId: reqId, - method: requestMeta.method, - url: sanitizeOptionalUrl(response.url || requestMeta.url) ?? "unknown", - status: response.status, - mimeType: contentType, - encoding: "utf8", - body: sampled.body, - size: - typeof encodedDataLength === "number" - ? encodedDataLength - : sampled.truncated - ? Math.max(sampled.sampledBytes + 1, sampled.sampledBytes) - : sampled.sampledBytes, - sampledSize: sampled.sampledBytes, - truncated: - sampled.truncated || - (typeof encodedDataLength === "number" && encodedDataLength > sampled.sampledBytes) - }); + emit( + "networkBody", + { + source: "fetch", + reqId, + requestId: reqId, + method: requestMeta.method, + url: sanitizeOptionalUrl(response.url || requestMeta.url) ?? "unknown", + status: response.status, + mimeType: contentType, + encoding: "utf8", + body: sampled.body, + size: + typeof encodedDataLength === "number" + ? encodedDataLength + : sampled.truncated + ? Math.max(sampled.sampledBytes + 1, sampled.sampledBytes) + : sampled.sampledBytes, + sampledSize: sampled.sampledBytes, + truncated: + sampled.truncated || + (typeof encodedDataLength === "number" && encodedDataLength > sampled.sampledBytes) + }, + expectedActivationId + ); } async function emitXhrResponseBody( @@ -961,8 +2309,13 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = __wbMethod?: string; __wbUrl?: string; }, - contentType: string | undefined + contentType: string | undefined, + expectedActivationId: string ): Promise { + if (activationId !== expectedActivationId) { + return; + } + if (!isBodyCaptureMimeAllowed(contentType)) { return; } @@ -981,7 +2334,7 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = const bodyText = await readXhrBodyText(xhr); - if (!bodyText || bodyText.length === 0) { + if (activationId !== expectedActivationId || !bodyText || bodyText.length === 0) { return; } @@ -992,23 +2345,27 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = return; } - emit("networkBody", { - source: "xhr", - reqId, - requestId: reqId, - method: xhr.__wbMethod ?? "GET", - url: sanitizeOptionalUrl(xhr.responseURL || xhr.__wbUrl) ?? "unknown", - status: xhr.status, - statusText: xhr.statusText, - mimeType: contentType, - encoding: "utf8", - body: clipped.value, - size: typeof encodedDataLength === "number" ? encodedDataLength : clipped.fullBytes, - sampledSize, - truncated: - clipped.truncated || - (typeof encodedDataLength === "number" && encodedDataLength > sampledSize) - }); + emit( + "networkBody", + { + source: "xhr", + reqId, + requestId: reqId, + method: xhr.__wbMethod ?? "GET", + url: sanitizeOptionalUrl(xhr.responseURL || xhr.__wbUrl) ?? "unknown", + status: xhr.status, + statusText: xhr.statusText, + mimeType: contentType, + encoding: "utf8", + body: clipped.value, + size: typeof encodedDataLength === "number" ? encodedDataLength : clipped.fullBytes, + sampledSize, + truncated: + clipped.truncated || + (typeof encodedDataLength === "number" && encodedDataLength > sampledSize) + }, + expectedActivationId + ); } async function readFetchBodySample( @@ -1194,7 +2551,7 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = function isBodyCaptureMimeAllowed(mimeType: string | undefined): boolean { if (!mimeType) { - return true; + return false; } const normalized = mimeType.toLowerCase(); @@ -1397,12 +2754,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); @@ -1590,4 +2956,6 @@ export function installInjectedLiteCaptureHooks(options: InjectedHooksOptions = function serializeDate(value: Date): string { return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); } + + return controlSecret ?? null; } diff --git a/packages/webblackbox/src/lite-capture-agent.test.ts b/packages/webblackbox/src/lite-capture-agent.test.ts index 4e2e19b..0706160 100644 --- a/packages/webblackbox/src/lite-capture-agent.test.ts +++ b/packages/webblackbox/src/lite-capture-agent.test.ts @@ -16,6 +16,10 @@ 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 ACTIVATION_A = `a1_${"a".repeat(64)}`; +const ACTIVATION_B = `a1_${"b".repeat(64)}`; const SCREENSHOT_TEST_CAPTURE_POLICY: CapturePolicy = { ...DEFAULT_CAPTURE_POLICY, @@ -26,6 +30,32 @@ 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" + } +}; + +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, + categories: { + ...DEFAULT_CAPTURE_POLICY.categories, + inputs + } + }; +} + function createAgent( state: Partial = {}, options: Partial = {} @@ -39,6 +69,7 @@ function createAgent( agent.setRecordingStatus({ active: true, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite", @@ -68,31 +99,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 { @@ -177,13 +205,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(); @@ -239,6 +276,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, @@ -254,6 +310,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, @@ -276,7 +347,13 @@ describe("LiteCaptureAgent", () => { expect(state.screenshotInFlight).toBe(false); expect(state.screenshotCaptureBlocked).toBe(true); - await agent.prepareStopCapture(); + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot", "screenshot"], + emittedArtifacts: ["snapshot", "localStorageSnapshot"], + omittedArtifacts: ["screenshot"], + degraded: true, + degradationReason: "artifact-error" + }); expect(snapdomToBlobMock).toHaveBeenCalledTimes(1); @@ -294,18 +371,247 @@ describe("LiteCaptureAgent", () => { screenshotIdleMs: 1_000 } }); + const internal = agent as unknown as { + captureScreenshot: (reason: string) => Promise; + queueEvent: (rawType: string, payload: Record) => boolean; + stopScreenshotCaptured: boolean; + }; const captureScreenshotSpy = vi - .spyOn( - agent as unknown as { - captureScreenshot: (reason: string) => Promise; + .spyOn(internal, "captureScreenshot") + .mockImplementation(async (reason) => { + internal.stopScreenshotCaptured = internal.queueEvent("screenshot", { reason }); + }); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot", "screenshot"], + emittedArtifacts: ["snapshot", "localStorageSnapshot", "screenshot"], + omittedArtifacts: [], + degraded: false, + degradationReason: null + }); + + expect(captureScreenshotSpy).toHaveBeenCalledWith("stop"); + + agent.dispose(); + }); + + it("reports every configured artifact as omitted for an inactive agent", async () => { + const { agent, emitBatch } = createInactiveAgent(); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot"], + emittedArtifacts: [], + omittedArtifacts: ["snapshot", "localStorageSnapshot"], + degraded: true, + degradationReason: "inactive" + }); + expect(emitBatch).not.toHaveBeenCalled(); + + agent.dispose(); + }); + + it("reports every configured artifact as omitted after disposal", async () => { + const { agent, emitBatch } = createAgent(); + emitBatch.mockClear(); + agent.dispose(); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot"], + emittedArtifacts: [], + omittedArtifacts: ["snapshot", "localStorageSnapshot"], + degraded: true, + degradationReason: "disposed" + }); + expect(emitBatch).not.toHaveBeenCalled(); + }); + + it("reports every configured artifact as omitted when the document is out of scope", async () => { + const excludedPolicy: CapturePolicy = { + ...DEFAULT_CAPTURE_POLICY, + scope: { + ...DEFAULT_CAPTURE_POLICY.scope, + excludedUrlPatterns: ["*"] + } + }; + const { agent, emitBatch } = createAgent({ capturePolicy: excludedPolicy }); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot"], + emittedArtifacts: [], + omittedArtifacts: ["snapshot", "localStorageSnapshot"], + degraded: true, + degradationReason: "out-of-scope" + }); + expect(emitBatch).not.toHaveBeenCalled(); + + agent.dispose(); + }); + + it("continues final artifact capture after a localStorage SecurityError", async () => { + const { agent } = createAgent({ + capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY, + sampling: { + screenshotIdleMs: 1_000 + } + }); + const internal = agent as unknown as { + captureScreenshot: (reason: string) => Promise; + queueEvent: (rawType: string, payload: Record) => boolean; + stopScreenshotCaptured: boolean; + }; + vi.spyOn(window, "localStorage", "get").mockImplementation(() => { + throw new DOMException("Storage is unavailable", "SecurityError"); + }); + vi.spyOn(internal, "captureScreenshot").mockImplementation(async (reason) => { + internal.stopScreenshotCaptured = internal.queueEvent("screenshot", { reason }); + }); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot", "screenshot"], + emittedArtifacts: ["snapshot", "screenshot"], + omittedArtifacts: ["localStorageSnapshot"], + degraded: true, + degradationReason: "artifact-error" + }); + + agent.dispose(); + }); + + it("captures localStorage when the final DOM snapshot throws", async () => { + const { agent } = createAgent(); + const internal = agent as unknown as { + emitDomSnapshot: (reason: string) => boolean; + }; + vi.spyOn(internal, "emitDomSnapshot").mockImplementation(() => { + throw new Error("DOM snapshot failed"); + }); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot"], + emittedArtifacts: ["localStorageSnapshot"], + omittedArtifacts: ["snapshot"], + degraded: true, + degradationReason: "artifact-error" + }); + + agent.dispose(); + }); + + it("freezes ordinary listeners while retaining the activation for final artifacts", async () => { + const { agent, emitBatch } = createAgent(); + const internal = agent as unknown as { + activationId: string | null; + captureInstalled: boolean; + recordingActive: boolean; + }; + + agent.freezeOrdinaryCapture(); + emitBatch.mockClear(); + + expect(internal.recordingActive).toBe(true); + expect(internal.activationId).toBe(ACTIVATION_A); + expect(internal.captureInstalled).toBe(false); + + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + agent.flush(); + expect(emittedEvents(emitBatch).some((event) => event.rawType === "click")).toBe(false); + + await expect(agent.prepareStopCapture()).resolves.toEqual({ + expectedArtifacts: ["snapshot", "localStorageSnapshot"], + emittedArtifacts: ["snapshot", "localStorageSnapshot"], + omittedArtifacts: [], + degraded: false, + degradationReason: null + }); + agent.dispose(); + }); + + it("quiesces ordinary producers while retaining the MAIN-world tail bridge", () => { + const { agent, emitBatch } = createAgent(); + + agent.quiesceOrdinaryCapture(); + emitBatch.mockClear(); + document.body.dispatchEvent(new MouseEvent("click", { bubbles: true })); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + activationId: ACTIVATION_A, + rawType: "console", + payload: { method: "info", redacted: true }, + t: 1, + mono: 1 }, - "captureScreenshot" - ) - .mockResolvedValue(); + source: window + }) + ); + agent.flush(); + + expect(emittedRawTypes(emitBatch)).toContain("console"); + expect(emittedRawTypes(emitBatch)).not.toContain("click"); + + agent.freezeOrdinaryCapture(); + emitBatch.mockClear(); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + activationId: ACTIVATION_A, + rawType: "console", + payload: { method: "warn", redacted: true }, + t: 2, + mono: 2 + }, + source: window + }) + ); + agent.flush(); + expect(emitBatch).not.toHaveBeenCalled(); + agent.dispose(); + }); + + it("emits each stop artifact once after earlier DOM and storage captures", async () => { + const { agent, emitBatch } = createAgent({ + capturePolicy: SCREENSHOT_TEST_CAPTURE_POLICY, + sampling: { + screenshotIdleMs: 1_000 + } + }); + const internal = agent as unknown as { + hasDomSnapshot: boolean; + hasLocalStorageSnapshot: boolean; + captureScreenshot: (reason: string) => Promise; + queueEvent: (rawType: string, payload: Record) => boolean; + stopScreenshotCaptured: boolean; + }; + internal.hasDomSnapshot = true; + internal.hasLocalStorageSnapshot = true; + const captureScreenshotSpy = vi + .spyOn(internal, "captureScreenshot") + .mockImplementation(async (reason) => { + internal.stopScreenshotCaptured = internal.queueEvent("screenshot", { reason }); + }); + + const firstResult = await agent.prepareStopCapture(); + const secondResult = await agent.prepareStopCapture(); + agent.setRecordingStatus({ + active: false, + activationId: ACTIVATION_A, + sid: "S-lite-agent-test", + tabId: 7, + mode: "lite" + }); - await agent.prepareStopCapture(); + const stopEvents = emittedEvents(emitBatch).filter((event) => event.payload?.reason === "stop"); + expect(stopEvents.filter((event) => event.rawType === "snapshot")).toHaveLength(1); + expect(stopEvents.filter((event) => event.rawType === "localStorageSnapshot")).toHaveLength(1); + expect(stopEvents.filter((event) => event.rawType === "screenshot")).toHaveLength(1); + expect(captureScreenshotSpy).toHaveBeenCalledTimes(1); expect(captureScreenshotSpy).toHaveBeenCalledWith("stop"); + expect(secondResult).toEqual(firstResult); agent.dispose(); }); @@ -319,6 +625,7 @@ describe("LiteCaptureAgent", () => { agent.setRecordingStatus({ active: true, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite" @@ -332,6 +639,7 @@ describe("LiteCaptureAgent", () => { emitBatch.mockClear(); agent.setRecordingStatus({ active: false, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite" @@ -345,6 +653,266 @@ 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", + activationId: ACTIVATION_A, + 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", + activationId: ACTIVATION_A, + 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("binds MAIN observations and armed acknowledgements to the exact activation", () => { + const onInjectedBridgeArmed = vi.fn(); + const { agent, emitBatch } = createAgent({}, { onInjectedBridgeArmed }); + + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-armed", + activationId: ACTIVATION_B + }, + source: window + }) + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-armed", + activationId: ACTIVATION_A + }, + source: window + }) + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "injected-armed", + activationId: ACTIVATION_A + }, + source: window + }) + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + activationId: ACTIVATION_B, + rawType: "console", + payload: { method: "info", redacted: true }, + t: 1, + mono: 1 + }, + source: window + }) + ); + agent.flush(); + + expect(onInjectedBridgeArmed).toHaveBeenCalledOnce(); + expect(onInjectedBridgeArmed).toHaveBeenCalledWith(ACTIVATION_A); + expect(emitBatch).not.toHaveBeenCalled(); + agent.dispose(); + }); + + it("deduplicates streamed MAIN events against the privileged replay window", () => { + const { agent, emitBatch } = createAgent(); + const first = { + activationId: ACTIVATION_A, + seq: 1, + rawType: "console", + payload: { method: "info", marker: "streamed" }, + t: 10, + mono: 11 + }; + const second = { + activationId: ACTIVATION_A, + seq: 2, + rawType: "pageError", + payload: { message: "tail" }, + t: 12, + mono: 13 + }; + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + ...first + }, + source: window + }) + ); + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + ...first + }, + source: window + }) + ); + agent.flush(); + + expect( + agent.mergeInjectedDirectReplay({ + kind: "drained", + activationId: ACTIVATION_A, + revision: 2, + events: [first, second], + producedThrough: 2, + truncatedBefore: 0, + degraded: false, + replayed: false + }) + ).toEqual({ confirmed: true, conflict: false, replayedEvents: 1 }); + const events = emittedEvents(emitBatch); + expect(events.filter((event) => event.rawType === "console")).toHaveLength(1); + expect(events.filter((event) => event.rawType === "pageError")).toHaveLength(1); + + expect( + agent.mergeInjectedDirectReplay({ + kind: "drained", + activationId: ACTIVATION_A, + revision: 3, + events: [{ ...first, payload: { method: "error", marker: "forged-conflict" } }], + producedThrough: 1, + truncatedBefore: 0, + degraded: false, + replayed: true + }) + ).toEqual({ confirmed: false, conflict: true, replayedEvents: 1 }); + + agent.dispose(); + }); + + it("drops an old activation buffer and timer before accepting its replacement", async () => { + const { agent, emitBatch } = createAgent(); + const queueRawEvent = ( + agent as unknown as { queueRawEvent(event: RawRecorderEvent): void } + ).queueRawEvent.bind(agent); + + queueRawEvent({ + source: "content", + rawType: "old-activation", + tabId: 7, + sid: "S-old", + t: 1, + mono: 1, + payload: {} + }); + agent.setRecordingStatus({ + active: true, + activationId: ACTIVATION_B, + sid: "S-new", + tabId: 7, + mode: "lite" + }); + queueRawEvent({ + source: "content", + rawType: "new-activation", + tabId: 7, + sid: "S-new", + t: 2, + mono: 2, + payload: {} + }); + + await vi.advanceTimersByTimeAsync(200); + + const events = emitBatch.mock.calls.flatMap((call) => call[0] as RawRecorderEvent[]); + expect(events.some((event) => event.rawType === "old-activation")).toBe(false); + expect(events.some((event) => event.rawType === "new-activation")).toBe(true); + expect(emitBatch.mock.calls.every((call) => call[1] === ACTIVATION_B)).toBe(true); + 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", + activationId: ACTIVATION_A, + events: Array.from({ length: 24 }, (_, index) => ({ + activationId: ACTIVATION_A, + 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); @@ -364,6 +932,36 @@ describe("LiteCaptureAgent", () => { agent.dispose(); }); + it("keeps Full interaction capture isolated without installing a window bridge", () => { + const addEventListener = vi.spyOn(window, "addEventListener"); + const onInjectedBridgeArmed = vi.fn(); + const { agent, emitBatch } = createAgent({ mode: "full" }, { onInjectedBridgeArmed }); + + expect(addEventListener.mock.calls.some(([type]) => type === "message")).toBe(false); + + window.dispatchEvent( + new MessageEvent("message", { + data: { + source: INJECTED_MESSAGE_SOURCE, + kind: "capture-event", + activationId: ACTIVATION_A, + rawType: "console", + payload: { method: "error" }, + t: 1, + mono: 1 + }, + source: window + }) + ); + clickTarget(); + agent.flush(); + + expect(emittedRawTypes(emitBatch)).toContain("click"); + expect(emittedRawTypes(emitBatch)).not.toContain("console"); + expect(onInjectedBridgeArmed).not.toHaveBeenCalled(); + agent.dispose(); + }); + it("throttles full-mode pointer tracking", () => { const { agent } = createAgent({ mode: "full" }); const state = agent as unknown as { @@ -426,6 +1024,7 @@ describe("LiteCaptureAgent", () => { agent.setRecordingStatus({ active: false, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite" @@ -455,6 +1054,7 @@ describe("LiteCaptureAgent", () => { agent.setRecordingStatus({ active: false, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite" @@ -482,7 +1082,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 } @@ -497,6 +1097,7 @@ describe("LiteCaptureAgent", () => { agent.setRecordingStatus({ active: false, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite", @@ -517,12 +1118,114 @@ 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("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, + activationId: ACTIVATION_A, + 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, + 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(); agent.setRecordingStatus({ active: false, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite" @@ -536,7 +1239,7 @@ describe("LiteCaptureAgent", () => { expect(storageEvent?.payload).toMatchObject({ count: 1, - mode: "counts-only", + mode: "schema-only", redacted: true }); expect(storageEvent?.payload).not.toHaveProperty("entries"); @@ -559,7 +1262,7 @@ describe("LiteCaptureAgent", () => { const cookieEvent = events.find((event) => event.rawType === "cookieSnapshot"); expect(cookieEvent?.payload).toMatchObject({ - mode: "counts-only", + mode: "schema-only", redacted: true }); expect(cookieEvent?.payload).not.toHaveProperty("names"); @@ -649,6 +1352,7 @@ describe("LiteCaptureAgent", () => { agent.setRecordingStatus({ active: false, + activationId: ACTIVATION_A, sid: "S-lite-agent-test", tabId: 7, mode: "lite" @@ -1127,6 +1831,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(); @@ -1359,7 +2253,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(); @@ -1372,7 +2266,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(); @@ -1392,7 +2286,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 3744285..e86660a 100644 --- a/packages/webblackbox/src/lite-capture-agent.ts +++ b/packages/webblackbox/src/lite-capture-agent.ts @@ -1,15 +1,31 @@ import { DEFAULT_CAPTURE_POLICY, + evaluateCaptureScope, sanitizeUrlForPrivacy, type CapturePolicy } from "@webblackbox/protocol"; 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 type { + LiteCaptureAgentOptions, + LiteCaptureSampling, + LiteCaptureState, + LiteStopCaptureArtifact, + LiteStopCaptureDegradationReason, + LiteStopCaptureResult +} from "./types.js"; +import { + isInjectedActivationId, + parseInjectedCaptureArmedMessage, + parseInjectedCaptureWindowMessage, + type InjectedDirectCaptureEvent, + type InjectedDirectDrainResult +} from "./injected-hooks.js"; const PRE_RECORDING_BUFFER_MAX = 400; +const INJECTED_DIRECT_SEQUENCE_CACHE_MAX = 512; +const INJECTED_DIRECT_SIGNATURE_CACHE_MAX_BYTES = 1024 * 1024; const SCREENSHOT_MAX_DATA_URL_LENGTH = 10 * 1024 * 1024; const SCREENSHOT_POINTER_STALE_MS = 2_500; const SCREENSHOT_ACTION_COOLDOWN_MS = 2_000; @@ -62,6 +78,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", @@ -102,6 +120,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 +187,13 @@ type MutationBatchSummary = { type TargetPayloadDetail = "action" | "input" | "fast" | "navigation"; type CapturePressureStage = "none" | "soft" | "hard" | "critical"; +type EditableKeydownContext = "input" | "protected" | "textarea" | "contenteditable"; + +export type InjectedDirectReplayMergeResult = Readonly<{ + confirmed: boolean; + conflict: boolean; + replayedEvents: number; +}>; /** * Browser-side event capture agent used by `WebBlackboxLiteSdk`. @@ -143,11 +203,14 @@ export class LiteCaptureAgent { private readonly eventBuffer: RawRecorderEvent[] = []; private readonly preRecordingBuffer: RawRecorderEvent[] = []; private readonly cleanupCallbacks: Array<() => void> = []; + private injectedBridgeCleanup: (() => void) | null = null; private readonly frameMarker: string | undefined; private readonly isTopLevelFrame: boolean; private recordingActive = false; private captureInstalled = false; + private activationId: string | null = null; + private eventBufferActivationId: string | null = null; private sid = ""; private tabId = -1; private mode: LiteCaptureState["mode"] = "lite"; @@ -165,13 +228,17 @@ 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; private screenshotCaptureBlocked = false; private screenshotInFlightPromise: Promise | null = null; private screenshotPendingReason: string | null = null; - private hasCapturedScreenshot = false; + private stopCapturePromise: Promise | null = null; + private stopCaptureResult: LiteStopCaptureResult | null = null; + private stopScreenshotCaptured = false; + private ordinaryCaptureFrozen = false; private lastActionScreenshotMono = Number.NEGATIVE_INFINITY; private lastUserActivityMono = monotonicTime(); private scrollBurstActiveUntilMono = Number.NEGATIVE_INFINITY; @@ -199,6 +266,14 @@ export class LiteCaptureAgent { private droppedLowPriorityEvents = 0; private disposed = false; private pendingQuietRecoverySummary = false; + private injectedBridgeWindowStartedAt = 0; + private injectedBridgeEventCount = 0; + private injectedBridgePayloadWindowStartedAt = 0; + private injectedBridgePayloadChars = 0; + private injectedBridgeArmedActivationId: string | null = null; + private readonly injectedDirectEventSignatures = new Map(); + private readonly injectedDirectConflictedSequences = new Set(); + private injectedDirectSignatureBytes = 0; /** Creates and installs capture hooks for the current page context. */ public constructor(private readonly options: LiteCaptureAgentOptions) { @@ -213,12 +288,52 @@ export class LiteCaptureAgent { return; } - const wasRecording = this.recordingActive; + const requestedActivationId = isInjectedActivationId(state.activationId) + ? state.activationId + : null; + const currentActivationId = this.activationId; + + if (state.active ? !requestedActivationId : currentActivationId !== requestedActivationId) { + return; + } + + let wasRecording = this.recordingActive; + const nextPolicy = state.capturePolicy ?? this.capturePolicy; + const nextMode = state.mode ?? this.mode; + const replacingActivation = Boolean( + state.active && + currentActivationId && + (currentActivationId !== requestedActivationId || nextMode !== this.mode) + ); + + if (replacingActivation) { + this.dropPendingActivation(); + wasRecording = false; + } + + if (typeof state.tabId === "number" && Number.isFinite(state.tabId)) { + this.tabId = Math.round(state.tabId); + } + + const nextActive = Boolean( + state.active && requestedActivationId && this.isDocumentWithinScope(nextPolicy) + ); + + this.capturePolicy = nextPolicy; + this.mode = nextMode; + this.sampling = sanitizeSamplingConfig(state.sampling); + + if (typeof state.sid === "string") { + this.sid = state.sid; + } - if (state.active && !wasRecording) { + if (nextActive && !wasRecording) { this.hasDomSnapshot = false; this.hasLocalStorageSnapshot = false; - this.hasCapturedScreenshot = false; + this.stopCapturePromise = null; + this.stopCaptureResult = null; + this.stopScreenshotCaptured = false; + this.ordinaryCaptureFrozen = false; } if (!state.active && wasRecording && this.shouldCaptureDomSnapshots() && !this.hasDomSnapshot) { @@ -228,41 +343,40 @@ export class LiteCaptureAgent { if ( !state.active && wasRecording && - this.shouldCaptureStorageSnapshots() && + this.shouldCaptureLocalStorageSnapshot() && !this.hasLocalStorageSnapshot ) { this.emitLocalStorageSnapshot("stop"); } - this.recordingActive = state.active; - 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; - } - - if (typeof state.tabId === "number" && Number.isFinite(state.tabId)) { - this.tabId = Math.round(state.tabId); - } - - if (this.recordingActive) { - this.ensureCaptureInstalled(); - - if (!wasRecording) { - this.flushPreRecordingBuffer(); + if (nextActive && requestedActivationId) { + if (this.activationId !== requestedActivationId) { + this.clearActivationBuffers(); + this.resetInjectedBridgeBudget(); } + this.activationId = requestedActivationId; + this.recordingActive = true; + this.ordinaryCaptureFrozen = false; + this.scheduleScopeExpiry(); + this.ensureCaptureInstalled(); this.ensureIndicator(this.sid, this.mode); this.startMutationAndSnapshots(); return; } + const stoppingActivationId = this.activationId; + this.cancelBufferedFlush(); + this.recordingActive = false; + this.ordinaryCaptureFrozen = false; + this.clearScopeExpiryTimer(); this.stopMutationAndSnapshots(); this.teardownCapture(); this.removeIndicator(); - this.flush(); + this.drainBufferedEvents(stoppingActivationId, true); + this.clearActivationBuffers(); + this.activationId = null; + this.resetInjectedBridgeBudget(); } /** Emits a manual marker event and optional snapshot/screenshot side effects. */ @@ -301,28 +415,207 @@ export class LiteCaptureAgent { /** Flushes the current buffered raw events immediately. */ public flush(): void { this.flushPendingScrollEvent(); - this.drainBufferedEvents(); + this.drainBufferedEvents(this.activationId); + } + + /** Stops ordinary listeners/timers while retaining one activation for final artifacts. */ + public freezeOrdinaryCapture(): void { + this.freezeCaptureProducers(false); + } + + /** Stops ordinary producers while preserving the MAIN-world bridge for its tail barrier. */ + public quiesceOrdinaryCapture(): void { + this.freezeCaptureProducers(true); + } + + /** + * Merges the privileged MAIN replay window after quiescence. Window messages + * are only a streaming optimization; this result is the stop-tail evidence. + */ + public mergeInjectedDirectReplay( + result: InjectedDirectDrainResult + ): InjectedDirectReplayMergeResult { + if ( + this.disposed || + !this.recordingActive || + !this.activationId || + result.activationId !== this.activationId + ) { + return { confirmed: false, conflict: false, replayedEvents: 0 }; + } + + let expectedSequence = result.truncatedBefore + 1; + let replayedEvents = 0; + let conflict = false; + + for (const event of result.events) { + if (event.activationId !== this.activationId || event.seq !== expectedSequence) { + return { confirmed: false, conflict: true, replayedEvents }; + } + expectedSequence += 1; + const signature = serializeInjectedDirectEvent(event); + const streamedSignature = this.injectedDirectEventSignatures.get(event.seq); + conflict ||= this.injectedDirectConflictedSequences.has(event.seq); + if (!signature || streamedSignature !== signature) { + if (streamedSignature !== undefined) { + conflict = true; + } + this.queueInjectedRawEvent(event, event.t, event.mono); + replayedEvents += 1; + } + if (signature) { + this.storeInjectedDirectEventSignature(event.seq, signature); + } + } + + const coversProducedThrough = + result.events.length === 0 + ? result.producedThrough === result.truncatedBefore + : expectedSequence - 1 === result.producedThrough; + this.flush(); + return { + confirmed: result.truncatedBefore === 0 && coversProducedThrough && !conflict, + conflict, + replayedEvents + }; } - /** Completes any in-flight screenshot and captures one final frame if none was recorded yet. */ - public async prepareStopCapture(): Promise { - if (this.disposed || !this.recordingActive) { + private freezeCaptureProducers(preserveInjectedBridge: boolean): void { + if (this.disposed || !this.recordingActive || !this.activationId) { return; } + if (!this.ordinaryCaptureFrozen) { + this.stopMutationAndSnapshots(); + this.teardownCapture({ preserveInjectedBridge }); + this.clearScopeExpiryTimer(); + this.ordinaryCaptureFrozen = true; + } else if (!preserveInjectedBridge) { + this.teardownInjectedMessageBridge(); + } + this.flush(); + } + + /** Emits exactly one final stop artifact per enabled top-frame capture category. */ + public prepareStopCapture(): Promise { + if (this.stopCaptureResult) { + return Promise.resolve(this.stopCaptureResult); + } + + if (this.stopCapturePromise) { + return this.stopCapturePromise; + } + + const expectedArtifacts = this.resolveExpectedStopCaptureArtifacts(); + const capturePromise = this.prepareStopCaptureOnce(expectedArtifacts); + this.stopCapturePromise = capturePromise; + void capturePromise.then( + (result) => { + if (this.stopCapturePromise === capturePromise) { + this.stopCaptureResult = result; + this.stopCapturePromise = null; + } + }, + () => { + if (this.stopCapturePromise === capturePromise) { + this.stopCapturePromise = null; + } + } + ); + return capturePromise; + } + + private async prepareStopCaptureOnce( + expectedArtifacts: readonly LiteStopCaptureArtifact[] + ): Promise { + if (this.disposed) { + return createStopCaptureResult(expectedArtifacts, [], "disposed"); + } + + let withinScope = false; try { - await this.screenshotInFlightPromise; + withinScope = this.isDocumentWithinScope(); } catch { - void 0; + withinScope = false; } - if (this.shouldCaptureScreenshots() && !this.hasCapturedScreenshot) { - if (this.screenshotCaptureBlocked) { - return; + if (!withinScope) { + this.deactivateForScope(); + return createStopCaptureResult(expectedArtifacts, [], "out-of-scope"); + } + + if (!this.recordingActive || !this.activationId) { + return createStopCaptureResult(expectedArtifacts, [], "inactive"); + } + + const emittedArtifacts: LiteStopCaptureArtifact[] = []; + + if (expectedArtifacts.includes("snapshot")) { + try { + if (this.emitDomSnapshot("stop")) { + emittedArtifacts.push("snapshot"); + } + } catch { + void 0; + } + } + + if (expectedArtifacts.includes("localStorageSnapshot")) { + try { + if (this.emitLocalStorageSnapshot("stop")) { + emittedArtifacts.push("localStorageSnapshot"); + } + } catch { + void 0; } + } + + if (expectedArtifacts.includes("screenshot")) { + try { + await this.screenshotInFlightPromise; - await this.startScreenshotCapture("stop"); + if (!this.screenshotCaptureBlocked && this.shouldCaptureScreenshots()) { + await this.startScreenshotCapture("stop"); + } + + if (this.stopScreenshotCaptured) { + emittedArtifacts.push("screenshot"); + } + } catch { + void 0; + } } + + return createStopCaptureResult( + expectedArtifacts, + emittedArtifacts, + emittedArtifacts.length === expectedArtifacts.length ? null : "artifact-error" + ); + } + + private resolveExpectedStopCaptureArtifacts(): LiteStopCaptureArtifact[] { + if (this.mode === "full" || !this.isTopLevelFrame) { + return []; + } + + const expectedArtifacts: LiteStopCaptureArtifact[] = []; + + if (this.capturePolicy.categories.dom !== "off") { + expectedArtifacts.push("snapshot"); + } + + if (this.capturePolicy.categories.storage !== "off") { + expectedArtifacts.push("localStorageSnapshot"); + } + + if ( + this.sampling.screenshotIdleMs > 0 && + this.capturePolicy.categories.screenshots === "allow" + ) { + expectedArtifacts.push("screenshot"); + } + + return expectedArtifacts; } /** Tears down listeners/timers and releases all internal buffers. */ @@ -332,6 +625,7 @@ export class LiteCaptureAgent { } this.disposed = true; + this.clearScopeExpiryTimer(); this.stopMutationAndSnapshots(); this.removeIndicator(); @@ -341,63 +635,176 @@ export class LiteCaptureAgent { } this.runCleanupCallbacks(); + this.teardownInjectedMessageBridge(); this.clearPendingTargetEnrichmentTimers(); this.eventBuffer.length = 0; this.preRecordingBuffer.length = 0; + this.eventBufferActivationId = null; + this.activationId = null; + this.resetInjectedBridgeBudget(); this.mutationSummary = createEmptyMutationSummary(); this.selectorCache = new WeakMap(); this.selectorCacheSize = 0; - this.hasCapturedScreenshot = false; } private installInjectedMessageBridge(): void { - this.listen(window, "message", (event: MessageEvent) => { + if (this.injectedBridgeCleanup) { + return; + } + + const listener = (event: MessageEvent) => { if (event.source !== window) { return; } - const data = event.data as InjectedCaptureWindowMessage | undefined; - - if (!data || data.source !== INJECTED_MESSAGE_SOURCE) { + const armedActivationId = parseInjectedCaptureArmedMessage(event.data); + + if (armedActivationId) { + if ( + this.recordingActive && + this.mode !== "full" && + this.activationId === armedActivationId && + this.injectedBridgeArmedActivationId !== armedActivationId + ) { + this.injectedBridgeArmedActivationId = armedActivationId; + this.options.onInjectedBridgeArmed?.(armedActivationId); + } return; } - if (data.kind === "capture-event" && typeof data.rawType === "string") { - this.queueInjectedRawEvent(data); + const events = parseInjectedCaptureWindowMessage(event.data); + const payloadChars = events ? JSON.stringify(events).length : 0; + const messageActivationId = events?.[0]?.activationId; + if ( + !events || + !messageActivationId || + !this.recordingActive || + this.mode === "full" || + this.activationId !== messageActivationId || + !this.consumeInjectedBridgeBudget(events.length, payloadChars) + ) { return; } - if (data.kind === "capture-events" && Array.isArray(data.events)) { - for (const item of data.events) { - if (item && typeof item.rawType === "string") { - this.queueInjectedRawEvent(item); + const receivedAt = Date.now(); + const receivedMono = monotonicTime(); + for (const [index, item] of events.entries()) { + if (item.activationId !== messageActivationId) { + return; + } + if (item.seq !== undefined) { + const observed = this.rememberInjectedDirectEvent(item as InjectedDirectCaptureEvent); + if (observed === "duplicate") { + continue; } } - - return; + this.queueInjectedRawEvent(item, receivedAt, receivedMono + index / 1_000); } + }; + window.addEventListener("message", listener); + this.injectedBridgeCleanup = () => { + window.removeEventListener("message", listener); + this.injectedBridgeCleanup = null; + }; + } + + private consumeInjectedBridgeBudget(eventCount: number, payloadChars: number): boolean { + const now = Date.now(); + if (now - this.injectedBridgeWindowStartedAt >= 1_000) { + this.injectedBridgeWindowStartedAt = now; + this.injectedBridgeEventCount = 0; + } + + 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 resetInjectedBridgeBudget(): void { + this.injectedBridgeWindowStartedAt = 0; + this.injectedBridgeEventCount = 0; + this.injectedBridgePayloadWindowStartedAt = 0; + this.injectedBridgePayloadChars = 0; + this.injectedBridgeArmedActivationId = null; + this.injectedDirectEventSignatures.clear(); + this.injectedDirectConflictedSequences.clear(); + this.injectedDirectSignatureBytes = 0; + } + + private rememberInjectedDirectEvent( + event: InjectedDirectCaptureEvent + ): "new" | "duplicate" | "conflict" { + const signature = serializeInjectedDirectEvent(event); + if (!signature) { + return "conflict"; + } + const existing = this.injectedDirectEventSignatures.get(event.seq); + if (existing === signature) { + return "duplicate"; + } + if (existing !== undefined) { + this.injectedDirectConflictedSequences.add(event.seq); + return "conflict"; + } + if (signature.length * 2 > INJECTED_DIRECT_SIGNATURE_CACHE_MAX_BYTES) { + return "conflict"; + } + this.storeInjectedDirectEventSignature(event.seq, signature); + return "new"; + } + + private storeInjectedDirectEventSignature(seq: number, signature: string): void { + const previous = this.injectedDirectEventSignatures.get(seq); + if (previous !== undefined) { + this.injectedDirectSignatureBytes -= previous.length * 2; + } + this.injectedDirectEventSignatures.set(seq, signature); + this.injectedDirectSignatureBytes += signature.length * 2; + while ( + this.injectedDirectEventSignatures.size > INJECTED_DIRECT_SEQUENCE_CACHE_MAX || + this.injectedDirectSignatureBytes > INJECTED_DIRECT_SIGNATURE_CACHE_MAX_BYTES + ) { + const oldest = this.injectedDirectEventSignatures.keys().next().value as number | undefined; + if (oldest === undefined) { + break; } - }); + this.injectedDirectSignatureBytes -= + (this.injectedDirectEventSignatures.get(oldest)?.length ?? 0) * 2; + this.injectedDirectEventSignatures.delete(oldest); + this.injectedDirectConflictedSequences.delete(oldest); + } } - private queueInjectedRawEvent(event: { - rawType: string; - payload?: Record; - t?: number; - mono?: number; - }): void { + private queueInjectedRawEvent( + event: { activationId: string; rawType: string; payload: Record }, + receivedAt = Date.now(), + receivedMono = monotonicTime() + ): void { + if (event.activationId !== this.activationId || !this.recordingActive) { + return; + } + 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 }); } @@ -453,16 +860,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 ); @@ -823,20 +1225,28 @@ export class LiteCaptureAgent { return ( this.mode !== "full" && this.isTopLevelFrame && + this.isDocumentWithinScope() && + !hasDisallowedEmbeddedFrame(this.capturePolicy) && this.sampling.screenshotIdleMs > 0 && - this.capturePolicy.categories.screenshots !== "off" + this.capturePolicy.categories.screenshots === "allow" ); } 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" ); } @@ -844,12 +1254,22 @@ 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") ); } + private shouldCaptureLocalStorageSnapshot(): boolean { + return ( + this.mode !== "full" && + this.isTopLevelFrame && + this.isDocumentWithinScope() && + this.capturePolicy.categories.storage !== "off" + ); + } + private startMutationAndSnapshots(): void { if (this.shouldCaptureMutationSignals() && !this.mutationObserver) { this.ensureMutationObserverActive(); @@ -958,19 +1378,55 @@ export class LiteCaptureAgent { this.installPerformanceCapture(); } - this.installInjectedMessageBridge(); + if (this.mode !== "full") { + this.installInjectedMessageBridge(); + } this.captureInstalled = true; this.emitLifecycleEvent("visibilitychange", { state: document.visibilityState }); } - private teardownCapture(): void { - if (!this.captureInstalled) { - return; + private teardownCapture(options: { preserveInjectedBridge?: boolean } = {}): void { + if (this.captureInstalled) { + this.captureInstalled = false; + this.runCleanupCallbacks(); + this.clearPendingTargetEnrichmentTimers(); } - this.captureInstalled = false; - this.runCleanupCallbacks(); - this.clearPendingTargetEnrichmentTimers(); + if (options.preserveInjectedBridge !== true) { + this.teardownInjectedMessageBridge(); + } + } + + private teardownInjectedMessageBridge(): void { + this.injectedBridgeCleanup?.(); + } + + private dropPendingActivation(): void { + this.cancelBufferedFlush(); + this.recordingActive = false; + this.clearScopeExpiryTimer(); + this.stopMutationAndSnapshots(); + this.teardownCapture(); + this.removeIndicator(); + this.clearActivationBuffers(); + this.activationId = null; + this.resetInjectedBridgeBudget(); + } + + private clearActivationBuffers(): void { + this.cancelBufferedFlush(); + this.eventBuffer.length = 0; + this.preRecordingBuffer.length = 0; + this.eventBufferActivationId = null; + this.pendingScrollPayload = null; + this.mutationSummary = createEmptyMutationSummary(); + } + + private cancelBufferedFlush(): void { + if (this.flushTimer > 0) { + clearTimeout(this.flushTimer); + this.flushTimer = 0; + } } private runCleanupCallbacks(): void { @@ -1046,7 +1502,7 @@ export class LiteCaptureAgent { }); } - private emitDomSnapshot(reason: string): void { + private emitDomSnapshot(reason: string): boolean { const nodeCount = document.getElementsByTagName("*").length; const summaryMode = this.resolveDomSnapshotSummaryMode(nodeCount); const html = buildDomSnapshotSummaryHtml({ @@ -1060,9 +1516,7 @@ export class LiteCaptureAgent { const truncated = true; const sampledHtml = html.slice(0, DOM_SNAPSHOT_MAX_HTML_CHARS); - this.hasDomSnapshot = true; - - this.queueEvent("snapshot", { + const queued = this.queueEvent("snapshot", { reason, href: readCurrentPageUrl(), title: document.title, @@ -1073,6 +1527,8 @@ export class LiteCaptureAgent { summaryOnly: true, summaryMode }); + this.hasDomSnapshot ||= queued; + return queued; } private emitStorageSnapshots(reason: string): void { @@ -1098,23 +1554,22 @@ export class LiteCaptureAgent { this.queueEvent("cookieSnapshot", { reason, count, - mode: "counts-only", + mode: "schema-only", redacted: true }); } - private emitLocalStorageSnapshot(reason: string): void { + private emitLocalStorageSnapshot(reason: string): boolean { const count = localStorage.length; - - this.hasLocalStorageSnapshot = true; - - this.queueEvent("localStorageSnapshot", { + const queued = this.queueEvent("localStorageSnapshot", { reason, count, truncated: false, - mode: "counts-only", + mode: "schema-only", redacted: true }); + this.hasLocalStorageSnapshot ||= queued; + return queued; } private async emitIndexedDbSnapshot(reason: string): Promise { @@ -1122,13 +1577,19 @@ export class LiteCaptureAgent { return; } + const expectedActivationId = this.activationId; + try { const rows = await indexedDB.databases(); + if (!expectedActivationId || this.activationId !== expectedActivationId) { + return; + } + this.queueEvent("indexedDbSnapshot", { reason, count: rows.length, - mode: "counts-only", + mode: "schema-only", redacted: true, truncated: false }); @@ -1269,7 +1730,14 @@ export class LiteCaptureAgent { } private async captureScreenshot(reason: string): Promise { - if (!this.recordingActive || !this.shouldCaptureScreenshots()) { + const expectedActivationId = this.activationId; + + if ( + !expectedActivationId || + this.activationId !== expectedActivationId || + !this.recordingActive || + !this.shouldCaptureScreenshots() + ) { return; } @@ -1310,15 +1778,15 @@ export class LiteCaptureAgent { if ( !screenshot || + this.activationId !== expectedActivationId || + !this.recordingActive || typeof screenshot.dataUrl !== "string" || screenshot.dataUrl.length > SCREENSHOT_MAX_DATA_URL_LENGTH ) { return; } - this.hasCapturedScreenshot = true; - - this.queueEvent("screenshot", { + const queued = this.queueEvent("screenshot", { reason, dataUrl: screenshot.dataUrl, format: screenshot.format, @@ -1332,6 +1800,9 @@ export class LiteCaptureAgent { }, pointer: this.readPointerSnapshot() }); + if (reason === "stop" && queued) { + this.stopScreenshotCaptured = true; + } } catch { void 0; } finally { @@ -1796,8 +2267,8 @@ export class LiteCaptureAgent { }; } - private queueEvent(rawType: string, payload: Record): void { - this.queueRawEvent({ + private queueEvent(rawType: string, payload: Record): boolean { + return this.queueRawEvent({ source: "content", rawType, tabId: this.tabId, @@ -1809,12 +2280,21 @@ export class LiteCaptureAgent { }); } - private queueRawEvent(event: RawRecorderEvent): void { + private queueRawEvent(event: RawRecorderEvent): boolean { if (this.mode === "full" && FULL_MODE_SKIPPED_RAW_TYPES.has(event.rawType)) { - return; + return false; + } + + if (this.recordingActive && !this.isDocumentWithinScope()) { + this.deactivateForScope(); + return false; } if (!this.recordingActive) { + if (!this.activationId) { + return false; + } + if (shouldBufferBeforeRecording(event)) { this.preRecordingBuffer.push(event); @@ -1824,19 +2304,102 @@ export class LiteCaptureAgent { this.preRecordingBuffer.length - PRE_RECORDING_BUFFER_MAX ); } + + return true; } - return; + return false; + } + + const eventActivationId = this.activationId; + + if (!eventActivationId) { + return false; } if (this.shouldDropEventForBackpressure(event)) { - return; + return false; + } + + if ( + this.eventBufferActivationId !== null && + this.eventBufferActivationId !== eventActivationId + ) { + this.clearActivationBuffers(); } + this.eventBufferActivationId = eventActivationId; this.eventBuffer.push(event); this.scheduleBufferedFlush( - this.eventBuffer.length >= EVENT_BUFFER_FORCE_FLUSH_SIZE ? 0 : EVENT_BUFFER_FLUSH_DELAY_MS + this.eventBuffer.length >= EVENT_BUFFER_FORCE_FLUSH_SIZE ? 0 : EVENT_BUFFER_FLUSH_DELAY_MS, + eventActivationId ); + return true; + } + + private isDocumentWithinScope(policy: CapturePolicy = this.capturePolicy): boolean { + return evaluateCaptureScope(policy, { + url: readDocumentUrl(), + tabId: this.tabId, + 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; + } + + const stoppingActivationId = this.activationId; + this.cancelBufferedFlush(); + this.recordingActive = false; + this.clearScopeExpiryTimer(); + this.stopMutationAndSnapshots(); + this.teardownCapture(); + this.removeIndicator(); + this.drainBufferedEvents(stoppingActivationId, true); + this.clearActivationBuffers(); + this.activationId = null; + this.resetInjectedBridgeBudget(); } private createClickPayload(event: MouseEvent): Record { @@ -1856,6 +2419,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 @@ -1918,37 +2528,30 @@ export class LiteCaptureAgent { return payload; } - private flushPreRecordingBuffer(): void { - if (!this.recordingActive || this.preRecordingBuffer.length === 0) { - return; - } - - this.eventBuffer.push(...this.preRecordingBuffer.splice(0, this.preRecordingBuffer.length)); - this.scheduleBufferedFlush(0); - } - - private flushEvents(): void { - if (this.flushTimer > 0) { - clearTimeout(this.flushTimer); - this.flushTimer = 0; - } - - if (this.eventBuffer.length === 0) { + private flushEvents(expectedActivationId: string): void { + if ( + !this.recordingActive || + this.activationId !== expectedActivationId || + this.eventBufferActivationId !== expectedActivationId || + this.eventBuffer.length === 0 + ) { return; } const events = this.eventBuffer.splice(0, EVENT_BUFFER_EMIT_CHUNK_SIZE); if (events.length > 0) { - this.options.emitBatch(events); + this.options.emitBatch(events, expectedActivationId); } if (this.eventBuffer.length > 0) { - this.scheduleBufferedFlush(0); + this.scheduleBufferedFlush(0, expectedActivationId); + } else { + this.eventBufferActivationId = null; } } - private scheduleBufferedFlush(delayMs: number): void { + private scheduleBufferedFlush(delayMs: number, expectedActivationId: string): void { if (this.flushTimer > 0) { if (delayMs > 0) { return; @@ -1958,21 +2561,30 @@ export class LiteCaptureAgent { this.flushTimer = 0; } - this.flushTimer = window.setTimeout( + const timer = window.setTimeout( () => { - this.flushEvents(); + if (this.flushTimer !== timer) { + return; + } + + this.flushTimer = 0; + this.flushEvents(expectedActivationId); }, Math.max(0, delayMs) ); + this.flushTimer = timer; } - private drainBufferedEvents(): void { - if (this.flushTimer > 0) { - clearTimeout(this.flushTimer); - this.flushTimer = 0; - } + private drainBufferedEvents(expectedActivationId: string | null, allowInactive = false): void { + this.cancelBufferedFlush(); - if (this.eventBuffer.length === 0) { + if ( + !expectedActivationId || + (!allowInactive && !this.recordingActive) || + this.activationId !== expectedActivationId || + this.eventBufferActivationId !== expectedActivationId || + this.eventBuffer.length === 0 + ) { return; } @@ -1983,8 +2595,10 @@ export class LiteCaptureAgent { break; } - this.options.emitBatch(events); + this.options.emitBatch(events, expectedActivationId); } + + this.eventBufferActivationId = null; } private shouldDropEventForBackpressure(event: RawRecorderEvent): boolean { @@ -2005,7 +2619,9 @@ export class LiteCaptureAgent { if (buffered >= EVENT_BUFFER_HARD_LIMIT || this.mode === "full") { if (buffered >= EVENT_BUFFER_HARD_LIMIT && this.mode !== "full") { this.dropBufferedLowPriorityEvents(buffered - EVENT_BUFFER_SOFT_LIMIT + 1); - this.scheduleBufferedFlush(0); + if (this.activationId) { + this.scheduleBufferedFlush(0, this.activationId); + } } this.droppedLowPriorityEvents += 1; @@ -2103,6 +2719,23 @@ export class LiteCaptureAgent { } } +function createStopCaptureResult( + expectedArtifacts: readonly LiteStopCaptureArtifact[], + emittedArtifacts: readonly LiteStopCaptureArtifact[], + degradationReason: LiteStopCaptureDegradationReason | null +): LiteStopCaptureResult { + const emitted = new Set(emittedArtifacts); + const omittedArtifacts = expectedArtifacts.filter((artifact) => !emitted.has(artifact)); + + return { + expectedArtifacts: [...expectedArtifacts], + emittedArtifacts: expectedArtifacts.filter((artifact) => emitted.has(artifact)), + omittedArtifacts, + degraded: degradationReason !== null || omittedArtifacts.length > 0, + degradationReason + }; +} + function sanitizeSamplingConfig(raw: unknown): LiteCaptureSampling { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ...DEFAULT_SAMPLING }; @@ -2147,6 +2780,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; @@ -2251,11 +2928,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"; + } + + if (target instanceof HTMLTextAreaElement) { + return "textarea"; } - return isRichTextEditableTarget(target); + 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 { @@ -2633,6 +3349,21 @@ function safeCanvasToDataUrl( } } +function serializeInjectedDirectEvent(event: InjectedDirectCaptureEvent): string | null { + try { + return JSON.stringify([ + event.activationId, + event.seq, + event.rawType, + event.payload, + event.t, + event.mono + ]); + } catch { + return null; + } +} + function shouldBufferBeforeRecording(event: RawRecorderEvent): boolean { if (event.source !== "content") { return false; diff --git a/packages/webblackbox/src/lite-materializer.test.ts b/packages/webblackbox/src/lite-materializer.test.ts index 5948baf..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( @@ -73,6 +83,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 +100,7 @@ describe("lite-materializer", () => { }); const result = await materializeLiteRawEvent(rawEvent, { - config: cloneConfig(), + config, putBlob: async (mime, bytes) => { putBlobCalls.push({ mime, bytes }); return "hash-shot"; @@ -102,8 +120,34 @@ 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(); + enableNetworkBodyCapture(config); config.sampling.bodyCaptureMaxBytes = 4 * 1024; const putBlobCalls: Array<{ mime: string; text: string; bytes: Uint8Array }> = []; @@ -133,7 +177,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 @@ -147,8 +192,118 @@ 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({ + 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(); + enableNetworkBodyCapture(config); + 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(); + enableNetworkBodyCapture(config); + 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(); + enableNetworkBodyCapture(config); config.sitePolicies = [ { originPattern: "https://example.test", @@ -179,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"); @@ -201,7 +389,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"); @@ -233,12 +421,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"); @@ -247,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 78221dc..07f0831 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; @@ -101,6 +103,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 = @@ -195,7 +201,7 @@ async function materializeLiteStorageSnapshot( ...rawEvent, payload: { count, - mode: "counts-only", + mode: "schema-only", redacted: true, reason, truncated: payload.truncated === true @@ -211,7 +217,7 @@ async function materializeLiteStorageSnapshot( ...rawEvent, payload: { count, - mode: "counts-only", + mode: "schema-only", redacted: true, reason, truncated: payload.truncated === true @@ -227,7 +233,7 @@ async function materializeLiteStorageSnapshot( ...rawEvent, payload: { count, - mode: "counts-only", + mode: "schema-only", redacted: true, reason, truncated: payload.truncated === true @@ -242,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) { @@ -254,7 +264,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; } @@ -264,26 +274,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, @@ -293,9 +299,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 } }; } @@ -448,7 +454,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(); @@ -477,46 +483,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/lite-sdk.test.ts b/packages/webblackbox/src/lite-sdk.test.ts index 4d5e108..75c68aa 100644 --- a/packages/webblackbox/src/lite-sdk.test.ts +++ b/packages/webblackbox/src/lite-sdk.test.ts @@ -1,6 +1,12 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import "fake-indexeddb/auto"; -import { readWebBlackboxArchive } from "@webblackbox/pipeline"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + IndexedDbPipelineStorage, + MemoryPipelineStorage, + readWebBlackboxArchive +} from "@webblackbox/pipeline"; import { DEFAULT_CAPTURE_POLICY, type CapturePolicy } from "@webblackbox/protocol"; import type { RawRecorderEvent } from "@webblackbox/recorder"; @@ -10,7 +16,8 @@ const mockRuntime = vi.hoisted(() => { statusHistory: Array>; flushCalls: number; disposeCalls: number; - emitBatch: (events: unknown[]) => void; + emitBatch: (events: unknown[], activationId: string) => void; + emitInjectedBridgeArmed: (activationId: string) => void; }> = []; class MockLiteCaptureAgent { @@ -22,7 +29,8 @@ const mockRuntime = vi.hoisted(() => { public constructor( private readonly options: { - emitBatch: (events: unknown[]) => void; + emitBatch: (events: unknown[], activationId: string) => void; + onInjectedBridgeArmed?: (activationId: string) => void; onMarker?: (message: string) => void; } ) { @@ -37,8 +45,12 @@ const mockRuntime = vi.hoisted(() => { this.options.onMarker?.(message); } - public emitBatch(events: unknown[]): void { - this.options.emitBatch(events); + public emitBatch(events: unknown[], activationId: string): void { + this.options.emitBatch(events, activationId); + } + + public emitInjectedBridgeArmed(activationId: string): void { + this.options.onInjectedBridgeArmed?.(activationId); } public flush(): void { @@ -76,6 +88,56 @@ vi.mock("./lite-capture-agent.js", () => { import { WebBlackboxLiteSdk } from "./lite-sdk.js"; +const ACTIVATION_ID_PATTERN = /^a1_[0-9a-f]{64}$/; + +type InjectedConfigDetail = { + active: boolean; + activationId: string; + bodyCaptureMaxBytes: number; + capturePolicy: CapturePolicy; +}; + +type LiteSdkActivationState = { + activeActivationId: string | null; + injectedBridgeArmedActivationId: string | null; +}; + +function captureInjectedConfigEvents(): InjectedConfigDetail[] { + const details: InjectedConfigDetail[] = []; + + vi.stubGlobal("window", { + dispatchEvent: (event: { detail?: InjectedConfigDetail; type?: string }) => { + if (event.type === "webblackbox:injected-config" && event.detail) { + details.push(event.detail); + } + + return true; + } + }); + vi.stubGlobal( + "CustomEvent", + class { + public readonly detail: T; + + public constructor( + public readonly type: string, + init: { detail: T } + ) { + this.detail = init.detail; + } + } + ); + + return details; +} + +function readLatestActivationId(agent: { statusHistory: Array> }): string { + const activationId = agent.statusHistory.at(-1)?.activationId; + + expect(activationId).toEqual(expect.stringMatching(ACTIVATION_ID_PATTERN)); + return activationId as string; +} + function createRawEvent( rawType: string, payload: Record, @@ -140,7 +202,12 @@ describe("WebBlackboxLiteSdk", () => { mockRuntime.installInjectedLiteCaptureHooksMock.mockReset(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("starts and stops recording with injected hooks wired", async () => { + const injectedConfigs = captureInjectedConfigEvents(); const sdk = new WebBlackboxLiteSdk({ sid: "S-sdk-start-stop", injectHookFlag: "__WB_TEST_FLAG__", @@ -162,24 +229,236 @@ describe("WebBlackboxLiteSdk", () => { await sdk.start(); const agent = mockRuntime.instances.at(-1); expect(agent).toBeDefined(); + const activationId = readLatestActivationId(agent!); expect(agent?.statusHistory.at(-1)).toMatchObject({ active: true, + activationId, sid: "S-sdk-start-stop", mode: "lite" }); + expect(injectedConfigs).toEqual([ + expect.objectContaining({ + active: true, + activationId, + bodyCaptureMaxBytes: 0 + }) + ]); await sdk.stop(); expect(agent?.statusHistory.at(-1)).toMatchObject({ active: false, + activationId, sid: "S-sdk-start-stop", mode: "lite" }); + expect(injectedConfigs.at(-1)).toMatchObject({ + active: false, + activationId, + bodyCaptureMaxBytes: 0 + }); + expect(sdk.isRecording).toBe(false); + expect((sdk as unknown as LiteSdkActivationState).activeActivationId).toBeNull(); + expect((sdk as unknown as LiteSdkActivationState).injectedBridgeArmedActivationId).toBeNull(); expect(agent?.flushCalls).toBeGreaterThan(0); await sdk.dispose(); expect(agent?.disposeCalls).toBe(1); }); + it("rotates activation identity for the same sid and rejects stale armed and batch callbacks", async () => { + const injectedConfigs = captureInjectedConfigEvents(); + const sdk = new WebBlackboxLiteSdk({ + sid: "S-sdk-same-sid-restart", + injectHooks: false, + useDefaultPlugins: false, + trustedPlaintextExemptionEvidenceRefs: [TRUSTED_LOCAL_DEBUG_EVIDENCE_REF], + config: { + capturePolicy: LOCAL_DEBUG_TEST_POLICY + } + }); + + await sdk.start(); + const agent = mockRuntime.instances.at(-1); + expect(agent).toBeDefined(); + const firstActivationId = readLatestActivationId(agent!); + + agent?.emitInjectedBridgeArmed(firstActivationId); + expect((sdk as unknown as LiteSdkActivationState).injectedBridgeArmedActivationId).toBe( + firstActivationId + ); + + await sdk.stop(); + expect((sdk as unknown as LiteSdkActivationState).activeActivationId).toBeNull(); + expect((sdk as unknown as LiteSdkActivationState).injectedBridgeArmedActivationId).toBeNull(); + + agent?.emitBatch( + [ + createRawEvent("marker", { + message: "stale-after-stop" + }) + ], + firstActivationId + ); + + await sdk.start(); + const secondActivationId = readLatestActivationId(agent!); + expect(secondActivationId).not.toBe(firstActivationId); + expect(sdk.sessionId).toBe("S-sdk-same-sid-restart"); + + agent?.emitInjectedBridgeArmed(firstActivationId); + expect((sdk as unknown as LiteSdkActivationState).injectedBridgeArmedActivationId).toBeNull(); + agent?.emitBatch( + [ + createRawEvent("marker", { + message: "stale-after-restart" + }) + ], + firstActivationId + ); + + agent?.emitInjectedBridgeArmed(secondActivationId); + agent?.emitBatch( + [ + createRawEvent("marker", { + message: "current-activation" + }) + ], + secondActivationId + ); + expect((sdk as unknown as LiteSdkActivationState).injectedBridgeArmedActivationId).toBe( + secondActivationId + ); + + const exported = await sdk.export({ stopCapture: false }); + const parsed = await readWebBlackboxArchive(exported.bytes); + const markerMessages = parsed.events + .filter((event) => event.type === "user.marker") + .map((event) => (event.data as { message?: unknown }).message); + + expect(markerMessages).toEqual(["current-activation"]); + expect(agent?.statusHistory).toEqual([ + expect.objectContaining({ + active: true, + activationId: firstActivationId, + sid: "S-sdk-same-sid-restart" + }), + expect.objectContaining({ + active: false, + activationId: firstActivationId, + sid: "S-sdk-same-sid-restart" + }), + expect.objectContaining({ + active: true, + activationId: secondActivationId, + sid: "S-sdk-same-sid-restart" + }) + ]); + expect(injectedConfigs).toEqual([ + expect.objectContaining({ active: true, activationId: firstActivationId }), + expect.objectContaining({ active: false, activationId: firstActivationId }), + expect.objectContaining({ active: true, activationId: secondActivationId }) + ]); + + await sdk.dispose(); + expect(sdk.isRecording).toBe(false); + expect((sdk as unknown as LiteSdkActivationState).activeActivationId).toBeNull(); + expect((sdk as unknown as LiteSdkActivationState).injectedBridgeArmedActivationId).toBeNull(); + expect(agent?.statusHistory.at(-1)).toMatchObject({ + active: false, + activationId: secondActivationId + }); + expect(injectedConfigs.at(-1)).toMatchObject({ + active: false, + activationId: secondActivationId + }); + }); + + it("fails closed when secure activation-id randomness is unavailable", async () => { + const injectedConfigs = captureInjectedConfigEvents(); + vi.stubGlobal("crypto", undefined); + const sdk = new WebBlackboxLiteSdk({ + sid: "S-sdk-no-secure-randomness", + injectHooks: false, + useDefaultPlugins: false + }); + const agent = mockRuntime.instances.at(-1); + + await expect(sdk.start()).rejects.toThrow(/requires secure cryptographic randomness/i); + expect(sdk.isRecording).toBe(false); + expect(agent?.statusHistory).toEqual([]); + expect(injectedConfigs).toEqual([]); + expect((sdk as unknown as LiteSdkActivationState).activeActivationId).toBeNull(); + + await sdk.dispose(); + 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", @@ -371,13 +650,17 @@ describe("WebBlackboxLiteSdk", () => { const agent = mockRuntime.instances.at(-1); expect(agent).toBeDefined(); + const activationId = readLatestActivationId(agent!); - agent?.emitBatch([ - createRawEvent("keydown", { - key: "Enter", - code: "Enter" - }) - ]); + agent?.emitBatch( + [ + createRawEvent("keydown", { + key: "Enter", + code: "Enter" + }) + ], + activationId + ); const exported = await sdk.export({ stopCapture: false }); const parsed = await readWebBlackboxArchive(exported.bytes); @@ -390,6 +673,44 @@ 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(); + const agent = mockRuntime.instances.at(-1); + expect(agent).toBeDefined(); + const activationId = readLatestActivationId(agent!); + agent?.emitBatch( + [ + createRawEvent("networkBody", { + reqId: "R-forged-agent", + url: "https://example.test/api/private", + mimeType: "application/json", + encoding: "utf8", + body: '{"secret":"must-not-reach-storage"}' + }) + ], + activationId + ); + 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({ @@ -429,6 +750,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..e810b5f 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. @@ -69,10 +75,20 @@ export class WebBlackboxLiteSdk { private pipelineFlushScheduled = false; + private queueFailure: Error | null = null; + + private rawQueueFailed = false; + + private pipelineQueueFailed = false; + private started = false; private recording = false; + private activeActivationId: string | null = null; + + private injectedBridgeArmedActivationId: string | null = null; + private disposed = false; /** @@ -83,7 +99,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, @@ -112,8 +132,11 @@ export class WebBlackboxLiteSdk { ); this.captureAgent = new LiteCaptureAgent({ - emitBatch: (events) => { - this.ingestRawEvents(events); + emitBatch: (events, activationId) => { + this.ingestCaptureAgentBatch(events, activationId); + }, + onInjectedBridgeArmed: (activationId) => { + this.handleInjectedBridgeArmed(activationId); }, showIndicator: options.showIndicator }); @@ -173,6 +196,12 @@ export class WebBlackboxLiteSdk { public async start(): Promise { this.assertNotDisposed(); + if (this.recording) { + return; + } + + const activationId = createLiteActivationId(); + if (!this.started) { await this.pipeline.start(); this.started = true; @@ -182,16 +211,47 @@ export class WebBlackboxLiteSdk { return; } + this.activeActivationId = activationId; + this.injectedBridgeArmedActivationId = null; this.recording = true; - this.syncInjectedHookConfig(true); - this.captureAgent.setRecordingStatus({ - active: true, - sid: this.sid, - tabId: this.tabId, - mode: "lite", - sampling: this.config.sampling, - capturePolicy: this.config.capturePolicy - }); + + try { + this.captureAgent.setRecordingStatus({ + active: true, + activationId, + sid: this.sid, + tabId: this.tabId, + mode: "lite", + sampling: this.config.sampling, + capturePolicy: this.config.capturePolicy + }); + this.syncInjectedHookConfig(true, activationId); + } catch (error) { + try { + this.syncInjectedHookConfig(false, activationId); + } catch { + // Preserve the activation failure that triggered rollback. + } + + try { + this.captureAgent.setRecordingStatus({ + active: false, + activationId, + sid: this.sid, + tabId: this.tabId, + mode: "lite", + sampling: this.config.sampling, + capturePolicy: this.config.capturePolicy + }); + } catch { + // Preserve the activation failure that triggered rollback. + } + + this.recording = false; + this.activeActivationId = null; + this.injectedBridgeArmedActivationId = null; + throw error; + } } /** Stops active capture and flushes all queued raw/normalized events. */ @@ -202,17 +262,32 @@ export class WebBlackboxLiteSdk { return; } - if (this.recording) { + const stoppingActivationId = this.activeActivationId; + + if (this.recording && stoppingActivationId) { + try { + try { + this.syncInjectedHookConfig(false, stoppingActivationId); + } finally { + this.captureAgent.setRecordingStatus({ + active: false, + activationId: stoppingActivationId, + sid: this.sid, + tabId: this.tabId, + mode: "lite", + sampling: this.config.sampling, + capturePolicy: this.config.capturePolicy + }); + } + } finally { + this.recording = false; + this.activeActivationId = null; + this.injectedBridgeArmedActivationId = null; + } + } else { this.recording = false; - this.syncInjectedHookConfig(false); - this.captureAgent.setRecordingStatus({ - active: false, - sid: this.sid, - tabId: this.tabId, - mode: "lite", - sampling: this.config.sampling, - capturePolicy: this.config.capturePolicy - }); + this.activeActivationId = null; + this.injectedBridgeArmedActivationId = null; } this.captureAgent.flush(); @@ -235,7 +310,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 +323,7 @@ export class WebBlackboxLiteSdk { } }) .catch((error) => { - console.warn("[WebBlackboxLiteSdk] failed to ingest raw event batch", error); + this.recordQueueFailure("raw event ingestion", error); }); } @@ -333,12 +408,40 @@ export class WebBlackboxLiteSdk { return; } - if (this.started) { - await this.stop(); + try { + if (this.started) { + await this.stop(); + } + } finally { + try { + this.captureAgent.dispose(); + } finally { + this.recording = false; + this.activeActivationId = null; + this.injectedBridgeArmedActivationId = null; + this.disposed = true; + } } + } - this.captureAgent.dispose(); - this.disposed = true; + private ingestCaptureAgentBatch(rawEvents: RawRecorderEvent[], activationId: string): void { + if (!this.recording || this.activeActivationId !== activationId) { + return; + } + + this.ingestRawEvents(rawEvents); + } + + private handleInjectedBridgeArmed(activationId: string): void { + if ( + !this.recording || + this.activeActivationId !== activationId || + this.injectedBridgeArmedActivationId === activationId + ) { + return; + } + + this.injectedBridgeArmedActivationId = activationId; } private async ingestOne(rawEvent: RawRecorderEvent): Promise { @@ -359,6 +462,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 +489,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 +502,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 +517,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 +538,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 { @@ -436,7 +574,7 @@ export class WebBlackboxLiteSdk { throw new Error("WebBlackboxLiteSdk has been disposed."); } - private syncInjectedHookConfig(active: boolean): void { + private syncInjectedHookConfig(active: boolean, activationId: string): void { if (typeof window === "undefined") { return; } @@ -445,6 +583,7 @@ export class WebBlackboxLiteSdk { new CustomEvent(INJECTED_CAPTURE_CONFIG_EVENT, { detail: { active, + activationId, bodyCaptureMaxBytes: active ? this.config.sampling.bodyCaptureMaxBytes : 0, capturePolicy: this.config.capturePolicy } @@ -453,6 +592,25 @@ export class WebBlackboxLiteSdk { } } +function createLiteActivationId(): string { + const cryptoApi = globalThis.crypto; + + if (!cryptoApi || typeof cryptoApi.getRandomValues !== "function") { + throw new Error("WebBlackbox Lite SDK requires secure cryptographic randomness to start."); + } + + const bytes = new Uint8Array(32); + cryptoApi.getRandomValues(bytes); + + let hex = ""; + + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, "0"); + } + + return `a1_${hex}`; +} + function normalizeSessionId(value: string | undefined): string { if (typeof value === "string" && value.trim().length > 0) { return value.trim(); @@ -632,7 +790,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); } @@ -643,19 +805,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/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; +} diff --git a/packages/webblackbox/src/types.ts b/packages/webblackbox/src/types.ts index 5d71f2b..47ade1c 100644 --- a/packages/webblackbox/src/types.ts +++ b/packages/webblackbox/src/types.ts @@ -22,6 +22,8 @@ export type LiteCaptureSampling = Pick< */ export type LiteCaptureState = { active: boolean; + /** Exact activation identity; active states fail closed unless this is `a1_` plus 64 hex. */ + activationId?: string; sid?: string; tabId?: number; mode?: SessionMetadata["mode"] | "freeze"; @@ -34,7 +36,9 @@ export type LiteCaptureState = { */ export type LiteCaptureAgentOptions = { /** Emits a normalized batch of raw recorder events. */ - emitBatch: (events: RawRecorderEvent[]) => void; + emitBatch: (events: RawRecorderEvent[], activationId: string) => void; + /** Receives the strict MAIN-world acknowledgement for the current Lite activation. */ + onInjectedBridgeArmed?: (activationId: string) => void; /** Optional marker callback used by custom hosts. */ onMarker?: (message: string) => void; /** Toggles in-page recording indicator UI. */ @@ -43,6 +47,23 @@ export type LiteCaptureAgentOptions = { frameScope?: "auto" | "top" | "child"; }; +export type LiteStopCaptureArtifact = "snapshot" | "localStorageSnapshot" | "screenshot"; + +export type LiteStopCaptureDegradationReason = + | "artifact-error" + | "disposed" + | "inactive" + | "out-of-scope"; + +/** Exact accounting for the final Lite artifacts requested at stop time. */ +export type LiteStopCaptureResult = Readonly<{ + expectedArtifacts: readonly LiteStopCaptureArtifact[]; + emittedArtifacts: readonly LiteStopCaptureArtifact[]; + omittedArtifacts: readonly LiteStopCaptureArtifact[]; + degraded: boolean; + degradationReason: LiteStopCaptureDegradationReason | null; +}>; + /** * Byte limits for payload materialization in lite mode. */ @@ -91,6 +112,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/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 + } + } } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4374cd..05f1280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,13 +4,36 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +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 + 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 + 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 + yaml@>=2.0.0 <2.8.3: 2.9.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 @@ -18,11 +41,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) @@ -43,25 +66,47 @@ 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.16)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) tsx: specifier: ^4.20.6 version: 4.21.0 turbo: - specifier: ^2.6.1 - version: 2.8.7 + specifier: 2.9.18 + version: 2.9.18 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)) + 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: @@ -84,6 +129,18 @@ importers: specifier: workspace:* version: link:../../packages/webblackbox devDependencies: + '@webblackbox/player-sdk': + specifier: workspace:* + version: link:../../packages/player-sdk + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + es-module-lexer: + specifier: ^2.3.0 + version: 2.3.0 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jszip: specifier: ^3.10.1 version: 3.10.1 @@ -91,8 +148,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 @@ -151,11 +208,17 @@ importers: apps/share-server: dependencies: + '@webblackbox/pipeline': + specifier: workspace:* + version: link:../../packages/pipeline '@webblackbox/player-sdk': specifier: workspace:* version: link:../../packages/player-sdk + '@webblackbox/protocol': + specifier: workspace:* + version: link:../../packages/protocol jszip: - specifier: ^3.10.1 + specifier: 3.10.1 version: 3.10.1 packages/cdp-router: @@ -183,7 +246,7 @@ importers: specifier: workspace:* version: link:../protocol jszip: - specifier: ^3.10.1 + specifier: 3.10.1 version: 3.10.1 packages/protocol: @@ -213,6 +276,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 @@ -229,12 +295,16 @@ 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'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} '@babel/parser@7.29.0': @@ -246,6 +316,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'} @@ -254,30 +328,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==} @@ -288,14 +362,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==} @@ -357,158 +431,161 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@esbuild/aix-ppc64@0.27.3': - resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@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] @@ -554,11 +631,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.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.29 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -604,8 +681,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 @@ -626,128 +703,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] @@ -802,6 +879,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==} @@ -814,6 +921,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==} @@ -896,43 +1006,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==} @@ -958,16 +1068,16 @@ 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@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - 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==} @@ -1029,8 +1139,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==} @@ -1039,6 +1149,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==} @@ -1049,15 +1163,16 @@ 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: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - 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==} @@ -1070,7 +1185,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==} @@ -1080,8 +1195,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: @@ -1108,9 +1223,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==} @@ -1118,10 +1230,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==} @@ -1177,9 +1285,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: @@ -1192,14 +1300,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==} @@ -1208,6 +1320,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'} @@ -1240,6 +1355,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'} @@ -1357,15 +1477,15 @@ 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.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: - resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1437,8 +1557,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: @@ -1453,8 +1573,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' @@ -1466,10 +1586,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'} @@ -1487,8 +1603,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==} @@ -1497,7 +1613,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 @@ -1543,8 +1659,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==} @@ -1647,16 +1763,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: @@ -1687,16 +1803,12 @@ 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==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} ieee754@1.2.1: @@ -1742,12 +1854,12 @@ 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.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: @@ -1828,8 +1940,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==} @@ -1841,12 +1953,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: @@ -1902,8 +2014,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==} @@ -1938,8 +2050,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==} @@ -1973,8 +2085,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: @@ -2027,15 +2139,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@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + minimatch@3.1.4: + resolution: {integrity: sha512-twmL+S8+7yIsE9wsqgzU3E8/LumN3M3QELrBZ20OdmQ9jB2JvW5oZtBEmft84k/Gs5CG9mqtWc6Y9vW+JEzGxw==} + + 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==} @@ -2057,8 +2173,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 @@ -2106,10 +2222,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==} @@ -2178,8 +2290,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==} @@ -2191,12 +2303,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: @@ -2224,9 +2336,9 @@ 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 + yaml: 2.9.0 peerDependenciesMeta: jiti: optional: true @@ -2237,8 +2349,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: @@ -2274,8 +2386,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: @@ -2284,8 +2396,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: @@ -2357,8 +2469,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 @@ -2419,8 +2531,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: @@ -2431,8 +2543,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: @@ -2474,8 +2586,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==} @@ -2567,8 +2679,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: @@ -2578,10 +2690,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'} @@ -2621,7 +2729,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': @@ -2638,38 +2746,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.9.18: + resolution: {integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==} hasBin: true type-check@0.4.0: @@ -2680,16 +2758,16 @@ 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==} + 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==} @@ -2734,8 +2812,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: @@ -2749,7 +2827,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 @@ -2774,20 +2852,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 @@ -2801,6 +2882,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: @@ -2850,9 +2935,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==} @@ -2861,8 +2946,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 @@ -2880,8 +2965,8 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true @@ -2889,10 +2974,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==} @@ -2911,13 +2996,20 @@ 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 + + '@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: @@ -2925,16 +3017,18 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': {} + '@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 @@ -2948,10 +3042,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 @@ -2961,30 +3055,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 @@ -2994,11 +3086,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 @@ -3008,19 +3101,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 @@ -3038,10 +3131,10 @@ 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 + js-yaml: 4.3.0 '@changesets/pre@2.0.2': dependencies: @@ -3050,11 +3143,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 @@ -3079,7 +3172,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': @@ -3137,82 +3230,84 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@esbuild/aix-ppc64@0.27.3': + '@epic-web/invariant@1.0.0': {} + + '@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))': @@ -3226,7 +3321,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.4 transitivePeerDependencies: - supports-color @@ -3240,14 +3335,14 @@ 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 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 + js-yaml: 4.3.0 + minimatch: 3.1.4 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -3269,9 +3364,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.14(hono@4.12.29)': dependencies: - hono: 4.11.9 + hono: 4.12.29 '@humanfs/core@0.19.1': {} @@ -3287,7 +3382,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 @@ -3307,39 +3402,39 @@ 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 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.14(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 @@ -3355,79 +3450,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': @@ -3457,8 +3552,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 @@ -3489,6 +3584,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': @@ -3500,6 +3613,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -3589,7 +3704,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) @@ -3613,58 +3728,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': {} @@ -3681,21 +3798,21 @@ 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: + 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: + ajv@8.20.0: 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 @@ -3743,7 +3860,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 @@ -3753,6 +3870,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base64-js@1.5.1: {} better-path-resolve@1.0.0: @@ -3765,28 +3884,28 @@ 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 - brace-expansion@1.1.12: + brace-expansion@1.1.16: dependencies: 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: @@ -3797,16 +3916,16 @@ 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: {} cac@6.7.14: {} - cachedir@2.3.0: {} + cachedir@2.4.0: {} call-bind-apply-helpers@1.0.2: dependencies: @@ -3833,16 +3952,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 @@ -3886,9 +4001,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 @@ -3896,10 +4011,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: @@ -3912,10 +4027,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: @@ -3924,6 +4041,8 @@ snapshots: meow: 13.2.0 optional: true + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -3947,12 +4066,17 @@ 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 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 @@ -3971,7 +4095,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 @@ -4054,40 +4178,40 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: 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: {} @@ -4118,7 +4242,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 @@ -4137,7 +4261,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: @@ -4165,7 +4289,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -4173,11 +4297,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: @@ -4185,16 +4309,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 @@ -4212,25 +4336,19 @@ 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 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: {} @@ -4247,15 +4365,15 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.2: {} fastq@1.20.1: 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: @@ -4308,14 +4426,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: {} @@ -4354,18 +4472,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: @@ -4384,7 +4502,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 @@ -4430,7 +4548,7 @@ snapshots: has-symbols@1.1.0: {} - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -4438,7 +4556,7 @@ snapshots: dependencies: parse-passwd: 1.0.0 - hono@4.11.9: {} + hono@4.12.29: {} html-encoding-sniffer@4.0.0: dependencies: @@ -4472,15 +4590,11 @@ 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: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -4516,15 +4630,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 @@ -4532,9 +4646,11 @@ 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.0.1: {} + ip-address@10.2.0: {} ipaddr.js@1.9.1: {} @@ -4594,7 +4710,7 @@ snapshots: jiti@2.6.1: optional: true - jose@6.1.3: {} + jose@6.2.3: {} joycon@3.1.1: {} @@ -4602,12 +4718,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 @@ -4631,7 +4747,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 @@ -4685,7 +4801,7 @@ snapshots: lines-and-columns@1.2.4: {} - linkify-it@5.0.0: + linkify-it@5.0.2: dependencies: uc.micro: 2.1.0 @@ -4697,7 +4813,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: @@ -4727,7 +4843,7 @@ snapshots: lodash.startcase@4.4.0: {} - lodash@4.17.21: {} + lodash@4.18.1: {} log-symbols@4.1.0: dependencies: @@ -4764,11 +4880,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 @@ -4791,7 +4907,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.54.0: {} @@ -4805,15 +4921,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 + brace-expansion: 1.1.16 - 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: @@ -4836,7 +4956,7 @@ snapshots: nano-spawn@2.0.0: {} - nanoid@3.3.11: {} + nanoid@3.3.15: {} natural-compare@1.4.0: {} @@ -4887,8 +5007,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: @@ -4947,7 +5065,7 @@ snapshots: path-key@3.1.1: {} - path-to-regexp@8.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -4955,9 +5073,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: {} @@ -4973,18 +5091,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.8.2): + 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.8.2 + 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 @@ -5011,21 +5129,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): @@ -5040,7 +5159,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 @@ -5094,35 +5213,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: @@ -5131,7 +5250,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 @@ -5170,7 +5289,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 @@ -5194,7 +5313,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 @@ -5214,11 +5333,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 @@ -5250,7 +5369,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.2.0: {} string-argv@0.3.2: {} @@ -5335,10 +5454,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: {} @@ -5346,10 +5465,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 @@ -5374,27 +5489,27 @@ 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.16)(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 - 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.16)(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 tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.16 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -5404,37 +5519,19 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.3 + esbuild: 0.28.1 get-tsconfig: 4.13.6 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: + turbo@2.9.18: 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/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: @@ -5442,20 +5539,20 @@ 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 - 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: @@ -5490,58 +5587,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 - postcss: 8.5.6 - rollup: 4.57.1 + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.16 + 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: @@ -5579,7 +5667,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 @@ -5593,17 +5681,17 @@ snapshots: wrappy@1.0.2: {} - ws@8.19.0: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} - yaml@2.8.2: {} + yaml@2.9.0: {} 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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3ff5faa..65b619b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,33 @@ packages: - "apps/*" - "packages/*" + +autoInstallPeers: true +strictPeerDependencies: false +sharedWorkspaceLockfile: true + +allowBuilds: + "esbuild@0.28.1": true + +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" + "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" + "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" + "yaml@>=2.0.0 <2.8.3": "2.9.0" diff --git a/scripts/archive-evidence.test.mjs b/scripts/archive-evidence.test.mjs new file mode 100644 index 0000000..4779e0e --- /dev/null +++ b/scripts/archive-evidence.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + verifyRealWorldArchiveEvidence, + verifyScreenRecordingArchiveEvidence +} from "../apps/extension/scripts/lib/archive-evidence.mjs"; + +test("real-world archive evidence reports exact missing markers, URLs, and event types", () => { + const player = createPlayer({ + events: [ + { + type: "console.entry", + data: { message: "[wb-realworld] checkout ready" } + }, + { + type: "network.request", + data: { url: "https://app.example/api/large-response?bytes=1024" } + } + ] + }); + + assert.deepEqual( + verifyRealWorldArchiveEvidence(player, { + markers: ["[wb-realworld] checkout ready", "[wb-realworld] upload complete"], + urls: ["/api/large-response", "/api/missing"], + eventTypes: ["console.entry", "dom.snapshot"] + }), + { + ok: false, + eventCount: 2, + missingMarkers: ["[wb-realworld] upload complete"], + missingUrls: ["/api/missing"], + missingEventTypes: ["dom.snapshot"] + } + ); +}); + +test("screen recording evidence reads every referenced recording blob", async () => { + const chunkA = "a".repeat(64); + const chunkB = "b".repeat(64); + const reads = []; + const player = createPlayer({ + events: [ + { type: "screen.recording.start", data: {} }, + { type: "screen.recording.chunk", data: { chunkId: chunkA } }, + { type: "screen.recording.end", data: { chunks: [chunkA, chunkB] } } + ], + files: { + [`blobs/${chunkA}.webm`]: "hash-a", + [`blobs/${chunkB}.webm`]: "hash-b" + }, + blobs: new Map([ + [chunkA, { mime: "video/webm", bytes: new Uint8Array([1, 2]) }], + [chunkB, { mime: "video/webm", bytes: new Uint8Array([3]) }] + ]), + reads + }); + + const result = await verifyScreenRecordingArchiveEvidence(player, { + captureScreenshots: false, + maxBlobBytes: 1024 + }); + + assert.equal(result.ok, true); + assert.equal(result.referencedChunks, 2); + assert.deepEqual(result.missingChunkBlobs, []); + assert.deepEqual(result.invalidChunkBlobs, []); + assert.deepEqual(reads, [ + [chunkA, 1024], + [chunkB, 1024] + ]); +}); + +test("screen recording evidence fails on unreadable chunks and screenshot leakage", async () => { + const missingChunk = "c".repeat(64); + const invalidChunk = "d".repeat(64); + const player = createPlayer({ + events: [ + { type: "screen.recording.start", data: {} }, + { type: "screen.recording.chunk", data: { chunkId: missingChunk } }, + { type: "screen.recording.end", data: { chunks: [missingChunk, invalidChunk] } }, + { type: "screen.screenshot", data: {} } + ], + files: { + [`blobs/${invalidChunk}.webm`]: "hash-video", + [`blobs/${"e".repeat(64)}.webp`]: "hash-screenshot" + }, + blobs: new Map([[invalidChunk, { mime: "video/webm", bytes: new Uint8Array() }]]) + }); + + const result = await verifyScreenRecordingArchiveEvidence(player, { + captureScreenshots: false, + maxBlobBytes: 1024 + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.missingChunkBlobs, [missingChunk]); + assert.deepEqual(result.invalidChunkBlobs, [ + { hash: invalidChunk, mime: "video/webm", bytes: 0 } + ]); + assert.equal(result.screenshotLeak, true); +}); + +function createPlayer({ events = [], files = {}, blobs = new Map(), reads = [] }) { + return { + events, + archive: { integrity: { files } }, + async readBlobTransient(hash, maxBytes) { + reads.push([hash, maxBytes]); + return blobs.get(hash) ?? null; + } + }; +} 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; } 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; +} 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; +} diff --git a/scripts/e2e-runtime.mjs b/scripts/e2e-runtime.mjs new file mode 100644 index 0000000..f8b1c0f --- /dev/null +++ b/scripts/e2e-runtime.mjs @@ -0,0 +1,194 @@ +import { constants } from "node:fs"; +import { access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { posix, win32 } from "node:path"; + +const WINDOWS_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD"; + +export function extensionChromeLoadArgs(extensionDirectory) { + if (typeof extensionDirectory !== "string" || extensionDirectory.trim().length === 0) { + throw new TypeError("Extension E2E directory must be a non-empty path."); + } + + return [ + // Chrome 137+ blocks --load-extension in branded builds. Keeping the + // disable-extensions-except path enabled preserves unpacked E2E loading, + // while unbranded Chromium continues to honor --load-extension directly. + "--disable-features=DisableDisableExtensionsExceptCommandLineSwitch", + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}` + ]; +} + +export function createE2eTempPath( + prefix, + extension = "", + { + temporaryDirectory = tmpdir(), + processId = process.pid, + timestamp = Date.now(), + platform = process.platform + } = {} +) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(prefix)) { + throw new TypeError(`Invalid E2E temporary-path prefix: ${prefix}`); + } + if (extension !== "" && !/^\.[A-Za-z0-9]{1,16}$/.test(extension)) { + throw new TypeError(`Invalid E2E temporary-path extension: ${extension}`); + } + if (!Number.isSafeInteger(processId) || processId < 0) { + throw new TypeError("E2E temporary-path process id must be a non-negative safe integer."); + } + if (!Number.isSafeInteger(timestamp) || timestamp < 0) { + throw new TypeError("E2E temporary-path timestamp must be a non-negative safe integer."); + } + + return pathApiForPlatform(platform).join( + temporaryDirectory, + `${prefix}-${processId}-${timestamp}${extension}` + ); +} + +export function executableSearchPaths( + candidate, + { + platform = process.platform, + pathValue = process.env.PATH ?? "", + pathExtValue = process.env.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT + } = {} +) { + if (typeof candidate !== "string") { + return []; + } + + const command = stripMatchingQuotes(candidate); + if (command.length === 0) { + return []; + } + const pathApi = pathApiForPlatform(platform); + if ( + pathApi.isAbsolute(command) || + command.startsWith(".") || + command.includes("/") || + command.includes("\\") + ) { + return [command]; + } + + const separator = platform === "win32" ? ";" : ":"; + const directories = String(pathValue) + .split(separator) + .map(stripMatchingQuotes) + .filter((directory) => directory.length > 0); + const commandNames = + platform === "win32" ? windowsCommandNames(command, pathExtValue, pathApi) : [command]; + const paths = []; + const seen = new Set(); + + for (const directory of directories) { + for (const commandName of commandNames) { + const path = pathApi.join(directory, commandName); + const key = platform === "win32" ? path.toLowerCase() : path; + if (!seen.has(key)) { + seen.add(key); + paths.push(path); + } + } + } + + return paths; +} + +export async function resolveExecutable( + candidate, + { + accessImplementation = access, + platform = process.platform, + pathValue = process.env.PATH ?? "", + pathExtValue = process.env.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT + } = {} +) { + for (const path of executableSearchPaths(candidate, { platform, pathValue, pathExtValue })) { + try { + await accessImplementation(path, constants.X_OK); + return path; + } catch { + // Continue searching the remaining explicit/PATH candidates. + } + } + + return null; +} + +export async function terminateProcess( + processHandle, + { graceMs = 5_000, forceKillWaitMs = 2_000 } = {} +) { + if (!processHandle || hasProcessExited(processHandle)) { + return; + } + + processHandle.kill("SIGTERM"); + + if (await waitForProcessExit(processHandle, graceMs)) { + return; + } + + processHandle.kill("SIGKILL"); + await waitForProcessExit(processHandle, forceKillWaitMs); +} + +function hasProcessExited(processHandle) { + return processHandle.exitCode !== null || processHandle.signalCode !== null; +} + +function waitForProcessExit(processHandle, timeoutMs) { + if (hasProcessExited(processHandle)) { + return Promise.resolve(true); + } + + return new Promise((resolvePromise) => { + const onExit = () => { + clearTimeout(timeout); + resolvePromise(true); + }; + const timeout = setTimeout( + () => { + processHandle.removeListener("exit", onExit); + resolvePromise(hasProcessExited(processHandle)); + }, + Math.max(0, timeoutMs) + ); + + processHandle.once("exit", onExit); + }); +} + +function windowsCommandNames(command, pathExtValue, pathApi) { + const extensions = String(pathExtValue) + .split(";") + .map((extension) => extension.trim()) + .filter((extension) => /^\.[A-Za-z0-9]+$/.test(extension)); + const normalizedExtensions = extensions.length > 0 ? extensions : [".EXE"]; + const commandExtension = pathApi.extname(command).toLowerCase(); + + if ( + commandExtension && + normalizedExtensions.some((extension) => extension.toLowerCase() === commandExtension) + ) { + return [command]; + } + + return normalizedExtensions.map((extension) => `${command}${extension}`); +} + +function stripMatchingQuotes(value) { + const trimmed = value.trim(); + return trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') + ? trimmed.slice(1, -1) + : trimmed; +} + +function pathApiForPlatform(platform) { + return platform === "win32" ? win32 : posix; +} diff --git a/scripts/e2e-runtime.test.mjs b/scripts/e2e-runtime.test.mjs new file mode 100644 index 0000000..5201772 --- /dev/null +++ b/scripts/e2e-runtime.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import test from "node:test"; + +import { + createE2eTempPath, + executableSearchPaths, + extensionChromeLoadArgs, + resolveExecutable, + terminateProcess +} from "./e2e-runtime.mjs"; + +test("keeps unpacked extension loading available in branded and unbranded test browsers", () => { + assert.deepEqual(extensionChromeLoadArgs("/tmp/WebBlackbox Extension"), [ + "--disable-features=DisableDisableExtensionsExceptCommandLineSwitch", + "--disable-extensions-except=/tmp/WebBlackbox Extension", + "--load-extension=/tmp/WebBlackbox Extension" + ]); + assert.throws(() => extensionChromeLoadArgs(""), /non-empty path/u); +}); + +test("creates platform-native E2E temporary paths", () => { + assert.equal( + createE2eTempPath("webblackbox-profile", "", { + temporaryDirectory: "/var/tmp", + processId: 42, + timestamp: 1_234, + platform: "linux" + }), + "/var/tmp/webblackbox-profile-42-1234" + ); + assert.equal( + createE2eTempPath("webblackbox-log", ".log", { + temporaryDirectory: "C:\\Temp", + processId: 42, + timestamp: 1_234, + platform: "win32" + }), + "C:\\Temp\\webblackbox-log-42-1234.log" + ); +}); + +test("builds POSIX PATH candidates without invoking which", () => { + assert.deepEqual( + executableSearchPaths("chromium", { + platform: "linux", + pathValue: "/opt/chrome/bin:/usr/local/bin:/usr/bin" + }), + ["/opt/chrome/bin/chromium", "/usr/local/bin/chromium", "/usr/bin/chromium"] + ); + assert.deepEqual( + executableSearchPaths("/Applications/Chrome For Testing", { + platform: "darwin", + pathValue: "/unused" + }), + ["/Applications/Chrome For Testing"] + ); +}); + +test("builds Windows PATH and PATHEXT candidates", () => { + assert.deepEqual( + executableSearchPaths("chrome", { + platform: "win32", + pathValue: '"C:\\Program Files\\Chrome";D:\\Tools', + pathExtValue: ".EXE;.CMD" + }), + [ + "C:\\Program Files\\Chrome\\chrome.EXE", + "C:\\Program Files\\Chrome\\chrome.CMD", + "D:\\Tools\\chrome.EXE", + "D:\\Tools\\chrome.CMD" + ] + ); + assert.deepEqual( + executableSearchPaths("chrome.exe", { + platform: "win32", + pathValue: "C:\\Chrome;D:\\Tools", + pathExtValue: ".EXE;.CMD" + }), + ["C:\\Chrome\\chrome.exe", "D:\\Tools\\chrome.exe"] + ); + assert.deepEqual( + executableSearchPaths('"C:\\Program Files\\Chrome\\chrome.exe"', { + platform: "win32", + pathValue: "C:\\Unused" + }), + ["C:\\Program Files\\Chrome\\chrome.exe"] + ); +}); + +test("resolves explicit paths and PATH commands with injected access checks", async () => { + const explicitChecks = []; + const explicit = await resolveExecutable("./fixtures/chrome", { + platform: "linux", + pathValue: "/unused", + async accessImplementation(path) { + explicitChecks.push(path); + } + }); + assert.equal(explicit, "./fixtures/chrome"); + assert.deepEqual(explicitChecks, ["./fixtures/chrome"]); + + const pathChecks = []; + const fromPath = await resolveExecutable("chrome", { + platform: "win32", + pathValue: "C:\\Missing;D:\\Chrome", + pathExtValue: ".EXE", + async accessImplementation(path) { + pathChecks.push(path); + if (path !== "D:\\Chrome\\chrome.EXE") { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + } + } + }); + assert.equal(fromPath, "D:\\Chrome\\chrome.EXE"); + assert.deepEqual(pathChecks, ["C:\\Missing\\chrome.EXE", "D:\\Chrome\\chrome.EXE"]); +}); + +test("force kills a child that ignores SIGTERM even after killed becomes true", async () => { + const processHandle = new EventEmitter(); + const signals = []; + processHandle.exitCode = null; + processHandle.signalCode = null; + processHandle.killed = false; + processHandle.kill = (signal) => { + signals.push(signal); + processHandle.killed = true; + + if (signal === "SIGKILL") { + processHandle.signalCode = signal; + queueMicrotask(() => processHandle.emit("exit", null, signal)); + } + + return true; + }; + + await terminateProcess(processHandle, { graceMs: 1, forceKillWaitMs: 10 }); + + assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]); +}); diff --git a/scripts/extension-e2e-controller.test.mjs b/scripts/extension-e2e-controller.test.mjs new file mode 100644 index 0000000..2d2df46 --- /dev/null +++ b/scripts/extension-e2e-controller.test.mjs @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import vm from "node:vm"; + +import { + deleteSessionFromSessionsPort, + exportSessionFromPopupRuntime, + startSessionFromPopupRuntime, + stopSessionFromPopupPort +} from "../apps/extension/scripts/lib/extension-e2e-controller.mjs"; + +const extensionBaseUrl = "chrome-extension://abcdefghijklmnopabcdefghijklmnop/"; + +test("popup runtime helper starts and exports through authorized runtime messages", async () => { + const messages = []; + const client = createEvaluationClient({ + page: "popup.html", + messages, + tabs: [{ id: 42, url: "http://127.0.0.1/demo/", active: true }] + }); + + const started = await startSessionFromPopupRuntime(client, { + mode: "full", + expectedUrl: "http://127.0.0.1/demo/", + visualCapture: "screenshots" + }); + const exported = await exportSessionFromPopupRuntime(client, { + sid: "session-1", + passphrase: "secret", + policy: { includeScreenshots: true } + }); + + assert.equal(started.ok, true); + assert.equal(started.via, "popup-runtime"); + assert.equal(exported.ok, true); + assert.deepEqual(messages, [ + { + kind: "ui.start", + tabId: 42, + mode: "full", + visualCapture: "screenshots" + }, + { + kind: "ui.export", + sid: "session-1", + passphrase: "secret", + saveAs: false, + policy: { includeScreenshots: true } + } + ]); +}); + +test("popup runtime helper rejects ambiguous tabs and missing acknowledgements", async () => { + const wrongTabClient = createEvaluationClient({ + page: "popup.html", + tabs: [{ id: 7, url: "http://127.0.0.1/unrelated/", active: true }] + }); + const missingTarget = await startSessionFromPopupRuntime(wrongTabClient, { + mode: "lite", + expectedUrl: "http://127.0.0.1/demo/" + }); + + assert.equal(missingTarget.ok, false); + assert.equal(missingTarget.reason, "target-tab-not-found"); + + const unacknowledgedClient = createEvaluationClient({ + page: "popup.html", + runtimeResponses: [undefined, {}], + tabs: [{ id: 42, url: "http://127.0.0.1/demo/", active: true }] + }); + const unacknowledgedStart = await startSessionFromPopupRuntime(unacknowledgedClient, { + mode: "lite", + expectedUrl: "http://127.0.0.1/demo/" + }); + const unacknowledgedExport = await exportSessionFromPopupRuntime(unacknowledgedClient, { + sid: "session-1", + passphrase: "secret" + }); + + assert.equal(unacknowledgedStart.ok, false); + assert.equal(unacknowledgedStart.reason, "runtime-start-rejected"); + assert.equal(unacknowledgedExport.ok, false); + assert.equal(unacknowledgedExport.reason, "runtime-export-rejected"); +}); + +test("popup stop helper uses the popup port instead of runtime messaging", async () => { + const messages = []; + const ports = []; + const client = createEvaluationClient({ page: "popup.html", messages, ports }); + + const stopped = await stopSessionFromPopupPort(client, { sid: "session-1", tabId: 42 }); + + assert.deepEqual(stopped, { + ok: true, + sid: "session-1", + tabId: 42, + via: "popup-port" + }); + assert.deepEqual(messages, []); + assert.deepEqual(ports, [ + { + name: "webblackbox:popup", + messages: [{ kind: "ui.stop", tabId: 42 }], + disconnected: true + } + ]); +}); + +test("session deletion requires sessions.html and the sessions port", async () => { + const popupPorts = []; + const popupClient = createEvaluationClient({ page: "popup.html", ports: popupPorts }); + const rejected = await deleteSessionFromSessionsPort(popupClient, { sid: "session-1" }); + + assert.equal(rejected.ok, false); + assert.equal(rejected.reason, "unexpected-controller-page"); + assert.deepEqual(popupPorts, []); + + const sessionsPorts = []; + const sessionsClient = createEvaluationClient({ page: "sessions.html", ports: sessionsPorts }); + const deleted = await deleteSessionFromSessionsPort(sessionsClient, { sid: "session-1" }); + + assert.deepEqual(deleted, { ok: true, sid: "session-1", via: "sessions-port" }); + assert.deepEqual(sessionsPorts, [ + { + name: "webblackbox:sessions", + messages: [{ kind: "ui.delete", sid: "session-1" }], + disconnected: true + } + ]); +}); + +function createEvaluationClient({ + page, + messages = [], + ports = [], + runtimeResponses = [], + tabs = [] +}) { + const location = { href: new URL(page, extensionBaseUrl).href }; + let runtimeResponseIndex = 0; + const chrome = { + runtime: { + connect({ name }) { + const record = { name, messages: [], disconnected: false }; + const listeners = new Set(); + ports.push(record); + return { + disconnect() { + record.disconnected = true; + }, + onMessage: { + addListener(listener) { + listeners.add(listener); + }, + removeListener(listener) { + listeners.delete(listener); + } + }, + postMessage(message) { + const cloned = structuredClone(message); + record.messages.push(cloned); + const sessions = + cloned.kind === "ui.stop" + ? [{ sid: "session-1", active: false }] + : cloned.kind === "ui.delete" + ? [] + : [{ sid: "session-1", active: true }]; + queueMicrotask(() => { + for (const listener of listeners) { + listener({ kind: "sw.session-list", sessions }); + } + }); + } + }; + }, + getURL(path) { + return new URL(path, extensionBaseUrl).href; + }, + async sendMessage(message) { + messages.push(structuredClone(message)); + + if (runtimeResponseIndex < runtimeResponses.length) { + const response = runtimeResponses[runtimeResponseIndex]; + runtimeResponseIndex += 1; + return response; + } + + return { ok: true }; + } + }, + tabs: { + async query() { + return tabs; + } + } + }; + + return { + async evaluate(expression) { + return structuredClone( + await vm.runInNewContext(expression, { + chrome, + clearTimeout, + location, + Promise, + queueMicrotask, + setTimeout + }) + ); + } + }; +} diff --git a/scripts/release-workflows.test.mjs b/scripts/release-workflows.test.mjs new file mode 100644 index 0000000..de0758b --- /dev/null +++ b/scripts/release-workflows.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("serializes stable package publishing across release tags", async () => { + const workflow = await readFile( + new URL("../.github/workflows/release.yml", import.meta.url), + "utf8" + ); + + assert.match( + workflow, + /^concurrency:\n[ ]{2}group: release-stable-publish\n[ ]{2}queue: max\n[ ]{2}cancel-in-progress: false$/m + ); + assert.doesNotMatch(workflow, /group: release-\$\{\{/); + assert.match(workflow, /git fetch --force --tags origin main:refs\/remotes\/origin\/main/); + assert.match( + workflow, + /- name: Verify publish artifacts\n[ ]{8}run: pnpm release:verify-artifacts/ + ); + assert.doesNotMatch(workflow, /npm pack --dry-run/); +}); + +test("serializes release assets and Pages deployments across tags", async () => { + const workflow = await readFile( + new URL("../.github/workflows/release-assets.yml", import.meta.url), + "utf8" + ); + const chromeJob = workflow.slice( + workflow.indexOf(" chrome-extension:"), + workflow.indexOf(" player-pages:") + ); + const playerJob = workflow.slice(workflow.indexOf(" player-pages:")); + const verifiedCheckout = /ref: \$\{\{ needs\.verify-release\.outputs\.verified_sha \}\}/; + + assert.match( + workflow, + /^concurrency:\n[ ]{2}group: release-assets-stable\n[ ]{2}queue: max\n[ ]{2}cancel-in-progress: false$/m + ); + assert.match(workflow, /git fetch --force --tags origin main:refs\/remotes\/origin\/main/); + assert.match( + workflow, + /outputs:\n[ ]{6}verified_sha: \$\{\{ steps\.verified_source\.outputs\.sha \}\}/ + ); + assert.match( + workflow, + /- name: Export verified release commit\n[ ]{8}id: verified_source\n[ ]{8}run: echo "sha=\$\(git rev-parse HEAD\)" >> "\$GITHUB_OUTPUT"/ + ); + assert.ok( + workflow.indexOf("- name: Export verified release commit") > + workflow.indexOf("- name: Verify successful CI for release commit") + ); + assert.match(chromeJob, verifiedCheckout); + assert.match(playerJob, verifiedCheckout); + assert.equal(workflow.match(new RegExp(verifiedCheckout.source, "g"))?.length, 2); + assert.doesNotMatch(chromeJob, /ref: \$\{\{ github\.event_name/); + assert.doesNotMatch(playerJob, /ref: \$\{\{ github\.event_name/); +}); diff --git a/scripts/verify-publish-artifacts.mjs b/scripts/verify-publish-artifacts.mjs new file mode 100644 index 0000000..efaec07 --- /dev/null +++ b/scripts/verify-publish-artifacts.mjs @@ -0,0 +1,632 @@ +import { execFile } from "node:child_process"; +import { constants as fsConstants } from "node:fs"; +import { access, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const MAX_COMMAND_OUTPUT_BYTES = 32 * 1024 * 1024; +const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const SEMVER_PATTERN = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const SAFE_PACKAGE_PATH_PATTERN = /^[A-Za-z0-9._@/-]+$/; +const WINDOWS_COMMAND_SHIM_PATTERN = /\.(?:cmd|bat)$/i; +const UNSAFE_WINDOWS_COMMAND_TOKEN_PATTERN = /["&|<>^()%!$`;*?]/u; + +export const PUBLIC_PACKAGE_DEFINITIONS = Object.freeze([ + Object.freeze({ name: "@webblackbox/protocol", directory: "packages/protocol" }), + Object.freeze({ name: "@webblackbox/cdp-router", directory: "packages/cdp-router" }), + Object.freeze({ name: "@webblackbox/recorder", directory: "packages/recorder" }), + Object.freeze({ name: "@webblackbox/pipeline", directory: "packages/pipeline" }), + Object.freeze({ name: "@webblackbox/player-sdk", directory: "packages/player-sdk" }), + Object.freeze({ name: "webblackbox", directory: "packages/webblackbox" }), + Object.freeze({ + name: "@webblackbox/mcp-server", + directory: "apps/mcp-server", + bin: Object.freeze({ "webblackbox-mcp-server": "./dist/cli.js" }) + }) +]); + +export const PUBLIC_PACKAGE_NAMES = Object.freeze( + PUBLIC_PACKAGE_DEFINITIONS.map(({ name }) => name) +); + +export function assertNoWorkspaceSpecifiers(manifest) { + const workspacePaths = []; + collectWorkspaceSpecifiers(manifest, "$", workspacePaths, new WeakSet()); + if (workspacePaths.length > 0) { + throw new Error( + `Packed manifest contains forbidden workspace: specifiers at ${workspacePaths.join(", ")}.` + ); + } +} + +export function assertCompletePackageSet(manifests, expectedNames = PUBLIC_PACKAGE_NAMES) { + const expected = new Set(expectedNames); + const counts = new Map(); + const unexpected = []; + + for (const manifest of manifests) { + const name = manifest?.name; + if (typeof name !== "string") { + throw new Error("Packed artifact manifest is missing a string package name."); + } + counts.set(name, (counts.get(name) ?? 0) + 1); + if (!expected.has(name)) { + unexpected.push(name); + } + } + + const missing = expectedNames.filter((name) => !counts.has(name)); + const duplicates = [...counts] + .filter(([, count]) => count !== 1) + .map(([name, count]) => `${name} (${count})`); + if (missing.length > 0 || unexpected.length > 0 || duplicates.length > 0) { + throw new Error( + [ + missing.length > 0 ? `missing: ${missing.join(", ")}` : null, + unexpected.length > 0 ? `unexpected: ${unexpected.join(", ")}` : null, + duplicates.length > 0 ? `duplicates: ${duplicates.join(", ")}` : null + ] + .filter(Boolean) + .join("; ") + ); + } +} + +export function validatePackedManifest({ manifest, entries, expectedPackage }) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new Error(`Packed artifact for ${expectedPackage.name} has an invalid manifest.`); + } + if (manifest.name !== expectedPackage.name || !isValidPackageName(manifest.name)) { + throw new Error( + `Packed artifact name ${JSON.stringify(manifest.name)} does not match ${expectedPackage.name}.` + ); + } + if (typeof manifest.version !== "string" || !SEMVER_PATTERN.test(manifest.version)) { + throw new Error(`Packed artifact ${manifest.name} has an invalid version.`); + } + if (manifest.private === true) { + throw new Error(`Packed artifact ${manifest.name} is unexpectedly private.`); + } + + assertNoWorkspaceSpecifiers(manifest); + validateTarEntries(entries, manifest.name); + validateFilesField(manifest, entries); + validateEntryPoint(manifest, entries, "main"); + validateEntryPoint(manifest, entries, "types"); + validateExportsField(manifest, entries); + validateBinField(manifest, entries, expectedPackage.bin); +} + +export function getPublicExportSpecifiers(manifest) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new Error("Cannot derive public exports from an invalid manifest."); + } + if (!isValidPackageName(manifest.name)) { + throw new Error("Cannot derive public exports without a valid package name."); + } + + const exportsField = manifest.exports; + if (typeof exportsField === "string" || exportsField === null || Array.isArray(exportsField)) { + return [manifest.name]; + } + if (!exportsField || typeof exportsField !== "object") { + throw new Error(`Packed artifact ${manifest.name} must declare exports.`); + } + + const keys = Object.keys(exportsField); + const subpathKeys = keys.filter((key) => key.startsWith(".")); + if (subpathKeys.length === 0) { + return [manifest.name]; + } + if (subpathKeys.length !== keys.length) { + throw new Error(`Packed artifact ${manifest.name} mixes export subpaths and conditions.`); + } + + return subpathKeys.map((key) => { + if (key === ".") { + return manifest.name; + } + if (!key.startsWith("./")) { + throw new Error(`Packed artifact ${manifest.name} has invalid export subpath ${key}.`); + } + const subpath = normalizePackageRelativePath(key.slice(2), `exports key ${key}`); + return `${manifest.name}/${subpath}`; + }); +} + +export function createCommandRunner({ + platform = process.platform, + commandInterpreter = process.env.ComSpec || "cmd.exe", + execFileRunner = execFileAsync +} = {}) { + return async function runCommand(command, arguments_, options = {}, label = command) { + const invocation = createCommandInvocation({ + command, + arguments_, + platform, + commandInterpreter + }); + + try { + const result = await execFileRunner(invocation.command, invocation.arguments, { + ...options, + ...invocation.options, + encoding: "utf8", + maxBuffer: MAX_COMMAND_OUTPUT_BYTES, + windowsHide: true + }); + return { + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? "") + }; + } catch (error) { + const stdout = + error && typeof error === "object" && "stdout" in error ? String(error.stdout) : ""; + const stderr = + error && typeof error === "object" && "stderr" in error ? String(error.stderr) : ""; + throw new Error( + `${label} failed.${stdout ? `\nstdout:\n${stdout}` : ""}${stderr ? `\nstderr:\n${stderr}` : ""}`, + { cause: error } + ); + } + }; +} + +export async function verifyPublishArtifacts({ + platform = process.platform, + workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."), + pnpmCommand = platform === "win32" ? "pnpm.cmd" : "pnpm", + npmCommand = platform === "win32" ? "npm.cmd" : "npm", + tarCommand = "tar", + commandRunner = createCommandRunner({ platform }) +} = {}) { + const temporaryRoot = await mkdtemp(join(tmpdir(), "webblackbox-publish-artifacts-")); + const artifactDirectory = join(temporaryRoot, "tarballs"); + const installDirectory = join(temporaryRoot, "install-smoke"); + + try { + await mkdir(artifactDirectory, { recursive: true }); + const artifacts = []; + + for (const expectedPackage of PUBLIC_PACKAGE_DEFINITIONS) { + const packageDirectory = resolve(workspaceRoot, expectedPackage.directory); + const sourceManifest = await readJsonFile(join(packageDirectory, "package.json")); + if (sourceManifest.name !== expectedPackage.name) { + throw new Error( + `Source package at ${expectedPackage.directory} is ${JSON.stringify(sourceManifest.name)}, expected ${expectedPackage.name}.` + ); + } + + const existingTarballs = new Set(await listTarballs(artifactDirectory)); + await commandRunner( + pnpmCommand, + ["pack", "--pack-destination", artifactDirectory], + { cwd: packageDirectory }, + `packing ${expectedPackage.name}` + ); + const addedTarballs = (await listTarballs(artifactDirectory)).filter( + (name) => !existingTarballs.has(name) + ); + if (addedTarballs.length !== 1) { + throw new Error( + `Packing ${expectedPackage.name} produced ${addedTarballs.length} new tarballs instead of one.` + ); + } + + const tarballPath = join(artifactDirectory, addedTarballs[0]); + const artifact = await readPackedArtifact(tarballPath, tarCommand, commandRunner); + validatePackedManifest({ + manifest: artifact.manifest, + entries: artifact.entries, + expectedPackage + }); + if (artifact.manifest.version !== sourceManifest.version) { + throw new Error( + `Packed ${expectedPackage.name} version ${artifact.manifest.version} does not match source ${sourceManifest.version}.` + ); + } + artifacts.push({ ...artifact, tarballPath, expectedPackage }); + } + + assertCompletePackageSet(artifacts.map(({ manifest }) => manifest)); + const versions = new Set(artifacts.map(({ manifest }) => manifest.version)); + if (versions.size !== 1) { + throw new Error( + `Public package artifacts are not versioned in lockstep: ${[...versions].join(", ")}.` + ); + } + + const importSpecifiers = artifacts + .filter(({ expectedPackage }) => expectedPackage.bin === undefined) + .flatMap(({ manifest }) => getPublicExportSpecifiers(manifest)); + if (new Set(importSpecifiers).size !== importSpecifiers.length) { + throw new Error("Public package artifacts expose duplicate import specifiers."); + } + + await installAndSmokeArtifacts({ + artifacts, + importSpecifiers, + installDirectory, + npmCommand, + platform, + commandRunner + }); + const version = artifacts[0]?.manifest.version; + console.log( + `Verified ${artifacts.length} publish artifacts at version ${version}: manifests, isolated install, ${importSpecifiers.length} public export imports, and MCP bin smoke passed.` + ); + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +async function readPackedArtifact(tarballPath, tarCommand, commandRunner) { + const listing = await commandRunner( + tarCommand, + ["-tzf", tarballPath], + undefined, + `listing ${basename(tarballPath)}` + ); + const entries = listing.stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.filter((entry) => entry === "package/package.json").length !== 1) { + throw new Error(`${basename(tarballPath)} must contain exactly one package/package.json.`); + } + + const manifestResult = await commandRunner( + tarCommand, + ["-xOzf", tarballPath, "package/package.json"], + undefined, + `reading ${basename(tarballPath)} package.json` + ); + let manifest; + try { + manifest = JSON.parse(manifestResult.stdout); + } catch (error) { + throw new Error(`${basename(tarballPath)} contains invalid package.json JSON.`, { + cause: error + }); + } + return { manifest, entries }; +} + +async function installAndSmokeArtifacts({ + artifacts, + importSpecifiers, + installDirectory, + npmCommand, + platform, + commandRunner +}) { + await mkdir(installDirectory, { recursive: true }); + await writeFile( + join(installDirectory, "package.json"), + `${JSON.stringify( + { + name: "webblackbox-publish-artifact-smoke", + version: "0.0.0", + private: true, + type: "module" + }, + null, + 2 + )}\n`, + "utf8" + ); + + await commandRunner( + npmCommand, + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-save", + "--package-lock=false", + ...artifacts.map(({ tarballPath }) => tarballPath) + ], + { + cwd: installDirectory, + env: { + ...process.env, + npm_config_update_notifier: "false" + } + }, + "installing packed artifacts" + ); + + for (const { manifest } of artifacts) { + const installedManifest = await readJsonFile( + join(installDirectory, "node_modules", ...manifest.name.split("/"), "package.json") + ); + if (installedManifest.version !== manifest.version) { + throw new Error( + `Installed ${manifest.name} version ${installedManifest.version} does not match packed ${manifest.version}.` + ); + } + } + + const smokeScriptPath = join(installDirectory, "import-smoke.mjs"); + await writeFile( + smokeScriptPath, + `const importSpecifiers = ${JSON.stringify(importSpecifiers)}; +for (const specifier of importSpecifiers) { + const namespace = await import(specifier); + if (!namespace || typeof namespace !== "object") { + throw new Error(\`Import did not return a module namespace: \${specifier}\`); + } +} +console.log(\`Imported \${importSpecifiers.length} public package exports.\`); +`, + "utf8" + ); + await commandRunner( + process.execPath, + [smokeScriptPath], + { cwd: installDirectory }, + "import smoke" + ); + + const mcpArtifact = artifacts.find(({ manifest }) => manifest.name === "@webblackbox/mcp-server"); + if (!mcpArtifact) { + throw new Error("MCP server artifact is missing from the install smoke."); + } + const binName = Object.keys(mcpArtifact.expectedPackage.bin ?? {})[0]; + const binPath = join( + installDirectory, + "node_modules", + ".bin", + platform === "win32" ? `${binName}.cmd` : binName + ); + await access(binPath, platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK); + const binResult = await commandRunner( + binPath, + ["--version"], + { cwd: installDirectory }, + "MCP bin smoke" + ); + if (binResult.stdout.trim() !== mcpArtifact.manifest.version || binResult.stderr.trim() !== "") { + throw new Error( + `MCP bin --version returned ${JSON.stringify(binResult.stdout.trim())} with stderr ${JSON.stringify(binResult.stderr.trim())}.` + ); + } +} + +function validateTarEntries(entries, packageName) { + if (entries.length === 0) { + throw new Error(`Packed artifact ${packageName} is empty.`); + } + for (const entry of entries) { + const normalized = entry.endsWith("/") ? entry.slice(0, -1) : entry; + if (!normalized.startsWith("package/") || !isSafePackageRelativePath(normalized.slice(8))) { + throw new Error(`Packed artifact ${packageName} contains unsafe tar entry ${entry}.`); + } + } +} + +function validateFilesField(manifest, entries) { + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + throw new Error(`Packed artifact ${manifest.name} must declare a non-empty files array.`); + } + const normalizedFiles = manifest.files.map((value) => + normalizePackageRelativePath(value, "files") + ); + if (new Set(normalizedFiles).size !== normalizedFiles.length) { + throw new Error(`Packed artifact ${manifest.name} has duplicate files entries.`); + } + for (const filePath of normalizedFiles) { + const archivePath = `package/${filePath}`; + if ( + !entries.some( + (entry) => entry.replace(/\/$/, "") === archivePath || entry.startsWith(`${archivePath}/`) + ) + ) { + throw new Error( + `Packed artifact ${manifest.name} files entry ${filePath} matched no content.` + ); + } + } +} + +function validateEntryPoint(manifest, entries, fieldName) { + const rawPath = manifest[fieldName]; + if (typeof rawPath !== "string") { + throw new Error(`Packed artifact ${manifest.name} must declare a string ${fieldName}.`); + } + validateArchiveTarget(manifest, entries, rawPath, fieldName); +} + +function validateExportsField(manifest, entries) { + if (manifest.exports === undefined) { + throw new Error(`Packed artifact ${manifest.name} must declare exports.`); + } + + getPublicExportSpecifiers(manifest); + const localTargets = []; + collectExportTargets(manifest.exports, "exports", localTargets); + if (localTargets.length === 0) { + throw new Error(`Packed artifact ${manifest.name} exports no local targets.`); + } + for (const { path, target } of localTargets) { + validateArchiveTarget(manifest, entries, target, path); + } +} + +function collectExportTargets(value, path, results) { + if (typeof value === "string") { + if (!value.startsWith("./")) { + throw new Error( + `Packed manifest ${path} must be a local target, received ${JSON.stringify(value)}.` + ); + } + results.push({ path, target: value }); + return; + } + if (value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => collectExportTargets(entry, `${path}[${index}]`, results)); + return; + } + if (!value || typeof value !== "object") { + throw new Error(`Packed manifest ${path} has an invalid exports target.`); + } + for (const [key, entry] of Object.entries(value)) { + collectExportTargets(entry, `${path}.${key}`, results); + } +} + +function validateArchiveTarget(manifest, entries, rawPath, fieldName) { + const filePath = normalizePackageRelativePath(rawPath, fieldName); + if (!entries.includes(`package/${filePath}`)) { + throw new Error(`Packed artifact ${manifest.name} ${fieldName} target ${filePath} is missing.`); + } +} + +function validateBinField(manifest, entries, expectedBin) { + const bin = normalizeBin(manifest); + const expected = expectedBin ?? {}; + if (JSON.stringify(bin) !== JSON.stringify(expected)) { + throw new Error( + `Packed artifact ${manifest.name} bin ${JSON.stringify(bin)} does not match ${JSON.stringify(expected)}.` + ); + } + for (const [command, rawPath] of Object.entries(bin)) { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(command)) { + throw new Error(`Packed artifact ${manifest.name} has invalid bin command ${command}.`); + } + validateArchiveTarget(manifest, entries, rawPath, `bin.${command}`); + } +} + +function normalizeBin(manifest) { + if (manifest.bin === undefined) { + return {}; + } + if (typeof manifest.bin === "string") { + const unscopedName = manifest.name.includes("/") ? manifest.name.split("/")[1] : manifest.name; + return { [unscopedName]: manifest.bin }; + } + if (!manifest.bin || typeof manifest.bin !== "object" || Array.isArray(manifest.bin)) { + throw new Error(`Packed artifact ${manifest.name} has an invalid bin field.`); + } + return Object.fromEntries( + Object.entries(manifest.bin).sort(([left], [right]) => left.localeCompare(right)) + ); +} + +function normalizePackageRelativePath(value, fieldName) { + if (typeof value !== "string") { + throw new Error(`Packed manifest ${fieldName} must contain strings.`); + } + const normalized = value.replace(/^\.\//, ""); + if (!isSafePackageRelativePath(normalized)) { + throw new Error(`Packed manifest ${fieldName} contains unsafe path ${JSON.stringify(value)}.`); + } + return normalized; +} + +function isSafePackageRelativePath(value) { + return ( + typeof value === "string" && + value.length > 0 && + value.length <= 512 && + SAFE_PACKAGE_PATH_PATTERN.test(value) && + !value.startsWith("/") && + !value.endsWith("/") && + !value.includes("\\") && + !value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ); +} + +function isValidPackageName(value) { + return typeof value === "string" && value.length <= 214 && PACKAGE_NAME_PATTERN.test(value); +} + +function collectWorkspaceSpecifiers(value, path, results, seen) { + if (typeof value === "string") { + if (value.trim().startsWith("workspace:")) { + results.push(path); + } + return; + } + if (!value || typeof value !== "object") { + return; + } + if (seen.has(value)) { + return; + } + seen.add(value); + + if (Array.isArray(value)) { + value.forEach((entry, index) => + collectWorkspaceSpecifiers(entry, `${path}[${index}]`, results, seen) + ); + return; + } + for (const [key, entry] of Object.entries(value)) { + collectWorkspaceSpecifiers(entry, `${path}.${key}`, results, seen); + } +} + +async function listTarballs(directory) { + return (await readdir(directory)).filter((name) => name.endsWith(".tgz")).sort(); +} + +async function readJsonFile(path) { + return JSON.parse(await readFile(path, "utf8")); +} + +function createCommandInvocation({ command, arguments_, platform, commandInterpreter }) { + if (platform !== "win32" || !WINDOWS_COMMAND_SHIM_PATTERN.test(command)) { + return { command, arguments: arguments_, options: {} }; + } + if (typeof commandInterpreter !== "string" || commandInterpreter.length === 0) { + throw new Error("Windows command interpreter must be a non-empty string."); + } + + const commandLine = [command, ...arguments_].map(quoteWindowsCommandToken).join(" "); + return { + command: commandInterpreter, + arguments: ["/d", "/s", "/v:off", "/c", `"${commandLine}"`], + options: { windowsVerbatimArguments: true } + }; +} + +function quoteWindowsCommandToken(value) { + if (typeof value !== "string") { + throw new Error("Windows command shim arguments must be strings."); + } + if ( + [...value].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint !== undefined && (codePoint <= 31 || codePoint === 127); + }) || + UNSAFE_WINDOWS_COMMAND_TOKEN_PATTERN.test(value) + ) { + throw new Error(`Unsafe Windows command shim token: ${JSON.stringify(value)}.`); + } + + const escapedTrailingBackslashes = value.replace(/(\\+)$/, "$1$1"); + return `"${escapedTrailingBackslashes}"`; +} + +function isDirectInvocation() { + return ( + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url) + ); +} + +if (isDirectInvocation()) { + verifyPublishArtifacts().catch((error) => { + console.error(error instanceof Error ? error.stack : error); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-publish-artifacts.test.mjs b/scripts/verify-publish-artifacts.test.mjs new file mode 100644 index 0000000..a578dc1 --- /dev/null +++ b/scripts/verify-publish-artifacts.test.mjs @@ -0,0 +1,273 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + PUBLIC_PACKAGE_DEFINITIONS, + PUBLIC_PACKAGE_NAMES, + assertCompletePackageSet, + assertNoWorkspaceSpecifiers, + createCommandRunner, + getPublicExportSpecifiers, + validatePackedManifest +} from "./verify-publish-artifacts.mjs"; + +test("rejects workspace specifiers recursively anywhere in a packed manifest", () => { + assert.throws( + () => + assertNoWorkspaceSpecifiers({ + name: "example", + dependencies: { + nested: { + resolutions: ["1.0.0", { protocol: "workspace:^" }] + } + } + }), + /\$\.dependencies\.nested\.resolutions\[1\]\.protocol/ + ); + + assert.doesNotThrow(() => + assertNoWorkspaceSpecifiers({ + dependencies: { + "@webblackbox/protocol": "0.6.0" + }, + files: ["dist"] + }) + ); +}); + +test("requires the complete public package set exactly once", () => { + const complete = PUBLIC_PACKAGE_NAMES.map((name) => ({ name })); + assert.doesNotThrow(() => assertCompletePackageSet(complete)); + assert.throws( + () => assertCompletePackageSet(complete.slice(1)), + /missing: @webblackbox\/protocol/ + ); + assert.throws( + () => assertCompletePackageSet([...complete, { name: PUBLIC_PACKAGE_NAMES[0] }]), + /duplicates: @webblackbox\/protocol \(2\)/ + ); + assert.throws( + () => assertCompletePackageSet([...complete, { name: "unexpected-package" }]), + /unexpected: unexpected-package/ + ); +}); + +test("validates declared files and MCP bin targets against tar contents", () => { + const expectedPackage = PUBLIC_PACKAGE_DEFINITIONS.find( + ({ name }) => name === "@webblackbox/mcp-server" + ); + const manifest = { + name: "@webblackbox/mcp-server", + version: "0.6.0", + files: ["dist", "README.md"], + main: "./dist/index.js", + types: "./dist/index.d.ts", + exports: { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js" + } + }, + bin: { + "webblackbox-mcp-server": "./dist/cli.js" + }, + dependencies: { + "@webblackbox/player-sdk": "0.6.0" + } + }; + const entries = [ + "package/package.json", + "package/dist/index.js", + "package/dist/index.d.ts", + "package/dist/cli.js", + "package/README.md" + ]; + + assert.doesNotThrow(() => validatePackedManifest({ manifest, entries, expectedPackage })); + assert.throws( + () => + validatePackedManifest({ + manifest: { + ...manifest, + bin: { "webblackbox-mcp-server": "../outside.js" } + }, + entries, + expectedPackage + }), + /bin .* does not match/ + ); + assert.throws( + () => + validatePackedManifest({ + manifest: { + ...manifest, + exports: { + ...manifest.exports, + "./missing": { + import: "./dist/missing.js" + } + } + }, + entries, + expectedPackage + }), + /exports\.\.\/missing\.import target dist\/missing\.js is missing/ + ); + assert.throws( + () => + validatePackedManifest({ + manifest: { ...manifest, main: "./dist/missing.js" }, + entries, + expectedPackage + }), + /main target dist\/missing\.js is missing/ + ); + assert.throws( + () => + validatePackedManifest({ + manifest: { ...manifest, types: "./dist/missing.d.ts" }, + entries, + expectedPackage + }), + /types target dist\/missing\.d\.ts is missing/ + ); +}); + +test("derives every public package export subpath for import smoke tests", () => { + assert.deepEqual( + getPublicExportSpecifiers({ + name: "webblackbox", + exports: { + ".": "./dist/index.js", + "./injected-hooks": { + types: "./dist/injected-hooks.d.ts", + import: "./dist/injected-hooks.js" + }, + "./lite-sdk": "./dist/lite-sdk.js" + } + }), + ["webblackbox", "webblackbox/injected-hooks", "webblackbox/lite-sdk"] + ); +}); + +test("launches Windows package command shims through a safely quoted cmd adapter", async () => { + const calls = []; + const commandInterpreter = "C:\\Windows\\System32\\cmd.exe"; + const runCommand = createCommandRunner({ + platform: "win32", + commandInterpreter, + execFileRunner: async (command, arguments_, options) => { + calls.push({ command, arguments_, options }); + return { stdout: "ok\n", stderr: "" }; + } + }); + + await runCommand( + "C:\\Program Files\\pnpm\\pnpm.cmd", + ["pack", "--pack-destination", "C:\\Temp Folder\\tarballs"], + { cwd: "C:\\Repo With Spaces" } + ); + await runCommand( + "npm.cmd", + ["install", "--package-lock=false", "C:\\Temp Folder\\package.tgz"], + {} + ); + await runCommand( + "C:\\Temp Folder\\node_modules\\.bin\\webblackbox-mcp-server.cmd", + ["--version"], + {} + ); + + assert.deepEqual( + calls.map(({ command, arguments_ }) => ({ command, arguments_ })), + [ + { + command: commandInterpreter, + arguments_: [ + "/d", + "/s", + "/v:off", + "/c", + '""C:\\Program Files\\pnpm\\pnpm.cmd" "pack" "--pack-destination" "C:\\Temp Folder\\tarballs""' + ] + }, + { + command: commandInterpreter, + arguments_: [ + "/d", + "/s", + "/v:off", + "/c", + '""npm.cmd" "install" "--package-lock=false" "C:\\Temp Folder\\package.tgz""' + ] + }, + { + command: commandInterpreter, + arguments_: [ + "/d", + "/s", + "/v:off", + "/c", + '""C:\\Temp Folder\\node_modules\\.bin\\webblackbox-mcp-server.cmd" "--version""' + ] + } + ] + ); + assert.equal(calls[0].options.cwd, "C:\\Repo With Spaces"); + assert.ok(calls.every(({ options }) => options.windowsVerbatimArguments === true)); +}); + +test("keeps non-Windows commands on the direct execFile path", async () => { + const calls = []; + const runCommand = createCommandRunner({ + platform: "linux", + execFileRunner: async (command, arguments_, options) => { + calls.push({ command, arguments_, options }); + return { stdout: "", stderr: "" }; + } + }); + + await runCommand("pnpm", ["pack", "--pack-destination", "/tmp/path with spaces"]); + + assert.equal(calls[0].command, "pnpm"); + assert.deepEqual(calls[0].arguments_, ["pack", "--pack-destination", "/tmp/path with spaces"]); + assert.equal(calls[0].options.windowsVerbatimArguments, undefined); +}); + +test("rejects Windows shim tokens that could escape cmd quoting or expand variables", async () => { + let calls = 0; + const runCommand = createCommandRunner({ + platform: "win32", + execFileRunner: async () => { + calls += 1; + return { stdout: "", stderr: "" }; + } + }); + const unsafeTokens = [ + "line\rbreak", + "line\nbreak", + "nul\0byte", + "%TEMP%", + "!TEMP!", + "$(whoami)", + 'quote"break', + "value&whoami", + "value|whoami", + "valueoutput", + "value^escape", + "value(parenthesized)" + ]; + + for (const unsafeToken of unsafeTokens) { + await assert.rejects( + runCommand("pnpm.cmd", [unsafeToken]), + /Unsafe Windows command shim token/ + ); + } + await assert.rejects( + runCommand("C:\\Unsafe&Path\\pnpm.cmd", ["pack"]), + /Unsafe Windows command shim token/ + ); + assert.equal(calls, 0); +}); diff --git a/scripts/verify-release-ref.mjs b/scripts/verify-release-ref.mjs new file mode 100644 index 0000000..5ff4eb4 --- /dev/null +++ b/scripts/verify-release-ref.mjs @@ -0,0 +1,198 @@ +#!/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*)$/; + +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" }) { + const expectedVersion = parseReleaseVersion(tag); + if (!expectedVersion) { + throw new Error( + `Release tag must be an exact semantic version tag (vX.Y.Z) compatible with Chrome (components 0..65535 and not all zero): ${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 newestRelease = findNewestStableRelease(root, mainRef); + if (newestRelease && compareStableVersions(newestRelease.version, expectedVersion) > 0) { + throw new Error( + `Release tag ${tag} is superseded by ${newestRelease.tag} on ${mainRef}; refusing to publish or deploy an older stable release` + ); + } + + 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( + `Release 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() + }; +} + +function parseReleaseVersion(tag) { + const match = releaseTagPattern.exec(tag); + if (!match) { + return undefined; + } + + const components = match.slice(1).map(Number); + if (components.some((component) => !Number.isSafeInteger(component) || component > 65_535)) { + return undefined; + } + if (components.every((component) => component === 0)) { + return undefined; + } + + return tag.slice(1); +} + +function findNewestStableRelease(root, mainRef) { + const output = runGit(root, ["tag", "--merged", mainRef, "--list", "v*"]); + let newest; + + for (const tag of output.split(/\r?\n/)) { + const version = parseReleaseVersion(tag); + if (!version) { + continue; + } + + if (!newest || compareStableVersions(version, newest.version) > 0) { + newest = { tag, version }; + } + } + + return newest; +} + +function compareStableVersions(left, right) { + const leftParts = left.split(".").map(Number); + const rightParts = right.split(".").map(Number); + + for (let index = 0; index < 3; index += 1) { + const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (delta !== 0) { + return delta; + } + } + + return 0; +} + +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 }); + 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; + } + + 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 release package manifest: ${packagePath}`); + } + if (expectedPrivateReleaseName && manifest.name !== expectedPrivateReleaseName) { + throw new Error( + `Invalid private release surface manifest: expected ${expectedPrivateReleaseName} at ${packagePath}` + ); + } + 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; +} + +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..f468051 --- /dev/null +++ b/scripts/verify-release-ref.test.mjs @@ -0,0 +1,224 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, 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", + "@webblackbox/extension", + "@webblackbox/player", + "@webblackbox/share-server" + ]); +}); + +test("accepts the Chrome extension version component boundary", async () => { + const root = await createRepository("65535.65535.65535"); + git(root, "tag", "v65535.65535.65535"); + + const result = await verifyReleaseRef({ + root, + tag: "v65535.65535.65535", + mainRef: "main" + }); + + assert.equal(result.version, "65535.65535.65535"); +}); + +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 prerelease and build-metadata tags", async () => { + const root = await createRepository("1.2.3"); + + for (const tag of ["v1.2.3-beta.1", "v1.2.3+build.7", "v1.2.3-beta.1+build.7"]) { + await assert.rejects( + verifyReleaseRef({ root, tag, mainRef: "main" }), + /exact semantic version tag \(vX\.Y\.Z\)/ + ); + } +}); + +test("rejects Chrome-incompatible stable versions", async () => { + for (const version of ["0.0.0", "65536.1.0"]) { + const root = await createRepository(version); + git(root, "tag", `v${version}`); + + await assert.rejects( + verifyReleaseRef({ root, tag: `v${version}`, mainRef: "main" }), + /compatible with Chrome/ + ); + } +}); + +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 a stable release tag superseded by a newer stable tag on main", async () => { + const root = await createRepository("1.2.3"); + git(root, "tag", "v1.2.3"); + await updateRepositoryVersion(root, "1.10.0"); + git(root, "add", "."); + git(root, "commit", "-m", "newer stable release"); + git(root, "tag", "v1.10.0"); + git(root, "checkout", "--detach", "v1.2.3"); + + await assert.rejects( + verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }), + /v1\.2\.3 is superseded by v1\.10\.0/ + ); +}); + +test("ignores non-stable tags when checking for a superseding release", async () => { + const root = await createRepository("1.2.3"); + git(root, "tag", "v1.2.3"); + await writeFile(resolve(root, "prerelease.txt"), "candidate\n"); + git(root, "add", "."); + git(root, "commit", "-m", "prerelease candidate"); + git(root, "tag", "v1.3.0-beta.1"); + git(root, "checkout", "--detach", "v1.2.3"); + + const result = await verifyReleaseRef({ root, tag: "v1.2.3", mainRef: "main" }); + + assert.equal(result.version, "1.2.3"); +}); + +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" }), + /Release package versions must match/ + ); +}); + +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", "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"); + git(root, "add", "."); + git(root, "commit", "-m", "initial"); + return root; +} + +async function updateRepositoryVersion(root, version) { + for (const relativePath of [ + "packages/library/package.json", + "apps/extension/package.json", + "apps/player/package.json", + "apps/share-server/package.json" + ]) { + const manifestPath = resolve(root, relativePath); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, version }, null, 2)}\n`); + } +} + +function git(root, ...args) { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }).trim(); +} 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" } ] }